最近工作中经常需要将原来的svg图转换为Mermaid,方便集成显示和分享,而且Mermaid格式更方便大模型进行解读,因此就做了这个转换工具,其他类型的转换工具详见之前文章,目前已经支持将近30种转换,涵盖各类office、图片、流程图、符号模式和专业格式的相互转换等等。主要内容如下,供参考。
一、主要功能
主要实现将 SVG格式 的组织架构图、流程图和泳道图转换为 Mermaid 格式。
具体支持:
- Mermaid flowchart SVG(流程图):rect、circle、polygon(diamond)
- Mermaid sequenceDiagram SVG(时序图)
- 通用 SVG 流程图(手工绘制的组织架构图等)
- 边标签(通过 data-id 精确匹配)
- 控制结构(alt/loop/opt)
- foreignObject 中的 HTML 内容(含换行等标签)
二、实现效果
原始svg

转换后的Mermaid

三、核心代码
python
'''
@author: sunjava
用法:
python svg2Mermaid.py input.svg
python svg2Mermaid.py input.svg --output output.md
python svg2Mermaid.py input.svg --direction TD
'''
import argparse
import xml.etree.ElementTree as ET
import re
import math
import sys
from typing import List, Optional, Tuple, Dict
from dataclasses import dataclass
# ==================== Data Classes ====================
@dataclass
class Node:
id: str
x: float
y: float
width: float
height: float
text: str
shape: str = "rect"
@dataclass
class Edge:
source: str
target: str
label: str = ""
@dataclass
class SeqParticipant:
name: str
x: float
@dataclass
class SeqMessage:
from_: str
to_: str
text: str
y: float
is_self: bool = False
@dataclass
class SeqControl:
type: str
label: str
y_start: float
y_end: float
branches: List[dict]
# ==================== SVG Preprocessor ====================
VOID_TAGS = [
'br', 'hr', 'img', 'input', 'meta', 'link',
'embed', 'param', 'source', 'track', 'wbr',
'area', 'base', 'col'
]
def preprocess_svg(svg_content: str) -> str:
"""将 HTML 空标签转为 XML 自闭合形式,避免解析错误。"""
for tag in VOID_TAGS:
svg_content = re.sub(
rf'</{tag}\s*>', f'<{tag}/>', svg_content, flags=re.IGNORECASE
)
for tag in VOID_TAGS:
pattern = re.compile(rf'<{tag}\b[^>]*>', re.IGNORECASE)
def fix_tag(match: re.Match) -> str:
full = match.group(0)
if full.rstrip().endswith('/>'):
return full
return full[:-1] + '/>'
svg_content = pattern.sub(fix_tag, svg_content)
return svg_content
# ==================== Text Extractor ====================
def get_text(elem: ET.Element) -> str:
"""递归提取元素文本,保留 <br> 换行标记。"""
texts: List[str] = []
def collect(e: ET.Element) -> None:
if e.text and e.text.strip():
texts.append(e.text.strip())
for child in e:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag.lower() in ('br', 'hr'):
texts.append('<br>')
else:
collect(child)
if child.tail and child.tail.strip():
texts.append(child.tail.strip())
collect(elem)
return ' '.join(texts) if texts else ""
def get_text_simple(elem: ET.Element) -> str:
"""简单文本提取(用于 tspan)。"""
txt = ""
for tspan in elem.findall('.//{http://www.w3.org/2000/svg}tspan'):
if tspan.text and tspan.text.strip():
txt = tspan.text.strip()
break
if not txt and elem.text and elem.text.strip():
txt = elem.text.strip()
return txt
# 1.FlowchartSVG
# 2.GenericSVG
# 3.SequenceDiagramSVG
# ==================== Flowchart Parser (Mermaid生成) ====================
class FlowchartParser:
def __init__(self, root: ET.Element):
self.root = root
self.ns = {"svg": "http://www.w3.org/2000/svg"}
self.nodes: List[Node] = []
self.edges: List[Edge] = []
self._node_counter = 0
def _next_id(self) -> str:
self._node_counter += 1
return f"N{self._node_counter}"
def _parse_transform(self, transform: str) -> Tuple[float, float]:
match = re.search(r"translate\(([^,]+),\s*([^)]+)\)", transform)
if match:
return float(match.group(1)), float(match.group(2))
return 0.0, 0.0
def _parse_path_endpoints(self, d: str) -> Optional[Tuple[Tuple[float, float], Tuple[float, float]]]:
d = re.sub(r'([MmLlHhVvCcSsQqTtAaZz])', r' \1 ', d)
d = re.sub(r'\s+', ' ', d).strip()
tokens = d.split()
points: List[Tuple[float, float]] = []
i = 0
current_x, current_y = 0.0, 0.0
last_cmd: Optional[str] = None
while i < len(tokens):
token = tokens[i]
if token in 'MmLlHhVvCcSsQqTtAaZz':
last_cmd = token
i += 1
continue
coords = token.split(',')
if last_cmd in 'Mm':
if len(coords) >= 2:
current_x, current_y = float(coords[0]), float(coords[1])
points.append((current_x, current_y))
i += 1
elif last_cmd in 'Ll':
if len(coords) >= 2:
current_x, current_y = float(coords[0]), float(coords[1])
points.append((current_x, current_y))
i += 1
elif last_cmd in 'Hh':
current_x = float(token)
points.append((current_x, current_y))
i += 1
elif last_cmd in 'Vv':
current_y = float(token)
points.append((current_x, current_y))
i += 1
elif last_cmd in 'Cc':
for _ in range(3):
if i < len(tokens):
c = tokens[i].split(',')
if len(c) >= 2:
current_x, current_y = float(c[0]), float(c[1])
i += 1
points.append((current_x, current_y))
elif last_cmd in 'Qq':
for _ in range(2):
if i < len(tokens):
c = tokens[i].split(',')
if len(c) >= 2:
current_x, current_y = float(c[0]), float(c[1])
i += 1
points.append((current_x, current_y))
else:
if len(coords) >= 2:
try:
current_x, current_y = float(coords[0]), float(coords[1])
points.append((current_x, current_y))
except ValueError:
pass
i += 1
if len(points) >= 2:
return points[0], points[-1]
return None
def _get_absolute_position(self, elem: ET.Element) -> Tuple[float, float]:
parent_map = {}
for parent in self.root.iter():
for child in parent:
parent_map[child] = parent
tx, ty = 0.0, 0.0
current: Optional[ET.Element] = elem
while current is not None:
transform = current.get("transform", "")
dx, dy = self._parse_transform(transform)
tx += dx
ty += dy
current = parent_map.get(current)
return tx, ty
def _point_in_node(self, x: float, y: float, node: Node, margin: float = 20) -> bool:
return (node.x - margin <= x <= node.x + node.width + margin and
node.y - margin <= y <= node.y + node.height + margin)
def _find_node_at_point(self, x: float, y: float, exclude: Optional[Node] = None) -> Optional[Node]:
for node in self.nodes:
if exclude and node.id == exclude.id:
continue
if self._point_in_node(x, y, node, margin=30):
return node
best: Optional[Node] = None
best_dist = float("inf")
for node in self.nodes:
if exclude and node.id == exclude.id:
continue
cx = node.x + node.width / 2
cy = node.y + node.height / 2
d = math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
if d < best_dist:
best_dist = d
best = node
return best
def _parse_polygon_bbox(self, points_str: str) -> Tuple[float, float, float, float]:
"""Parse polygon points to bounding box."""
coords = re.findall(r'(-?\d+\.?\d*),(-?\d+\.?\d*)', points_str)
xs = [float(c[0]) for c in coords]
ys = [float(c[1]) for c in coords]
return min(xs), min(ys), max(xs), max(ys)
def parse(self) -> None:
# Edge labels: key = data-id (e.g. "L_F_A_0"), value = text
edge_labels: Dict[str, str] = {}
for g in self.root.findall(".//svg:g", self.ns):
data_id = g.get("data-id", "")
if not (data_id.startswith("L_") and data_id.endswith("_0")):
continue
fo = g.find(".//svg:foreignObject", self.ns)
if fo is None:
continue
txt = get_text(fo)
if txt:
edge_labels[data_id] = txt
# Nodes: rect, circle, polygon(diamond)
for g in self.root.findall(".//svg:g", self.ns):
gid = g.get("id", "")
if "flowchart-" not in gid:
continue
outer_tx, outer_ty = self._parse_transform(g.get("transform", ""))
shape_elem: Optional[ET.Element] = None
shape_type = "rect"
for child in g:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == "rect":
shape_elem = child
shape_type = "rect"
rx_val = child.get("rx", "0")
try:
if float(rx_val) > 3:
shape_type = "roundrect"
except ValueError:
pass
break elif tag == "circle":
shape_elem = child
shape_type = "circle"
break
elif tag == "polygon":
shape_elem = child
shape_type = "diamond"
break
if shape_elem is None:
continue
tag = shape_elem.tag.split('}')[-1] if '}' in shape_elem.tag else shape_elem.tag
if tag == "rect":
rx = float(shape_elem.get("x", 0))
ry = float(shape_elem.get("y", 0))
rw = float(shape_elem.get("width", 0))
rh = float(shape_elem.get("height", 0))
elif tag == "circle":
cx = float(shape_elem.get("cx", 0))
cy = float(shape_elem.get("cy", 0))
r = float(shape_elem.get("r", 0))
rx, ry = cx - r, cy - r
rw, rh = r * 2, r * 2
elif tag == "polygon":
points_str = shape_elem.get("points", "")
min_x, min_y, max_x, max_y = self._parse_polygon_bbox(points_str)
ptx, pty = self._parse_transform(shape_elem.get("transform", ""))
rx = min_x + ptx
ry = min_y + pty
rw = max_x - min_x
rh = max_y - min_y
else:
continue
text_content = ""
for child in g:
ctag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if ctag != "g":
continue
fo = child.find(".//svg:foreignObject", self.ns)
if fo is not None:
txt = get_text(fo)
if txt:
text_content = txt
break
text_elem = child.find(".//svg:text", self.ns)
if text_elem is not None:
txt = get_text(text_elem)
if txt:
text_content = txt
break
if text_content:
self.nodes.append(Node(
id=self._next_id(),
x=outer_tx + rx,
y=outer_ty + ry,
width=rw,
height=rh,
text=text_content,
shape=shape_type
))
# Edges: match labels by data-id for 100% accuracy
for path in self.root.findall(".//svg:path", self.ns):
marker_end = path.get("marker-end", "")
data_et = path.get("data-et", "")
data_edge = path.get("data-edge", "")
is_edge = ("pointEnd" in marker_end or data_et == "edge" or data_edge == "true")
if not is_edge:
continue
endpoints = self._parse_path_endpoints(path.get("d", ""))
if endpoints is None:
continue
(sx, sy), (ex, ey) = endpoints
source = self._find_node_at_point(sx, sy)
target = self._find_node_at_point(ex, ey)
if source and target and source.id != target.id:
data_id = path.get("data-id", "")
label = edge_labels.get(data_id, "")
self.edges.append(Edge(source=source.id, target=target.id, label=label))
def to_mermaid(self, direction: str = "LR") -> str:
if not self.nodes:
return "```mermaid\nflowchart LR\n %% 未找到节点\n```"
lines: List[str] = [f"flowchart {direction}"]
for node in self.nodes:
text = node.text.replace("[", "[").replace("]", "]")
text = text.replace("(", "(").replace(")", ")")
text = text.replace("{", "{").replace("}", "}")
text = text.replace("|", "|")
if node.shape == "circle":
shape = f"({text})"
elif node.shape == "roundrect":
shape = f"({text})"
elif node.shape == "diamond":
shape = f"{{{text}}}"
else:
shape = f"[{text}]"
lines.append(f" {node.id}{shape}")
if self.edges:
lines.append("")
for edge in self.edges:
if edge.label:
lines.append(f" {edge.source} -->|{edge.label}| {edge.target}")
else:
lines.append(f" {edge.source} --> {edge.target}")
lines.insert(0, "```mermaid")
lines.append("```")
return "\n".join(lines)
# ==================== Generic SVG Parser (通用SVG解析器) ====================
class GenericSVGParser:
"""通用SVG解析器,用于处理手工绘制的组织架构图等非Mermaid生成的SVG。"""
def __init__(self, root: ET.Element):
self.root = root
self.ns = {"svg": "http://www.w3.org/2000/svg"}
self.nodes: List[Node] = []
self.edges: List[Edge] = []
self._node_counter = 0
def _next_id(self) -> str:
self._node_counter += 1
return f"N{self._node_counter}"
def _parse_transform(self, transform: str) -> Tuple[float, float]:
"""解析transform属性中的translate值。"""
match = re.search(r"translate\(([^,]+),\s*([^)]+)\)", transform)
if match:
return float(match.group(1)), float(match.group(2))
return 0.0, 0.0
def _get_text_inside_rect(self, rect_x: float, rect_y: float, rect_w: float, rect_h: float) -> str:
"""查找矩形内的所有文本,按y坐标排序后拼接。"""
texts_in_rect = []
for text in self.root.findall(".//svg:text", self.ns):
tx = float(text.get("x", 0))
ty = float(text.get("y", 0))
# 检查文本是否在矩形内
if rect_x <= tx <= rect_x + rect_w and rect_y <= ty <= rect_y + rect_h:
txt = get_text_simple(text)
if txt:
texts_in_rect.append((ty, txt))
# 按y坐标排序,拼接多行文本
texts_in_rect.sort(key=lambda x: x[0])
# 过滤掉装饰性文本
decorative_keywords = ['战略层', '业务层', '执行层', '协同层']
filtered_texts = []
for ty, txt in texts_in_rect:
is_decorative = False
for keyword in decorative_keywords:
if keyword in txt and len(txt) < 10:
is_decorative = True
break if not is_decorative:
filtered_texts.append((ty, txt))
result = "<br>".join([t[1] for t in filtered_texts]) if filtered_texts else ""
return result
def parse(self) -> None:
"""解析通用SVG,提取节点和连线。"""
# 解析矩形节点
for rect in self.root.findall(".//svg:rect", self.ns):
x = float(rect.get("x", 0))
y = float(rect.get("y", 0))
w = float(rect.get("width", 0))
h = float(rect.get("height", 0))
# 跳过太小的矩形(可能是装饰元素)
if w < 30 or h < 15:
continue
# 跳过背景矩形(通常覆盖整个画布)
if w >= 700 and h >= 500:
continue
# 跳过图例区域(通常在底部)
if y > 500:
continue
# 获取transform偏移
tx, ty = self._parse_transform(rect.get("transform", ""))
actual_x = x + tx
actual_y = y + ty
# 查找矩形内的文本
text = self._get_text_inside_rect(actual_x, actual_y, w, h)
# 过滤掉标题和装饰性文本
if text and not self._is_decorative_text(text):
self.nodes.append(Node(
id=self._next_id(),
x=actual_x,
y=actual_y,
width=w,
height=h,
text=text,
shape="rect"
))
# 解析连线(line)
# 收集所有线段
lines_data = []
for line in self.root.findall(".//svg:line", self.ns):
x1 = float(line.get("x1", 0))
y1 = float(line.get("y1", 0))
x2 = float(line.get("x2", 0))
y2 = float(line.get("y2", 0))
# 跳过图例区域的线
if y1 > 500 or y2 > 500:
continue
lines_data.append((x1, y1, x2, y2))
# 处理所有线段(包括斜线)
for x1, y1, x2, y2 in lines_data:
# 确定上方点为起点,下方点为终点
if y1 < y2:
top_x, top_y = x1, y1
bottom_x, bottom_y = x2, y2
elif y2 < y1:
top_x, top_y = x2, y2
bottom_x, bottom_y = x1, y1
else:
# 水平线,跳过
continue
# 查找上方节点(父节点)
parent = self._find_node_at_point(top_x, top_y)
# 查找下方节点(子节点)
child = self._find_node_at_point(bottom_x, bottom_y)
if parent and child and parent.id != child.id:
edge_key = (parent.id, child.id)
if not any(e.source == edge_key[0] and e.target == edge_key[1] for e in self.edges):
self.edges.append(Edge(source=parent.id, target=child.id))
# 解析path连线(曲线)
for path in self.root.findall(".//svg:path", self.ns):
d = path.get("d", "")
if not d:
continue
# 提取起点和终点
points = re.findall(r'[ML]\s*([\d.]+),([\d.]+)', d)
if len(points) >= 2:
x1, y1 = float(points[0][0]), float(points[0][1])
x2, y2 = float(points[-1][0]), float(points[-1][1])
# 跳过图例区域的路径
if y1 > 500 or y2 > 500:
continue
source = self._find_node_at_point(x1, y1)
target = self._find_node_at_point(x2, y2)
if source and target and source.id != target.id:
edge_key = (source.id, target.id)
if not any(e.source == edge_key[0] and e.target == edge_key[1] for e in self.edges):
self.edges.append(Edge(source=source.id, target=target.id))
def _is_decorative_text(self, text: str) -> bool:
"""判断是否为装饰性文本(标题、层级标注等)。"""
# 过滤掉纯标题(单行且包含关键词)
decorative_keywords = ['关系图', '图例']
for keyword in decorative_keywords:
if keyword in text and '<br>' not in text:
return True
return False
def _find_node_at_point(self, x: float, y: float, margin: float = 10) -> Optional[Node]:
"""查找指定坐标处的节点,优先匹配边界上的节点。"""
# 首先尝试精确匹配(点在节点边界上)
for node in self.nodes:
# 检查点是否在节点的上边界或下边界附近
on_top_edge = abs(y - node.y) <= margin and node.x - margin <= x <= node.x + node.width + margin
on_bottom_edge = abs(y - (node.y + node.height)) <= margin and node.x - margin <= x <= node.x + node.width + margin
on_left_edge = abs(x - node.x) <= margin and node.y - margin <= y <= node.y + node.height + margin
on_right_edge = abs(x - (node.x + node.width)) <= margin and node.y - margin <= y <= node.y + node.height + margin
if on_top_edge or on_bottom_edge or on_left_edge or on_right_edge:
return node
# 如果边界匹配失败,尝试区域匹配
for node in self.nodes:
if (node.x - margin <= x <= node.x + node.width + margin and
node.y - margin <= y <= node.y + node.height + margin):
return node
# 最后找最近的节点(限制距离)
best_node = None
best_dist = float('inf')
for node in self.nodes:
cx = node.x + node.width / 2
cy = node.y + node.height / 2
dist = math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
if dist < best_dist:
best_dist = dist
best_node = node
# 只有当距离足够近时才返回
if best_dist < 100:
return best_node
return None
def to_mermaid(self, direction: str = "TD") -> str:
"""转换为Mermaid代码。"""
if not self.nodes:
return "```mermaid\nflowchart TD\n %% 未找到节点\n```"
lines: List[str] = [f"flowchart {direction}"]
for node in self.nodes:
text = node.text.replace("[", "[").replace("]", "]")
text = text.replace("(", "(").replace(")", ")")
text = text.replace("{", "{").replace("}", "}")
text = text.replace("|", "|")
shape = f"[{text}]"
lines.append(f" {node.id}{shape}")
if self.edges:
lines.append("")
for edge in self.edges:
lines.append(f" {edge.source} --> {edge.target}")
lines.insert(0, "```mermaid")
lines.append("```")
return "\n".join(lines)
# ==================== Sequence Diagram Parser ====================
# ==================== Main Converter ====================
class SVGToMermaid:
def __init__(self, svg_content: str):
self.svg_content = preprocess_svg(svg_content)
self.diagram_type = "unknown"
self.output = ""
def detect_type(self, root: ET.Element) -> str:
"""检测SVG类型:Mermaid生成、时序图、通用SVG。"""
aria = root.get('aria-roledescription', '')
if 'sequence' in aria.lower():
return "sequence"
if 'flowchart' in aria.lower():
return "flowchart"
# 检查是否有Mermaid特有的标记
ns = {"svg": "http://www.w3.org/2000/svg"}
# 检查participant元素(时序图)
for g in root.findall(".//svg:g", ns):
if g.get('data-et') == 'participant':
return "sequence"
# 检查flowchart节点
for g in root.findall(".//svg:g", ns):
if "flowchart-" in g.get('id', ''):
return "flowchart"
# 检查是否有data-et="edge"标记
for path in root.findall(".//svg:path", ns):
if path.get('data-et') == 'edge':
return "flowchart"
# 如果有rect和text元素,但没有Mermaid标记,认为是通用SVG
has_rect = len(root.findall(".//svg:rect", ns)) > 0
has_text = len(root.findall(".//svg:text", ns)) > 0
if has_rect and has_text:
return "generic"
return "unknown"
def parse(self) -> "SVGToMermaid":
root = ET.fromstring(self.svg_content)
self.diagram_type = self.detect_type(root)
if self.diagram_type == "sequence":
parser = SequenceParser(root)
parser.parse()
self.output = parser.to_mermaid()
elif self.diagram_type == "flowchart":
parser = FlowchartParser(root)
parser.parse()
self.output = parser.to_mermaid("LR")
elif self.diagram_type == "generic":
parser = GenericSVGParser(root)
parser.parse()
self.output = parser.to_mermaid("TD")
else:
self.output = "```mermaid\n%% 无法识别 SVG 类型\n```"
return self
def to_mermaid(self) -> str:
return self.output
# ==================== CLI ====================
def main():
parser = argparse.ArgumentParser(
description="将 SVG 流程图/时序图转换为 Mermaid 代码",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python svg2Mermaid2.py diagram.svg
python svg2Mermaid2.py diagram.svg -o result.md .
python svg2Mermaid2.py diagram.svg --direction TD """ )
parser.add_argument("input", help="输入 SVG 文件路径")
parser.add_argument("-o", "--output", help="输出文件路径(默认输出到控制台)")
parser.add_argument(
"--direction", "-d",
default="TD",
choices=["LR", "RL", "TD", "DT", "TB", "BT"],
help="流程图方向(默认: TD,时序图忽略此参数)"
)
args = parser.parse_args()
try:
with open(args.input, "r", encoding="utf-8") as f:
svg_content = f.read()
except FileNotFoundError:
print(f"错误: 文件未找到 '{args.input}'", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"错误: 无法读取文件: {e}", file=sys.stderr)
sys.exit(1)
converter = SVGToMermaid(svg_content)
converter.parse()
mermaid = converter.to_mermaid()
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(mermaid)
print(f"已保存到: {args.output}")
else:
print(mermaid)
# 使用示例
# python svg2Mermaid.py data/flow1.svg --output data/flow1.md
# python svg2Mermaid.py data/flow3.svg --output data/flow3.md
if __name__ == "__main__":
main()