structviz 0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
structviz/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1"
structviz/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ import click
2
+ import yaml
3
+ import os
4
+ import json
5
+ from .models import EquityChart, OrgChart
6
+ from .generators import generate_equity_dot, generate_org_dot, generate_org_excalidraw, generate_org_d2
7
+ from .render import render_dot
8
+ from .html_wrapper import generate_html
9
+ from .excalidraw_render import render_excalidraw_to_png
10
+ from .d2_render import render_d2
11
+
12
+ @click.command()
13
+ @click.argument('yaml_file', type=click.Path(exists=True))
14
+ @click.option('-t', '--type', 'chart_type', required=True,
15
+ type=click.Choice(['equity', 'org']), help='Chart type')
16
+ @click.option('-f', '--formats', default='png',
17
+ help='Comma-separated output formats (png,pdf,svg,html,excalidraw,d2)')
18
+ @click.option('-o', '--output', default='output/', help='Output directory')
19
+ @click.option('-p', '--papersize', default=None,
20
+ help='Paper preset (a4/a3/letter) or custom "W,H[mm|in]" '
21
+ 'e.g. "210,297" or "8.5,11in" (overrides YAML)')
22
+ @click.option('-d', '--dpi', default=None, type=int,
23
+ help='Output DPI (overrides YAML meta.dpi)')
24
+ def main(yaml_file, chart_type, formats, output, papersize, dpi):
25
+ """Generate charts from YAML definition."""
26
+ with open(yaml_file, 'r', encoding='utf-8') as f:
27
+ data = yaml.safe_load(f)
28
+
29
+ os.makedirs(output, exist_ok=True)
30
+ base_name = os.path.splitext(os.path.basename(yaml_file))[0]
31
+ output_base = os.path.join(output, base_name)
32
+
33
+ if chart_type == 'equity':
34
+ chart = EquityChart(**data)
35
+ elif chart_type == 'org':
36
+ chart = OrgChart(**data)
37
+ else:
38
+ raise click.BadParameter("type must be equity or org")
39
+
40
+ fmt_list = [f.strip() for f in formats.split(',')]
41
+
42
+ # Excalidraw path
43
+ if 'excalidraw' in fmt_list:
44
+ if chart_type == 'org':
45
+ json_str = generate_org_excalidraw(chart, paper_size=papersize or chart.meta.papersize,
46
+ dpi=dpi or chart.meta.dpi)
47
+ path = output_base + '.excalidraw'
48
+ with open(path, 'w', encoding='utf-8') as f:
49
+ f.write(json_str)
50
+ png_path = output_base + '_ex.png'
51
+ render_excalidraw_to_png(json_str, png_path)
52
+ click.echo(f"Excalidraw saved to {path} + {png_path}")
53
+ else:
54
+ click.echo("Excalidraw only supports org charts currently.")
55
+
56
+ # D2 path
57
+ if 'd2' in fmt_list and chart_type == 'org':
58
+ d2_source = generate_org_d2(chart)
59
+ d2_path = output_base + '.d2'
60
+ with open(d2_path, 'w', encoding='utf-8') as f:
61
+ f.write(d2_source)
62
+ svg_path = render_d2(d2_source, output_base + '_d2.svg')
63
+ click.echo(f"D2 saved to {d2_path} + {svg_path}")
64
+
65
+ # DOT path (png, pdf, svg, html)
66
+ dot_formats = [f for f in fmt_list if f not in ('excalidraw', 'html', 'd2')]
67
+ if dot_formats:
68
+ if chart_type == 'equity':
69
+ dot_source = generate_equity_dot(chart, paper_size=papersize or chart.meta.papersize,
70
+ dpi=dpi or chart.meta.dpi)
71
+ else:
72
+ dot_source = generate_org_dot(chart, paper_size=papersize or chart.meta.papersize,
73
+ dpi=dpi or chart.meta.dpi)
74
+ render_dot(dot_source, output_base, dot_formats)
75
+
76
+ if 'html' in fmt_list:
77
+ svg_path = output_base + '.svg'
78
+ if not os.path.exists(svg_path):
79
+ if chart_type == 'equity':
80
+ ds = generate_equity_dot(chart, paper_size=papersize or chart.meta.papersize,
81
+ dpi=dpi or chart.meta.dpi)
82
+ else:
83
+ ds = generate_org_dot(chart, paper_size=papersize or chart.meta.papersize,
84
+ dpi=dpi or chart.meta.dpi)
85
+ render_dot(ds, output_base, ['svg'])
86
+ generate_html(svg_path, chart.meta.title, output_base + '.html')
87
+
88
+ if dot_formats or 'html' in fmt_list:
89
+ click.echo(f"Chart generated in {output}")
90
+
91
+ if __name__ == '__main__':
92
+ main()
structviz/d2_render.py ADDED
@@ -0,0 +1,169 @@
1
+ """D2 渲染器:编译 D2 源码 → SVG,含直角线后处理"""
2
+
3
+ import subprocess
4
+ import tempfile
5
+ import os
6
+ import re
7
+ import xml.etree.ElementTree as ET
8
+
9
+
10
+ def render_d2(d2_source: str, output_path: str, cleanup: bool = True) -> str:
11
+ """编译 D2 源码到 SVG,返回输出文件路径"""
12
+ # 写临时文件
13
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.d2', delete=False) as f:
14
+ tmp_d2 = f.name
15
+ f.write(d2_source)
16
+
17
+ svg_path = output_path
18
+ if not svg_path.endswith('.svg'):
19
+ svg_path = output_path + '.svg'
20
+
21
+ try:
22
+ result = subprocess.run(
23
+ ['d2', '--pad=0', '--layout=elk', tmp_d2, svg_path],
24
+ capture_output=True, text=True, timeout=60
25
+ )
26
+ if result.returncode != 0:
27
+ raise RuntimeError(f"d2 compile failed: {result.stderr}")
28
+
29
+ # SVG 后处理
30
+ _post_process_svg(svg_path)
31
+
32
+ finally:
33
+ if cleanup:
34
+ os.unlink(tmp_d2)
35
+
36
+ return svg_path
37
+
38
+
39
+ def _flatten_tspan(content: str) -> str:
40
+ """把 <text> 内的 <tspan> 多行拆成独立 <text>,兼容 resvg"""
41
+ def replace_tspan(match):
42
+ full = match.group(0)
43
+ attrs = match.group(1)
44
+ # 提取属性
45
+ x_val = re.search(r'x="([^"]*)"', attrs).group(1)
46
+ y_val = re.search(r'y="([^"]*)"', attrs).group(1)
47
+ fill = re.search(r'fill="([^"]*)"', attrs)
48
+ fill = fill.group(1) if fill else '#0A0F25'
49
+ base_y = float(y_val)
50
+
51
+ # 保留 class 和 style
52
+ cls = re.search(r'class="([^"]*)"', attrs)
53
+ style = re.search(r'style="([^"]*)"', attrs)
54
+ extra = f'class="{cls.group(1)}"' if cls else ''
55
+ if style:
56
+ extra += f' style="{style.group(1)}"'
57
+ ta = re.search(r'text-anchor:(\w+)', style.group(1))
58
+ anchor = ta.group(1) if ta else 'middle'
59
+ else:
60
+ anchor = 'middle'
61
+
62
+ # 逐行生成独立 <text>
63
+ result = []
64
+ current_y = base_y
65
+ for ts in re.finditer(r'<tspan[^>]*dy="([^"]*)"[^>]*>([^<]*)</tspan>', full):
66
+ current_y += float(ts.group(1))
67
+ result.append(
68
+ f'<text x="{x_val}" y="{current_y:.1f}" fill="{fill}" '
69
+ f'{extra} text-anchor="{anchor}">{ts.group(2)}</text>'
70
+ )
71
+ return '\n'.join(result) if result else full
72
+
73
+ # 逐个 <text>...</text> 处理,只替换含 <tspan> 的
74
+ def patch(s):
75
+ if '<tspan' not in s:
76
+ return s
77
+ m = re.match(r'<text(\s[^>]*)>(.*?)</text>', s, re.DOTALL)
78
+ if m:
79
+ return replace_tspan(m)
80
+ return s
81
+
82
+ return re.sub(
83
+ r'<text\s[^>]*>.*?</text>',
84
+ lambda m: patch(m.group(0)),
85
+ content,
86
+ flags=re.DOTALL
87
+ )
88
+
89
+
90
+ def _post_process_svg(svg_path: str):
91
+ """SVG 后处理:去嵌套、去 legend、斜线转直角"""
92
+ with open(svg_path, 'r', encoding='utf-8') as f:
93
+ content = f.read()
94
+
95
+ # 1. 剥离 XML 声明
96
+ xml_decl = ''
97
+ if content.startswith('<?xml'):
98
+ xml_end = content.index('?>') + 2
99
+ xml_decl = content[:xml_end]
100
+ content = content[xml_end:]
101
+
102
+ # 2. 去外层 SVG,把 xmlns 挪到内层
103
+ outer = re.match(r'<svg[^>]*>', content).group(0)
104
+ # 提取所有 xmlns 属性
105
+ xmlns_attrs = ' '.join(re.findall(r'xmlns(?::\w+)?="[^"]*"', outer))
106
+ content = re.sub(r'^<svg[^>]*>', '', content, count=1)
107
+ # 给内层 SVG 补上 xmlns
108
+ content = re.sub(r'^(<svg)\b', r'\1 ' + xmlns_attrs + ' ', content, count=1)
109
+ content = re.sub(r'</svg>$', '', content)
110
+
111
+ # 3. 删除 layout/routing 图例
112
+ for legend_class in ['bGF5b3V0', 'cm91dGluZw==']:
113
+ while True:
114
+ pattern = f'<g class="{legend_class}">'
115
+ idx = content.find(pattern)
116
+ if idx == -1:
117
+ break
118
+ depth = 1
119
+ pos = idx + len(pattern)
120
+ while depth > 0:
121
+ ot = content.find('<g ', pos)
122
+ ct = content.find('</g>', pos)
123
+ if ot >= 0 and ot < ct:
124
+ depth += 1
125
+ pos = ot + 3
126
+ else:
127
+ depth -= 1
128
+ pos = ct + 4
129
+ content = content[:idx] + content[pos:]
130
+
131
+ # 4. tspan 多行拆成独立 <text>(兼容 resvg)
132
+ content = _flatten_tspan(content)
133
+
134
+ # 5. 查找板块中心 x(font-size:24px 的容器标题)
135
+ plate_centers = {}
136
+ for m in re.finditer(r'<text x="([^"]*)" y="([^"]*)"[^>]*font-size:24px[^>]*>([^<]*)</text>', content):
137
+ plate_centers[m.group(3)] = float(m.group(1))
138
+
139
+ # 5. 连接线转直角 + 终点对齐板块中心
140
+ def fix_path(match):
141
+ d = match.group(1)
142
+ parts = d.split()
143
+ if len(parts) != 6 or parts[0] != 'M' or parts[3] != 'L':
144
+ return match.group(0)
145
+
146
+ x1, y1 = float(parts[1]), float(parts[2])
147
+ x2, y2 = float(parts[4]), float(parts[5])
148
+ y_mid = (y1 + y2) / 2
149
+
150
+ # 只对到达板块区域的连线(y2 > 0)做中心对齐
151
+ if y2 > 0 and plate_centers:
152
+ best_dist = 500
153
+ target_x = x2
154
+ for cx in plate_centers.values():
155
+ dist = abs(x2 - cx)
156
+ if dist < best_dist:
157
+ best_dist = dist
158
+ target_x = cx
159
+ else:
160
+ target_x = x2
161
+
162
+ return f'd="M {x1} {y1} L {x1} {y_mid} L {target_x} {y_mid} L {target_x} {y2}"'
163
+
164
+ content = re.sub(r'd="(M [^"]*)"', fix_path, content)
165
+
166
+ # 6. 写回
167
+ content = xml_decl + content
168
+ with open(svg_path, 'w', encoding='utf-8') as f:
169
+ f.write(content)
@@ -0,0 +1,213 @@
1
+ """Render Excalidraw JSON to PNG using Pillow."""
2
+ import json
3
+ from PIL import Image, ImageDraw, ImageFont
4
+
5
+ NAMED_COLORS = {"white": (255,255,255), "black": (0,0,0), "transparent": None,
6
+ "red": (255,0,0), "green": (0,255,0), "blue": (0,0,255),
7
+ "grey": (128,128,128), "gray": (128,128,128)}
8
+
9
+ def _parse_color(c: str):
10
+ """Return (r,g,b) or None for transparent."""
11
+ if not c or c == "transparent":
12
+ return None
13
+ if c in NAMED_COLORS:
14
+ return NAMED_COLORS[c]
15
+ h = c.lstrip('#')
16
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
17
+
18
+ def _get_font(size: int) -> ImageFont.FreeTypeFont:
19
+ """Find a CJK-capable font, falling back to default."""
20
+ import os, glob
21
+ candidates = []
22
+ # User fonts
23
+ for pattern in [
24
+ os.path.expanduser("~/.fonts/**/*.otf"),
25
+ os.path.expanduser("~/.fonts/**/*.ttf"),
26
+ os.path.expanduser("~/.fonts/**/*.ttc"),
27
+ "/usr/share/fonts/**/*.ttc",
28
+ "/usr/share/fonts/**/*.otf",
29
+ "/usr/share/fonts/**/*.ttf",
30
+ ]:
31
+ candidates.extend(glob.glob(pattern, recursive=True))
32
+
33
+ # Prefer CJK fonts
34
+ cjk = [p for p in candidates if any(k in p.lower() for k in
35
+ ['cjk', 'noto', 'wqy', 'chinese', 'hans', 'simsun', 'songti', 'heiti', 'hanyishufang'])]
36
+ for path in cjk + candidates:
37
+ try:
38
+ return ImageFont.truetype(path, size)
39
+ except Exception:
40
+ continue
41
+ return ImageFont.load_default()
42
+
43
+
44
+ def _split_lines(text: str, font, max_w: int, draw) -> list:
45
+ """Split text into lines fitting max_w."""
46
+ lines = text.split('\n')
47
+ result = []
48
+ for line in lines:
49
+ if not line:
50
+ result.append('')
51
+ continue
52
+ bbox = draw.textbbox((0, 0), line, font=font)
53
+ if bbox[2] - bbox[0] <= max_w:
54
+ result.append(line)
55
+ else:
56
+ # crude line break: split into chunks
57
+ current = ''
58
+ for ch in line:
59
+ test = current + ch
60
+ bbox = draw.textbbox((0, 0), test, font=font)
61
+ if bbox[2] - bbox[0] > max_w and current:
62
+ result.append(current)
63
+ current = ch
64
+ else:
65
+ current = test
66
+ if current:
67
+ result.append(current)
68
+ return result
69
+
70
+
71
+ def render_excalidraw_to_png(excalidraw_json: str, output_path: str, scale: float = 1.0):
72
+ """Render an Excalidraw JSON string to a PNG file."""
73
+ data = json.loads(excalidraw_json) if isinstance(excalidraw_json, str) else excalidraw_json
74
+ elements = data.get("elements", [])
75
+
76
+ # Find canvas bounds
77
+ min_x = min_y = float('inf')
78
+ max_x = max_y = float('-inf')
79
+ for el in elements:
80
+ if el.get("isDeleted"):
81
+ continue
82
+ x, y = el.get("x", 0), el.get("y", 0)
83
+ w, h = el.get("width", 0), el.get("height", 0)
84
+ if el["type"] == "arrow" and "points" in el:
85
+ for px, py in el["points"]:
86
+ min_x = min(min_x, x + px)
87
+ min_y = min(min_y, y + py)
88
+ max_x = max(max_x, x + px)
89
+ max_y = max(max_y, y + py)
90
+ else:
91
+ min_x = min(min_x, x)
92
+ min_y = min(min_y, y)
93
+ max_x = max(max_x, x + w)
94
+ max_y = max(max_y, y + h)
95
+
96
+ pad = 40
97
+ min_x = max(0, min_x - pad)
98
+ min_y = max(0, min_y - pad)
99
+ max_x += pad
100
+ max_y += pad
101
+
102
+ w_img = int((max_x - min_x) * scale)
103
+ h_img = int((max_y - min_y) * scale)
104
+ img = Image.new('RGB', (max(w_img, 100), max(h_img, 100)), 'white')
105
+ draw = ImageDraw.Draw(img)
106
+
107
+ # Group elements by type for correct z-order: rectangles first, then text, then arrows
108
+ rects = [e for e in elements if e["type"] == "rectangle"]
109
+ texts = [e for e in elements if e["type"] == "text"]
110
+ arrows = [e for e in elements if e["type"] == "arrow"]
111
+
112
+ # Draw rectangles
113
+ for el in rects:
114
+ if el.get("isDeleted"):
115
+ continue
116
+ x = (el["x"] - min_x) * scale
117
+ y = (el["y"] - min_y) * scale
118
+ w = el["width"] * scale
119
+ h = el["height"] * scale
120
+ bg = el.get("backgroundColor", "#ffffff")
121
+ stroke = el.get("strokeColor", "#1e1e1e")
122
+ sw = max(1, int(el.get("strokeWidth", 1) * scale))
123
+
124
+ fill_color = _parse_color(bg) if bg and bg != "transparent" else None
125
+ if el.get("roundness"):
126
+ r = int(8 * scale)
127
+ draw.rounded_rectangle([x, y, x + w, y + h], radius=r,
128
+ fill=fill_color, outline=_parse_color(stroke), width=sw)
129
+ else:
130
+ draw.rectangle([x, y, x + w, y + h],
131
+ fill=fill_color, outline=_parse_color(stroke), width=sw)
132
+
133
+ # Dashed style
134
+ if el.get("strokeStyle") == "dashed":
135
+ # Draw dashed outline over solid one
136
+ import math
137
+ dash_len = 6 * scale
138
+ gap_len = 4 * scale
139
+ total = dash_len + gap_len
140
+ # Simplified: draw dashed segments along the perimeter
141
+ perimeter = 2 * (w + h)
142
+ n_dashes = int(perimeter / total)
143
+ # Just redraw with PIL dashed (Pillow 10.2+)
144
+ pass # Pillow rounded_rectangle doesn't support dashes natively
145
+
146
+ # Draw text
147
+ font = _get_font(14)
148
+ for el in texts:
149
+ if el.get("isDeleted"):
150
+ continue
151
+ text = el.get("text", "")
152
+ if not text:
153
+ continue
154
+ x = (el["x"] - min_x) * scale
155
+ y = (el["y"] - min_y) * scale
156
+ w = el["width"] * scale
157
+ h = el["height"] * scale
158
+ color = _parse_color(el.get("strokeColor", "#1e1e1e"))
159
+ fs = int(el.get("fontSize", 14) * scale)
160
+ try:
161
+ font = _get_font(max(8, fs))
162
+ except Exception:
163
+ font = ImageFont.load_default()
164
+
165
+ lines = text.split('\n')
166
+ line_h = fs + 4
167
+ total_h = len(lines) * line_h
168
+ text_y = y + (h - total_h) / 2
169
+ for li, line in enumerate(lines):
170
+ if not line:
171
+ continue
172
+ bbox = draw.textbbox((0, 0), line, font=font)
173
+ tw = bbox[2] - bbox[0]
174
+ align = el.get("textAlign", "center")
175
+ if align == "center":
176
+ tx = x + (w - tw) / 2
177
+ elif align == "right":
178
+ tx = x + w - tw - 2
179
+ else:
180
+ tx = x + 2
181
+ draw.text((tx, text_y + li * line_h), line, fill=color, font=font)
182
+
183
+ # Draw arrows
184
+ for el in arrows:
185
+ if el.get("isDeleted"):
186
+ continue
187
+ points = el.get("points", [])
188
+ if len(points) < 2:
189
+ continue
190
+ x0 = (el["x"] - min_x) * scale
191
+ y0 = (el["y"] - min_y) * scale
192
+ stroke = _parse_color(el.get("strokeColor", "#7F8C8D"))
193
+ sw = max(1, int(el.get("strokeWidth", 2) * scale))
194
+
195
+ pts = [(x0 + p[0] * scale, y0 + p[1] * scale) for p in points] # points are relative
196
+ draw.line(pts, fill=stroke, width=sw)
197
+
198
+ # Arrowhead
199
+ import math
200
+ x2, y2 = pts[-1]
201
+ x1, y1 = pts[-2]
202
+ angle = math.atan2(y2 - y1, x2 - x1)
203
+ arrow_len = 10 * scale
204
+ a1 = angle + math.pi * 0.85
205
+ a2 = angle - math.pi * 0.85
206
+ draw.polygon([
207
+ (x2, y2),
208
+ (x2 - arrow_len * math.cos(a1), y2 - arrow_len * math.sin(a1)),
209
+ (x2 - arrow_len * math.cos(a2), y2 - arrow_len * math.sin(a2)),
210
+ ], fill=stroke)
211
+
212
+ img.save(output_path, "PNG")
213
+ print(f"Saved {output_path} ({img.size[0]}x{img.size[1]}px)")
@@ -0,0 +1,4 @@
1
+ from .equity import generate_equity_dot
2
+ from .org import generate_org_dot
3
+ from .excalidraw import generate_org_excalidraw
4
+ from .d2 import generate_org_d2
@@ -0,0 +1,196 @@
1
+ """D2 源码生成器:OrgChart 模型 → D2 声明式布局"""
2
+
3
+ from typing import List, Optional
4
+ from ..models import OrgChart, OrgNode, OrgBlock, OrgGroup
5
+ from ..themes import THEMES
6
+
7
+
8
+ def _theme_colors(theme_name: str) -> dict:
9
+ """从 StructViz theme 提取颜色映射"""
10
+ theme = THEMES.get(theme_name, THEMES["corporate"])
11
+ return {
12
+ "leader_bg": theme.get("card_header_bg", "#2C3E50"),
13
+ "leader_font": theme.get("card_header_font", "#FFFFFF"),
14
+ "deputy_bg": theme.get("card_body_bg", "#BDC3C7"),
15
+ "deputy_font": theme.get("dept_fontcolor", "#2C3E50"),
16
+ "dept_bg": theme.get("dept_fillcolor", "#EBF5FB"),
17
+ "dept_border": theme.get("dept_border", "#2980B9"),
18
+ "dept_font": theme.get("dept_fontcolor", "#2C3E50"),
19
+ "plate_stroke": theme.get("card_border_color", "#34495E"),
20
+ "plate_bg": theme.get("cluster_bg", "#F4F6F7"),
21
+ "edge_color": theme.get("edge_color", "#0D32B2"),
22
+ "bg": theme.get("bgcolor", "#F4F6F7"),
23
+ }
24
+
25
+
26
+ def _escape(s: str) -> str:
27
+ """转义 D2 字符串中的特殊字符"""
28
+ return s.replace('"', '\\"').replace('\n', '\\n')
29
+
30
+
31
+ def _wrap_text(s: str, width: int) -> str:
32
+ """超长文字自动插入 \\n 换行(已有手动换行则跳过自动切分)"""
33
+ if not width or width <= 0:
34
+ return s
35
+ # 已含手动换行 → 保留原样
36
+ if '\n' in s:
37
+ return s
38
+ # 纯单行超长 → 按宽度切分
39
+ if len(s) <= width:
40
+ return s
41
+ wrapped = []
42
+ rest = s
43
+ while len(rest) > width:
44
+ wrapped.append(rest[:width])
45
+ rest = rest[width:]
46
+ if rest:
47
+ wrapped.append(rest)
48
+ return '\n'.join(wrapped)
49
+
50
+
51
+ def _dept_height(title: str) -> int:
52
+ """根据内容行数估算节点高度"""
53
+ lines = title.count('\\n') + 1
54
+ return 40 if lines <= 1 else max(40, lines * 20 + 8)
55
+
56
+
57
+ def generate_org_d2(chart: OrgChart) -> str:
58
+ """将 OrgChart 模型转为 D2 源码"""
59
+ c = _theme_colors(chart.meta.theme)
60
+ layout = chart.meta.layout
61
+ max_cols = layout.max_columns if layout and layout.max_columns else 3
62
+ wrap_w = layout.wrap_width if layout and layout.wrap_width else 0
63
+ dept_gap = layout.dept_gap if layout and layout.dept_gap else 6
64
+ dept_w = layout.dept_width if layout and layout.dept_width else 150
65
+
66
+ lines: List[str] = []
67
+
68
+ # ── 全局设置 ──
69
+ lines.append('layout: elk')
70
+ lines.append('direction: down')
71
+ lines.append(f'style.fill: "{c["bg"]}"')
72
+ lines.append('')
73
+
74
+ # ── 节点 class 定义 ──
75
+ lines.append('classes: {')
76
+ lines.append(' top-node: {')
77
+ lines.append(' shape: rectangle')
78
+ lines.append(f' style: {{ fill: "{c["leader_bg"]}"; font-color: "{c["leader_font"]}"; font-size: 16; bold: true }}')
79
+ lines.append(' width: 120; height: 50')
80
+ lines.append(' }')
81
+ lines.append(' top-sub: {')
82
+ lines.append(' shape: rectangle')
83
+ lines.append(f' style: {{ fill: "{c["deputy_bg"]}"; font-color: "{c["deputy_font"]}"; font-size: 12 }}')
84
+ lines.append(' width: 90; height: 36')
85
+ lines.append(' }')
86
+ lines.append(' dept: {')
87
+ lines.append(' shape: rectangle')
88
+ lines.append(f' style: {{ fill: "{c["dept_bg"]}"; stroke: "{c["dept_border"]}"; font-color: "{c["dept_font"]}"; font-size: 12; border-radius: 4 }}')
89
+ lines.append(f' width: {dept_w}; height: 40')
90
+ lines.append(' }')
91
+ lines.append('}')
92
+ lines.append('')
93
+
94
+ # ── 收集分组信息 ──
95
+ leadership_nodes: List[OrgNode] = []
96
+ deputy_nodes: List[OrgNode] = []
97
+ plate_blocks: List[OrgBlock] = []
98
+ leader_id: Optional[str] = None
99
+
100
+ for group in chart.groups:
101
+ if group.level == 1:
102
+ leadership_nodes = group.nodes
103
+ deputy_nodes = group.right_nodes
104
+ if leadership_nodes:
105
+ leader_id = leadership_nodes[0].id
106
+ else:
107
+ for b in group.blocks:
108
+ if b.position == "bottom":
109
+ plate_blocks.append(b)
110
+
111
+ # ── 领导层 ──
112
+ lines.append('leadership: {')
113
+ lines.append(' near: top-center')
114
+ lines.append(' grid-rows: 2')
115
+ lines.append(' grid-gap: 16')
116
+ lines.append(' label: ""')
117
+ lines.append(' style: { stroke-width: 0; fill: transparent }')
118
+ lines.append('')
119
+
120
+ # 司库中心
121
+ if leadership_nodes:
122
+ leader = leadership_nodes[0]
123
+ lines.append(f' {leader.id}: "{_escape(leader.title)}" {{ class: top-node }}')
124
+ lines.append('')
125
+
126
+ # 中心领导虚线框(主任/副主任)
127
+ if deputy_nodes:
128
+ dgap = layout.deputy_gap if layout and layout.deputy_gap else 40
129
+ lines.append(' deputies: {')
130
+ lines.append(' label: "中心领导"')
131
+ lines.append(' grid-columns: 2')
132
+ lines.append(f' grid-gap: {dgap}')
133
+ lines.append(f' style: {{ stroke: "{c["plate_stroke"]}"; stroke-dash: 5; border-radius: 8; fill: "{c["plate_bg"]}" }}')
134
+ lines.append('')
135
+ for d in deputy_nodes:
136
+ lines.append(f' {d.id}: "{_escape(d.title)}" {{ class: top-sub }}')
137
+ lines.append(' }')
138
+ lines.append('')
139
+
140
+ # 司库中心 → 中心领导
141
+ if leader_id and deputy_nodes:
142
+ lines.append(f' {leader_id} -> deputies')
143
+ lines.append('')
144
+
145
+ lines.append('}')
146
+ lines.append('')
147
+
148
+ # ── 三板块横排 ──
149
+ if plate_blocks:
150
+ lines.append('plates: {')
151
+ lines.append(f' grid-columns: {len(plate_blocks)}')
152
+ lines.append(' grid-gap: 36')
153
+ lines.append(' style: { stroke-width: 0; fill: transparent; font-color: transparent }')
154
+ lines.append('')
155
+
156
+ for block in plate_blocks:
157
+ # 分离 cluster_head 和普通部门节点
158
+ heads = [n for n in block.nodes if n.is_cluster_head]
159
+ depts = [n for n in block.nodes if not n.is_cluster_head]
160
+ ncols = min(block.max_columns or max_cols, len(depts)) if depts else 1
161
+
162
+ lines.append(f' {block.id}: {{')
163
+ lines.append(f' label: "{_escape(block.label or block.id)}"')
164
+ lines.append(f' grid-columns: {ncols}')
165
+ lines.append(f' grid-gap: {dept_gap}')
166
+ lines.append(f' style: {{ fill: "{c["plate_bg"]}"; stroke: "{c["plate_stroke"]}"; stroke-dash: 5; border-radius: 8 }}')
167
+ lines.append('')
168
+
169
+ # 部门节点(自动换行)
170
+ for d in depts:
171
+ title = _wrap_text(d.title, wrap_w) if wrap_w > 0 else d.title
172
+ escaped = _escape(title)
173
+ h = _dept_height(escaped)
174
+ if h > 40:
175
+ lines.append(f' {d.id}: "{escaped}" {{ class: dept; height: {h} }}')
176
+ else:
177
+ lines.append(f' {d.id}: "{escaped}" {{ class: dept }}')
178
+
179
+ # 如果有 cluster_head,也作为 dept 节点(或跳过,用 label 代替)
180
+ # 这里跳过 cluster_head 节点,因为 block.label 已经充当标题
181
+ lines.append(' }')
182
+ lines.append('')
183
+
184
+ lines.append('}')
185
+ lines.append('')
186
+
187
+ # ── 连线:中心领导 → 各板块 ──
188
+ if deputy_nodes and plate_blocks:
189
+ for block in plate_blocks:
190
+ lines.append(f'leadership.deputies -> plates.{block.id}')
191
+ elif leader_id and plate_blocks:
192
+ for block in plate_blocks:
193
+ lines.append(f'leadership.{leader_id} -> plates.{block.id}')
194
+ lines.append('')
195
+
196
+ return '\n'.join(lines)