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 +1 -0
- structviz/cli.py +92 -0
- structviz/d2_render.py +169 -0
- structviz/excalidraw_render.py +213 -0
- structviz/generators/__init__.py +4 -0
- structviz/generators/d2.py +196 -0
- structviz/generators/equity.py +38 -0
- structviz/generators/excalidraw.py +281 -0
- structviz/generators/org.py +300 -0
- structviz/html_wrapper.py +31 -0
- structviz/models.py +82 -0
- structviz/render.py +20 -0
- structviz/themes.py +99 -0
- structviz-0.2.dist-info/METADATA +205 -0
- structviz-0.2.dist-info/RECORD +18 -0
- structviz-0.2.dist-info/WHEEL +5 -0
- structviz-0.2.dist-info/entry_points.txt +2 -0
- structviz-0.2.dist-info/top_level.txt +1 -0
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,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)
|