#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Free Structure Mind Map Tool (Smart Layout)
Generates optimized mind maps from Markdown text.
Automatically selects between Center (Radial) and Horizontal (Left-Right) layouts
based on content complexity and structure depth.
"""
import os
import re
import tempfile
import time
import math
import shutil
from typing import Any, Dict, Generator, List, Tuple
class MindMapFreeTool:
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
# Handle headers (# ## ###)
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
# Handle numbered lists
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)
# Handle bullet lists
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
# Create node
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:
"""Clean markdown formatting from text"""
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:
"""Calculate the maximum depth of the tree structure"""
if not node.get('children'):
return 1
return 1 + max((self._calculate_tree_depth(child) for child in node['children']), default=0)
def _get_all_nodes(self, node: dict) -> List[dict]:
"""Get all nodes in the tree for analysis"""
nodes = [node]
for child in node.get('children', []):
nodes.extend(self._get_all_nodes(child))
return nodes
def _analyze_structure_complexity(self, tree_data: dict) -> str:
"""
Analyze tree complexity to decide layout.
Returns: 'center' or 'horizontal'
Rules:
- Use center layout when depth <= 4 AND total_nodes <= 100
- Use horizontal layout otherwise (for deep or large structures)
"""
depth = self._calculate_tree_depth(tree_data)
nodes = self._get_all_nodes(tree_data)
total_nodes = len(nodes)
# Use center layout for moderate complexity (depth <= 4 and nodes <= 100)
if depth <= 4 and total_nodes <= 100:
return 'center'
# Use horizontal layout for deep or large structures
return 'horizontal'
def _draw_text_with_pil(self, img, draw, x, y, text, depth_level, color, font_file):
"""
Unified PIL text drawing function
"""
try:
from PIL import ImageFont, ImageDraw
safe_text = str(text).strip()
if not safe_text:
safe_text = f"Node{depth_level}"
# Font size
base_font_size = 42
font_size = max(base_font_size - (depth_level * 6), 24)
# Load font
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
padding = max(18 - depth_level * 2, 10)
if depth_level == 1:
border_width = 4
else:
border_width = 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 rounded rectangle
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
# ==========================================
# Center (Radial) Layout Specific Methods
# ==========================================
def _calculate_subtree_weight(self, node: dict) -> int:
"""Calculate weight of subtree for radial distribution"""
if not node.get('children'):
node['weight'] = 1
return 1
weight = sum(self._calculate_subtree_weight(child) for child in node['children'])
node['weight'] = weight
return weight
def _measure_text_size(self, text: str, depth_level: int, font_file: str = None) -> Tuple[int, int]:
"""Estimate text dimensions for collision detection in Center mode"""
try:
from PIL import ImageFont, ImageDraw, Image
safe_text = str(text).strip()
if not safe_text:
safe_text = f"Node{depth_level}"
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 len(safe_text) * font_size * 0.6 + 20, font_size + 20
dummy_img = Image.new('RGB', (1, 1))
draw = ImageDraw.Draw(dummy_img)
bbox = draw.textbbox((0, 0), safe_text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
padding = max(18 - depth_level * 2, 10)
return text_width + 2 * padding, text_height + 2 * padding
except Exception:
return len(str(text)) * 15 + 20, 40
def _generate_center_layout(self, tree_data: dict, output_file: str, temp_dir: str) -> bool:
"""
Generate Center/Radial Mind Map with optimized compact layout
Same implementation as mind_map_center.py for consistency
"""
try:
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
self._calculate_subtree_weight(tree_data)
branch_colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3',
'#54A0FF', '#5F27CD', '#00D2D3', '#FF9F43', '#EE5A24', '#0984E3'
]
# Store layout results: {'x', 'y', 'w', 'h', 'text', 'depth', 'color', 'children': []}
layout_nodes = []
# To check for collisions: list of (x, y, w, h)
placed_boxes = []
# Track minimum radius for each depth level to enforce strict hierarchy
min_radius_by_depth = {} # {depth_level: min_radius}
# Track parent radius to ensure children are always further out
parent_radius_map = {} # {node_id: parent_radius}
def check_collision(x, y, w, h, margin=20):
"""Check if the box collides with any existing boxes"""
# Simple AABB collision
l1, r1 = x - w/2 - margin, x + w/2 + margin
t1, b1 = y - h/2 - margin, y + h/2 + margin
for bx, by, bw, bh in placed_boxes:
l2, r2 = bx - bw/2, bx + bw/2
t2, b2 = by - bh/2, by + bh/2
if not (l1 > r2 or r1 < l2 or t1 > b2 or b1 < t2):
return True
return False
def get_min_radius_for_depth(depth_level, parent_radius=0, parent_size=None, current_size=None):
"""
Calculate minimum radius for a depth level to ensure strict hierarchy.
Uses node sizes to calculate compact but safe distances.
"""
# Reduced base minimum radius for more compact layout
# Original: 150 + (depth_level - 1) * 200
# Optimized: 100 + (depth_level - 1) * 120
base_min_radius = 100 + (depth_level - 1) * 120
# Ensure child radius is always greater than parent radius
if parent_radius > 0:
# Calculate safe distance based on actual node sizes
if parent_size and current_size:
# parent_size and current_size are (width, height) tuples
parent_diagonal = math.sqrt(parent_size[0]**2 + parent_size[1]**2) / 2
current_diagonal = math.sqrt(current_size[0]**2 + current_size[1]**2) / 2
# Safe distance: half of parent diagonal + half of current diagonal + small gap
safe_distance = parent_diagonal + current_diagonal + 30 # Reduced from fixed 120
else:
# Fallback: use smaller fixed distance
safe_distance = 60 # Reduced from 120
required_radius = parent_radius + safe_distance
base_min_radius = max(base_min_radius, required_radius)
# Update global minimum for this depth level
if depth_level not in min_radius_by_depth:
min_radius_by_depth[depth_level] = base_min_radius
else:
min_radius_by_depth[depth_level] = max(min_radius_by_depth[depth_level], base_min_radius)
return min_radius_by_depth[depth_level]
def layout_recursive(node, parent_x, parent_y, start_angle, end_angle, depth_level, inherited_color, parent_radius=0, parent_size=None, node_id=None):
"""
Recursive layout with strict hierarchy enforcement and enhanced collision detection.
Ensures all elements extend outward only, never inward.
Optimized for compact layout while maintaining no-overlap guarantee.
"""
content = node.get('content', 'Node')
children = node.get('children', [])
# Generate unique node ID for tracking
if node_id is None:
node_id = f"node_{depth_level}_{id(node)}"
# Measure text size
w, h = self._measure_text_size(content, depth_level, font_file)
current_size = (w, h)
if depth_level == 1:
# Root node
x, y = 0, 0
node_color = '#333333'
current_radius = 0
else:
node_color = inherited_color
# Calculate minimum radius for this depth level (strict hierarchy)
# Pass node sizes for more accurate distance calculation
min_radius = get_min_radius_for_depth(depth_level, parent_radius, parent_size, current_size)
# Preliminary polar coordinates calculation
mid_angle = (start_angle + end_angle) / 2
# Start from minimum radius (never go inward)
radius_base = min_radius
# Optimized collision resolution with smaller, more precise steps
# Use smaller base step for finer positioning
base_step = 20 + (depth_level - 1) * 5 # Reduced from 60 + (depth-1)*10
max_attempts = 150 # More attempts with smaller steps
final_x, final_y = 0, 0
placed = False
current_radius = 0
# Try placing along the radial line, pushing outwards if collision
# CRITICAL: Never allow inward movement - always start from min_radius
for attempt in range(max_attempts):
# Use smaller, more precise steps for compact layout
# Only slightly increase step size for later attempts
step = base_step * (1 + attempt * 0.05) # Reduced from 0.1 to 0.05
test_r = radius_base + attempt * step
# Ensure we never go inward (strict hierarchy enforcement)
if test_r < min_radius:
test_r = min_radius
test_x = test_r * math.cos(mid_angle)
test_y = test_r * math.sin(mid_angle)
if not check_collision(test_x, test_y, w, h):
final_x, final_y = test_x, test_y
current_radius = test_r
placed = True
break
if not placed:
# Fallback: place at maximum attempted radius
final_x = test_r * math.cos(mid_angle)
final_y = test_r * math.sin(mid_angle)
current_radius = test_r
x, y = final_x, final_y
# Update minimum radius for this depth level based on actual placement
# But don't update too aggressively to avoid pushing everything out
if current_radius > min_radius_by_depth.get(depth_level, 0):
# Only update if significantly larger (avoid minor updates that push everything out)
if current_radius > min_radius_by_depth.get(depth_level, 0) * 1.2:
min_radius_by_depth[depth_level] = current_radius
# Store parent radius for children
parent_radius_map[node_id] = current_radius
# Register placed box
placed_boxes.append((x, y, w, h))
# Store node info for drawing
node_info = {
'x': x, 'y': y,
'parent_x': parent_x, 'parent_y': parent_y,
'text': content, 'depth': depth_level,
'color': node_color, 'width': w, 'height': h
}
layout_nodes.append(node_info)
# Process children
if children:
total_weight = sum(child.get('weight', 1) for child in children)
angle_range = end_angle - start_angle
current_angle = start_angle
for i, child in enumerate(children):
child_weight = child.get('weight', 1)
child_angle_step = (child_weight / total_weight) * angle_range
child_start = current_angle
child_end = current_angle + child_angle_step
# Determine color
if depth_level == 1:
child_c = branch_colors[i % len(branch_colors)]
else:
child_c = inherited_color
# Pass parent radius and size to ensure child is always further out
child_node_id = f"{node_id}_child_{i}"
layout_recursive(child, x, y, child_start, child_end, depth_level + 1, child_c,
parent_radius=current_radius, parent_size=current_size, node_id=child_node_id)
current_angle += child_angle_step
# Start Layout
layout_recursive(tree_data, 0, 0, 0, 2*math.pi, 1, '#333333', parent_radius=0, parent_size=None, node_id='root')
# Calculate dynamic canvas size with enhanced margin
if not layout_nodes:
return False
# Calculate bounding box with node dimensions
min_x = min(n['x'] - n['width']/2 for n in layout_nodes)
max_x = max(n['x'] + n['width']/2 for n in layout_nodes)
min_y = min(n['y'] - n['height']/2 for n in layout_nodes)
max_y = max(n['y'] + n['height']/2 for n in layout_nodes)
# Calculate maximum radius to ensure adequate space
max_radius = 0
for n in layout_nodes:
node_radius = math.sqrt(n['x']**2 + n['y']**2)
# Add half of node diagonal to account for node size
node_diagonal = math.sqrt(n['width']**2 + n['height']**2) / 2
total_radius = node_radius + node_diagonal
max_radius = max(max_radius, total_radius)
# Enhanced margin calculation based on maximum radius and depth
# Deeper structures need more margin
max_depth = max(n['depth'] for n in layout_nodes) if layout_nodes else 1
base_margin = 150
depth_margin = max_depth * 30 # Additional margin per depth level
margin = base_margin + depth_margin
# Calculate canvas size with enhanced margin
total_width = max_x - min_x + 2 * margin
total_height = max_y - min_y + 2 * margin
# Ensure minimum size based on maximum radius
min_size_from_radius = (max_radius + margin) * 2
total_width = max(total_width, min_size_from_radius, 1000)
total_height = max(total_height, min_size_from_radius, 800)
# Matplotlib figsize is in inches. Assume dpi=100
dpi = 100
fig_width = total_width / dpi
fig_height = total_height / dpi
# Re-create figure with calculated size
plt.close() # Close initial dummy figure
fig, ax = plt.subplots(1, 1, figsize=(fig_width, fig_height), dpi=dpi)
# Set limits to match our coordinate system
ax.set_xlim(min_x - margin, max_x + margin)
ax.set_ylim(min_y - margin, max_y + margin)
ax.axis('off')
# Helper for drawing lines
def draw_curved_branch_line(start_x, start_y, end_x, end_y, color='#333333', linewidth=3):
"""Draw smooth curved branch line"""
if abs(start_x - end_x) < 0.01 and abs(start_y - end_y) < 0.01:
return
dx = end_x - start_x
dy = end_y - start_y
distance = math.sqrt(dx*dx + dy*dy)
if distance < 0.1:
ax.plot([start_x, end_x], [start_y, end_y], color=color, linewidth=linewidth, alpha=0.8)
return
# 贝塞尔控制点计算
t = np.linspace(0, 1, 50)
start_dist = math.sqrt(start_x**2 + start_y**2)
if start_dist > 0.001:
norm_start_x, norm_start_y = start_x / start_dist, start_y / start_dist
else:
norm_start_x, norm_start_y = dx / distance, dy / distance
cp1_dist = distance * 0.4
cp1_x = start_x + norm_start_x * cp1_dist
cp1_y = start_y + norm_start_y * cp1_dist
cp2_x = end_x - (end_x - start_x) * 0.4
cp2_y = end_y - (end_y - start_y) * 0.4
curve_x = (1-t)**3 * start_x + 3*(1-t)**2*t * cp1_x + 3*(1-t)*t**2 * cp2_x + t**3 * end_x
curve_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(curve_x, curve_y, color=color, linewidth=linewidth, alpha=0.8)
# Draw lines first
for node in layout_nodes:
if node['depth'] > 1:
# Draw line from parent
line_width = max(3 - node['depth'] * 0.5, 1)
draw_curved_branch_line(node['parent_x'], node['parent_y'],
node['x'], node['y'],
color=node['color'], linewidth=line_width)
# Save base image (lines only)
plt.tight_layout(pad=0)
ax.set_position([0, 0, 1, 1]) # Occupy full figure
temp_base_file = os.path.join(temp_dir, "base_center_mindmap.png")
plt.savefig(temp_base_file, dpi=dpi, facecolor='white', edgecolor='none', format='png')
plt.close()
# Open with PIL to draw text
base_img = Image.open(temp_base_file)
draw = ImageDraw.Draw(base_img)
img_w, img_h = base_img.size
# Coordinate transform: Data (min_x..max_x) -> Pixel (0..img_w)
x_range = (max_x + margin) - (min_x - margin)
y_range = (max_y + margin) - (min_y - margin)
def data_to_pixel(x, y):
px = (x - (min_x - margin)) / x_range * img_w
py = img_h - (y - (min_y - margin)) / y_range * img_h # Flip Y
return px, py
# Draw text
for node in layout_nodes:
px, py = data_to_pixel(node['x'], node['y'])
self._draw_text_with_pil(
base_img, draw, px, py,
node['text'], node['depth'],
node['color'], font_file
)
base_img.save(output_file, 'PNG')
return True
except Exception:
import traceback
traceback.print_exc()
return False
# ==========================================
# Horizontal Layout Specific Methods
# ==========================================
def _estimate_text_width(self, text: str, depth_level: int) -> float:
"""
Estimate text width in coordinate units for horizontal layout
"""
width_score = 0
for char in text:
if ord(char) > 127:
width_score += 1.0
else:
width_score += 0.6
scale = max(1.0 - (depth_level - 1) * 0.1, 0.6)
estimated_width = width_score * scale * 0.4
estimated_width += 0.8
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')
node['_width'] = self._estimate_text_width(content, depth_level)
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
parent_width = node['_width']
connector_length = 2.0
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
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_bezier_curve(self, ax, start_x, start_y, end_x, end_y,
visual_start_x, visual_end_x, color, linewidth):
import numpy as np
dist = math.sqrt((visual_end_x - visual_start_x)**2 + (end_y - start_y)**2)
h_dist = abs(visual_end_x - visual_start_x)
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
t = np.linspace(0, 1, 50)
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_horizontal_lines(self, ax, node):
children = node.get('children', [])
if not children:
return
start_x, start_y = node['x'], node['y']
parent_width = node['_width']
visual_start_x = start_x + (parent_width / 2)
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_end_x = end_x - (child_width / 2)
line_end_x = end_x - (child_width / 2) * 0.6
color = child['color']
linewidth = max(3 - child['depth'] * 0.3, 1)
self._draw_bezier_curve(ax, line_start_x, start_y, line_end_x, end_y,
visual_start_x, visual_end_x,
color, linewidth)
self._draw_horizontal_lines(ax, child)
def _generate_horizontal_layout(self, tree_data: dict, output_file: str, temp_dir: str) -> bool:
"""
Generate Horizontal Mind Map
"""
try:
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
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
all_nodes = self._get_all_nodes_with_coords(tree_data)
if not all_nodes:
return False
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
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)
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)
fig_width = content_width * 0.8
fig_height = content_height * 0.8
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_horizontal_lines(ax, tree_data)
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', format='png')
plt.close()
# 5. Draw Text
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:
import traceback
traceback.print_exc()
return False
def _invoke(self, tool_parameters: dict) -> Generator[Dict[str, Any], None, None]:
"""
Invoke free structure 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('Free mind map generation failed: No Markdown content provided.')
return
display_filename = filename if filename else f"mindmap_free_{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)
# Parse Markdown
tree_data = self._parse_markdown_to_tree(markdown_content)
# Analyze Structure
layout_mode = self._analyze_structure_complexity(tree_data)
# Generate based on decision
if layout_mode == 'horizontal':
success = self._generate_horizontal_layout(tree_data, temp_output_path, temp_dir)
else:
success = self._generate_center_layout(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": "smart_free_structure",
"selected_mode": layout_mode,
"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,
"generation_time": time.strftime("%Y-%m-%d %H:%M:%S"),
"success": True,
"file_info": {
"type": "image",
"mime_type": "image/png",
"size": file_size,
"filename": display_filename
}
}
yield blob_message
yield self.create_text_message(f'Free mind map generation successful (Mode: {layout_mode})! File size: {size_text}')
yield self.create_json_message(json_data)
else:
json_data = {
"layout_type": "smart_free_structure",
"selected_mode": layout_mode,
"success": False,
"error": "Unable to create image file"
}
yield self.create_text_message('Free mind map generation failed: Unable to create image file.')
yield self.create_json_message(json_data)
except Exception as e:
error_msg = str(e)
json_data = {
"layout_type": "smart_free_structure",
"success": False,
"error": error_msg
}
yield self.create_text_message(f'Free mind map generation failed: {error_msg}')
yield self.create_json_message(json_data)
def get_tool():
return MindMapFreeTool