Skip to main content
Glama
mind_map_horizontal.py22.7 kB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Horizontal Layout Mind Map Tool Generates optimized left-to-right mind maps from Markdown text. Features: - Overlap prevention using Subtree-Aware Layout Algorithm (Vertical) - Text-Width-Aware Horizontal Spacing (Horizontal Overlap Prevention) - Dynamic canvas expansion for complex trees - PIL-based Chinese font support """ import os import re import tempfile import time import math import shutil from typing import Any, Dict, Generator, List class MindMapHorizontalTool: def create_text_message(self, text: str) -> Dict[str, Any]: return {"type": "text", "text": text} def create_blob_message(self, blob: bytes, meta: Dict[str, Any]) -> Dict[str, Any]: return {"type": "blob", "blob": blob, "meta": meta} def create_json_message(self, data: Dict[str, Any]) -> Dict[str, Any]: return {"type": "json", "data": data} def _setup_pil_chinese_font(self, temp_dir): """ 使用PIL/Pillow进行中文字体处理的解决方案 - 优先使用嵌入字体 """ try: from PIL import Image, ImageDraw, ImageFont except ImportError: return None import platform system = platform.system() # 优先使用嵌入的字体文件 embedded_font_path = os.path.join(os.path.dirname(__file__), '..', 'fonts', 'chinese_font.ttc') embedded_font_path = os.path.abspath(embedded_font_path) if os.path.exists(embedded_font_path): return embedded_font_path # 查找系统中文字体文件(作为备用) font_file = None if system == 'Windows': font_paths = [ r'C:\Windows\Fonts\msyh.ttc', # 微软雅黑 r'C:\Windows\Fonts\simhei.ttf', # 黑体 r'C:\Windows\Fonts\simsun.ttc', # 宋体 ] for font_path in font_paths: if os.path.exists(font_path): font_file = font_path break elif system == 'Darwin': # macOS font_paths = [ '/System/Library/Fonts/STHeiti Light.ttc', '/System/Library/Fonts/PingFang.ttc', '/System/Library/Fonts/Hiragino Sans GB.ttc', ] for font_path in font_paths: if os.path.exists(font_path): font_file = font_path break else: # Linux font_paths = [ '/usr/share/fonts/wqy-microhei/wqy-microhei.ttc', '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', ] for font_path in font_paths: if os.path.exists(font_path): font_file = font_path break return font_file def _parse_markdown_to_tree(self, markdown_text: str) -> dict: """ Universal Markdown parser """ markdown_text = markdown_text.replace('\\n', '\n') lines = markdown_text.strip().split('\n') nodes = [] node_stack = [] last_header_level = 0 for line in lines: line = line.rstrip() if not line or line.startswith('```'): continue level = 0 content = "" is_header = False if line.startswith('#'): header_count = 0 for char in line: if char == '#': header_count += 1 else: break level = header_count content = line[header_count:].strip() is_header = True last_header_level = level elif re.match(r'^\s*\d+\.\s+', line): leading_spaces = len(line) - len(line.lstrip()) level = leading_spaces // 2 + 2 content = re.sub(r'^\s*\d+\.\s*', '', line) content = self._clean_markdown_text(content) elif re.match(r'^\s*[-\*\+]\s+', line): leading_spaces = len(line) - len(line.lstrip()) if leading_spaces == 0 and last_header_level > 0: level = last_header_level + 1 else: level = leading_spaces // 2 + 2 content = re.sub(r'^\s*[-\*\+]\s*', '', line) content = self._clean_markdown_text(content) else: continue if not content: continue node = { 'content': content, 'level': level, 'children': [] } if not is_header and not re.match(r'^\s*[-\*\+]\s+', line): last_header_level = 0 while node_stack and node_stack[-1]['level'] >= level: node_stack.pop() if node_stack: node_stack[-1]['children'].append(node) else: nodes.append(node) node_stack.append(node) if not nodes: return {'content': 'Mind Map', 'level': 1, 'children': []} if len(nodes) == 1: return nodes[0] return { 'content': 'Mind Map', 'level': 1, 'children': nodes } def _clean_markdown_text(self, text: str) -> str: text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) text = re.sub(r'\*(.*?)\*', r'\1', text) text = text.replace('《', '').replace('》', '') text = re.sub(r'\*\*(.*?)\*\*:\s*', r'\1: ', text) return text.strip() def _calculate_tree_depth(self, node: dict) -> int: if not node.get('children'): return 1 return 1 + max(self._calculate_tree_depth(child) for child in node['children']) def _get_all_nodes(self, node: dict) -> list: nodes = [node] for child in node.get('children', []): nodes.extend(self._get_all_nodes(child)) return nodes def _estimate_text_width(self, text: str, depth_level: int) -> float: """ Estimate text width in coordinate units. This maps text length + font size to our abstract coordinate system. """ # Base unit reference: # In _assign_coordinates, x_step was ~3.5 + len*0.05 # Let's make this more precise. # Assume 1 coordinate unit ≈ 40 pixels (just a reference scale) # Rough character width estimation # Chinese/Wide chars: 1.0 width # ASCII/Narrow chars: 0.6 width width_score = 0 for char in text: if ord(char) > 127: width_score += 1.0 else: width_score += 0.6 # Font scale factor decreases with depth # Level 1: Scale 1.0 # Level 2: Scale 0.9 # ... scale = max(1.0 - (depth_level - 1) * 0.1, 0.6) # Convert to coordinate units # Factor 0.4 found by experimentation to match visual look estimated_width = width_score * scale * 0.4 # Add padding (box borders) estimated_width += 0.8 # Padding return estimated_width def _calculate_subtree_layout_data(self, node: dict, depth_level: int = 1) -> float: """ Pass 1: Calculate vertical height AND estimate horizontal width for each node. """ children = node.get('children', []) content = node.get('content', 'Node') # Calculate and store self width node['_width'] = self._estimate_text_width(content, depth_level) # Basic height unit base_node_height = 1.0 if not children: node['_subtree_height'] = base_node_height return base_node_height children_total_height = 0 for child in children: children_total_height += self._calculate_subtree_layout_data(child, depth_level + 1) gap = 0.6 if len(children) > 1: children_total_height += (len(children) - 1) * gap node['_subtree_height'] = max(base_node_height, children_total_height) return node['_subtree_height'] def _assign_coordinates_to_tree(self, node, x, y_center, branch_colors, inherited_color, depth_level): """ Pass 2: Assign coordinates using variable width for precise spacing. """ children = node.get('children', []) if depth_level == 1: color = '#333333' else: color = inherited_color node['x'] = x node['y'] = y_center node['depth'] = depth_level node['color'] = color if not children: return # Calculate X position for children based on parent's actual width # We need: Parent Half Width + Connector Length + Child Half Width (variable) # Simplified: Parent Right Edge + Gap parent_width = node['_width'] connector_length = 2.0 # Fixed minimum length for the curved line max_child_width = 0 for child in children: max_child_width = max(max_child_width, child['_width']) dist_to_children = (parent_width / 2) + connector_length + (max_child_width / 2) child_x = x + dist_to_children # Calc Y positions total_children_height = sum(c['_subtree_height'] for c in children) gap = 0.6 if len(children) > 1: total_children_height += (len(children) - 1) * gap current_y = y_center + total_children_height / 2 for i, child in enumerate(children): child_height = child['_subtree_height'] child_y_center = current_y - child_height / 2 if depth_level == 1: child_color = branch_colors[i % len(branch_colors)] else: child_color = color self._assign_coordinates_to_tree(child, child_x, child_y_center, branch_colors, child_color, depth_level + 1) current_y -= (child_height + gap) def _get_all_nodes_with_coords(self, node): """Flatten tree to list, ensuring coords exist""" if 'x' not in node: return [] nodes = [node] for child in node.get('children', []): nodes.extend(self._get_all_nodes_with_coords(child)) return nodes def _draw_lines_recursive(self, ax, node): children = node.get('children', []) if not children: return start_x, start_y = node['x'], node['y'] # Line starts from right edge of parent text box parent_width = node['_width'] # Visual edge (where the box ends visually) visual_start_x = start_x + (parent_width / 2) # Actual line start (retracted into the box to ensure connection) # Retract by 40% of half-width to be safe against width estimation errors line_start_x = start_x + (parent_width / 2) * 0.6 for child in children: end_x, end_y = child['x'], child['y'] child_width = child['_width'] # Visual edge visual_end_x = end_x - (child_width / 2) # Actual line end (retracted) line_end_x = end_x - (child_width / 2) * 0.6 color = child['color'] linewidth = max(3 - child['depth'] * 0.3, 1) # Draw bezier using both visual and actual points self._draw_bezier_curve(ax, line_start_x, start_y, line_end_x, end_y, visual_start_x, visual_end_x, color, linewidth) # Recurse self._draw_lines_recursive(ax, child) def _draw_bezier_curve(self, ax, start_x, start_y, end_x, end_y, visual_start_x, visual_end_x, color, linewidth): import numpy as np # Calculate control points based on VISUAL boundaries for correct curvature shape # If we used retracted points, the curve might bend inside the box dist = math.sqrt((visual_end_x - visual_start_x)**2 + (end_y - start_y)**2) h_dist = abs(visual_end_x - visual_start_x) # Control points extending from visual edges cp_dist = min(h_dist * 0.6, 4.0) cp1_x = visual_start_x + cp_dist cp1_y = start_y cp2_x = visual_end_x - cp_dist cp2_y = end_y # Generate curve points t = np.linspace(0, 1, 50) # Bezier formula x = (1-t)**3 * start_x + 3*(1-t)**2*t * cp1_x + 3*(1-t)*t**2 * cp2_x + t**3 * end_x y = (1-t)**3 * start_y + 3*(1-t)**2*t * cp1_y + 3*(1-t)*t**2 * cp2_y + t**3 * end_y ax.plot(x, y, color=color, linewidth=linewidth, alpha=0.7) def _draw_text_with_pil(self, img, draw, x, y, text, depth_level, color, font_file): """ Draw text using PIL with high quality rendering """ try: from PIL import ImageFont safe_text = str(text).strip() if not safe_text: safe_text = f"Node" # Dynamic font size (unified with center layout) base_font_size = 42 font_size = max(base_font_size - (depth_level * 6), 24) font = None if font_file and os.path.exists(font_file): try: font = ImageFont.truetype(font_file, font_size) except Exception: pass if font is None: try: font = ImageFont.load_default() except: return # Measure text bbox = draw.textbbox((0, 0), safe_text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] # Padding (unified with center layout) padding = max(18 - depth_level * 2, 10) border_width = 4 if depth_level == 1 else 3 box_width = text_width + 2 * padding box_height = text_height + 2 * padding box_x1 = x - box_width // 2 box_y1 = y - box_height // 2 box_x2 = x + box_width // 2 box_y2 = y + box_height // 2 # Draw background draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=6, fill='white', outline=color, width=border_width) # Draw text centered try: draw.text((x, y), safe_text, font=font, fill=color, anchor='mm') except TypeError: text_x = x - text_width / 2 text_y = y - (bbox[1] + text_height / 2) draw.text((text_x, text_y), safe_text, font=font, fill=color) except Exception: pass def _generate_png_mindmap(self, tree_data: dict, output_file: str, temp_dir: str) -> bool: """ Generate PNG mind map using optimized layout engine """ try: # Setup fonts font_file = self._setup_pil_chinese_font(temp_dir) import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import numpy as np from PIL import Image, ImageDraw # 1. Calc heights AND widths self._calculate_subtree_layout_data(tree_data) # 2. Assign Coordinates (Store in tree nodes) branch_colors = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD', '#00D2D3', '#FF9F43', '#EE5A24', '#0984E3' ] self._assign_coordinates_to_tree(tree_data, 0, 0, branch_colors, '#333333', 1) # 3. Collect nodes for determining canvas size all_nodes = self._get_all_nodes_with_coords(tree_data) if not all_nodes: return False # Calculate exact bounding box including text widths min_x = float('inf') max_x = float('-inf') min_y = float('inf') max_y = float('-inf') for n in all_nodes: half_w = n['_width'] / 2 # Height is roughly fixed/estimated as 1.0 unit for calculation half_h = 0.5 min_x = min(min_x, n['x'] - half_w) max_x = max(max_x, n['x'] + half_w) min_y = min(min_y, n['y'] - half_h) max_y = max(max_y, n['y'] + half_h) # Add margins margin_x = 2.0 margin_y = 1.5 content_width = max_x - min_x + 2 * margin_x content_height = max_y - min_y + 2 * margin_y content_width = max(content_width, 12) content_height = max(content_height, 8) # Use a reasonable scale fig_width = content_width * 0.8 fig_height = content_height * 0.8 # Limit max size for safety if fig_width > 200: fig_width = 200 if fig_height > 200: fig_height = 200 plt.close('all') fig, ax = plt.subplots(1, 1, figsize=(fig_width, fig_height), dpi=100) ax.set_xlim(min_x - margin_x, max_x + margin_x) ax.set_ylim(min_y - margin_y, max_y + margin_y) ax.axis('off') # 4. Draw Lines self._draw_lines_recursive(ax, tree_data) # 5. Save Base Image plt.tight_layout(pad=0) ax.set_position([0, 0, 1, 1]) temp_base_file = os.path.join(temp_dir, "base_horizontal.png") plt.savefig(temp_base_file, dpi=100, facecolor='white', edgecolor='none') plt.close() # 6. Draw Text with PIL base_img = Image.open(temp_base_file) draw = ImageDraw.Draw(base_img) img_w, img_h = base_img.size x_range = (max_x + margin_x) - (min_x - margin_x) y_range = (max_y + margin_y) - (min_y - margin_y) def to_px(x, y): px = (x - (min_x - margin_x)) / x_range * img_w py = img_h - (y - (min_y - margin_y)) / y_range * img_h return px, py for node in all_nodes: px, py = to_px(node['x'], node['y']) self._draw_text_with_pil(base_img, draw, px, py, node['content'], node['depth'], node['color'], font_file) base_img.save(output_file, 'PNG') return True except Exception as e: import traceback traceback.print_exc() return False def _invoke(self, tool_parameters: dict) -> Generator[Dict[str, Any], None, None]: """ Invoke horizontal layout mind map generation """ try: markdown_content = tool_parameters.get('markdown_content', '').strip() filename = tool_parameters.get('filename', '').strip() if not markdown_content: yield self.create_text_message('Generation failed: No Markdown content.') return display_filename = filename if filename else f"mindmap_horizontal_{int(time.time())}" display_filename = re.sub(r'[^\w\-_\.]', '_', display_filename) if not display_filename.endswith('.png'): display_filename += '.png' with tempfile.TemporaryDirectory() as temp_dir: temp_output_path = os.path.join(temp_dir, display_filename) tree_data = self._parse_markdown_to_tree(markdown_content) success = self._generate_png_mindmap(tree_data, temp_output_path, temp_dir) if success and os.path.exists(temp_output_path): with open(temp_output_path, 'rb') as f: png_data = f.read() file_size = len(png_data) size_mb = file_size / (1024 * 1024) size_text = f"{size_mb:.2f}M" blob_message = self.create_blob_message( blob=png_data, meta={'mime_type': 'image/png', 'filename': display_filename} ) json_data = { "layout_type": "horizontal_optimized", "file_size_mb": round(size_mb, 2), "tree_depth": self._calculate_tree_depth(tree_data), "total_nodes": len(self._get_all_nodes(tree_data)), "filename": display_filename, "success": True } yield blob_message yield self.create_text_message(f'Horizontal mind map generated successfully! Size: {size_text}') yield self.create_json_message(json_data) else: yield self.create_text_message('Generation failed: Unable to create image file.') except Exception as e: yield self.create_text_message(f'Generation failed: {str(e)}') def get_tool(): return MindMapHorizontalTool

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sawyer-shi/mind-map-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server