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.
@@ -0,0 +1,38 @@
1
+ from typing import Optional
2
+ from ..models import EquityChart
3
+ from ..themes import THEMES, parse_paper_size
4
+
5
+ def generate_equity_dot(chart: EquityChart, paper_size: Optional[str] = None, dpi: Optional[int] = None) -> str:
6
+ theme = THEMES.get(chart.meta.theme, THEMES["corporate"])
7
+ lines = ['digraph G {']
8
+ lines.append(f'label="{chart.meta.title}"; labelloc=t; fontsize=14;')
9
+ lines.append(f'rankdir=TB;')
10
+ ps = parse_paper_size(paper_size) if paper_size else None
11
+ if ps:
12
+ lines.append(f'size="{ps["size"]}"; ratio={ps["ratio"]}; margin=0;')
13
+ lines.append(f'ranksep=0.3; nodesep=0.15;')
14
+ else:
15
+ lines.append(f'ranksep={theme["ranksep"]}; nodesep={theme["nodesep"]};')
16
+ if dpi:
17
+ lines.append(f'dpi={dpi};')
18
+ nd = theme["node_default"]
19
+ lines.append(f'node [shape={nd["shape"]} fontname="{nd["fontname"]}" fontsize={nd["fontsize"]}];')
20
+ lines.append(f'edge [color="{theme["edge_color"]}" penwidth={theme["edge_penwidth"]}];')
21
+
22
+ for node in chart.nodes:
23
+ attrs = []
24
+ if node.shape: attrs.append(f'shape={node.shape}')
25
+ if node.style: attrs.append(f'style="{node.style}"')
26
+ if node.fillcolor: attrs.append(f'fillcolor="{node.fillcolor}"')
27
+ if node.color: attrs.append(f'color="{node.color}"')
28
+ if node.fontcolor: attrs.append(f'fontcolor="{node.fontcolor}"')
29
+ attr_str = ", ".join(attrs)
30
+ lines.append(f'"{node.id}" [label="{node.label}" {attr_str}];')
31
+
32
+ for edge in chart.edges:
33
+ attrs = [f'label="{edge.label}"', f'penwidth={edge.penwidth}',
34
+ f'color="{edge.color}"', f'style={edge.style}']
35
+ lines.append(f'"{edge.from_id}" -> "{edge.to}" [{", ".join(attrs)}];')
36
+
37
+ lines.append('}')
38
+ return '\n'.join(lines)
@@ -0,0 +1,281 @@
1
+ """Excalidraw generator. YAML model → .excalidraw JSON with manual layout."""
2
+ import json, math
3
+ from typing import Optional, List, Dict, Any
4
+ from ..models import OrgChart, OrgNode, OrgBlock
5
+ from ..themes import THEMES
6
+
7
+ RECT = "rectangle"; TEXT = "text"; ARROW = "arrow"
8
+
9
+ # Layout constants (Excalidraw units ≈ px)
10
+ LARGE_W, LARGE_H = 120, 38
11
+ CARD_W, CARD_H = 130, 48
12
+ DEPT_W, DEPT_H = 110, 34
13
+ SIDE_W, SIDE_H = 110, 34 # side block node (committee)
14
+ GAP_Y = 60 # inter-level vertical gap
15
+ GAP_X = 16 # inter-node horizontal gap
16
+ ROW_GAP = 8 # intra-block row gap
17
+ PAD_X = 8; PAD_Y = 20 # block padding
18
+ PAGE_W = 1600; PAGE_MARGIN = 40
19
+ FONT = 13; FONT_S = 11
20
+
21
+ def _tc(name):
22
+ t = THEMES.get(name, THEMES["corporate"])
23
+ return {"hb": t["card_header_bg"], "hf": t["card_header_font"],
24
+ "bb": t["card_body_bg"], "br": t["card_border_color"],
25
+ "db": t["dept_fillcolor"], "dc": t["dept_border"],
26
+ "cb": t["cluster_bg"], "ec": t["edge_color"]}
27
+
28
+ def _el(eid, tp, x, y, w, h, **kw):
29
+ return {"id": eid, "type": tp, "x": round(x,1), "y": round(y,1),
30
+ "width": round(w,1), "height": round(h,1),
31
+ "strokeColor": kw.get("sc","#1e1e1e"),
32
+ "backgroundColor": kw.get("bg","#ffffff"),
33
+ "fillStyle": kw.get("fs","solid"),
34
+ "strokeWidth": kw.get("sw",1),
35
+ "strokeStyle": kw.get("ss","solid"),
36
+ "roughness": 0, "opacity": 100,
37
+ "roundness": {"type":3} if tp==RECT else None,
38
+ "groupIds": [], "isDeleted": False}
39
+
40
+ def _tx(eid, x, y, w, h, text, fs=FONT, color="#1e1e1e", bold=False):
41
+ return {"id": eid, "type": TEXT, "x": round(x,1), "y": round(y,1),
42
+ "width": round(w,1), "height": round(h,1),
43
+ "text": text, "fontSize": fs, "fontFamily": 1,
44
+ "textAlign": "center", "verticalAlign": "middle",
45
+ "strokeColor": color, "backgroundColor": "transparent",
46
+ "fillStyle": "solid", "strokeWidth": 0, "roughness": 0,
47
+ "opacity": 100, "groupIds": [], "isDeleted": False}
48
+
49
+ def _ar(eid, pts, sc="#7F8C8D"):
50
+ x0, y0 = pts[0]
51
+ return {"id": eid, "type": ARROW,
52
+ "x": round(x0,1), "y": round(y0,1), "width": 0, "height": 0,
53
+ "points": [[round(p[0]-x0,1), round(p[1]-y0,1)] for p in pts],
54
+ "startArrowhead": None, "endArrowhead": "arrow",
55
+ "strokeColor": sc, "strokeWidth": 2, "roughness": 0,
56
+ "opacity": 100, "groupIds": [], "isDeleted": False,
57
+ "startBinding": None, "endBinding": None}
58
+
59
+ def generate_org_excalidraw(chart: OrgChart, paper_size=None, dpi=None):
60
+ tc = _tc(chart.meta.theme)
61
+ layout = chart.meta.layout
62
+ els = []; ei = [0]; nr = {}
63
+
64
+ def _nid(): ei[0]+=1; return f"e{ei[0]}"
65
+
66
+ def _large(node, x, y):
67
+ e = _el(_nid(), RECT, x, y, LARGE_W, LARGE_H, bg=tc["hb"], sc=tc["br"])
68
+ els.append(e); els.append(_tx(_nid(), x, y, LARGE_W, LARGE_H,
69
+ node.title, FONT+2, "#ffffff", True))
70
+ return {"eid": e["id"], "x": x, "y": y, "w": LARGE_W, "h": LARGE_H}
71
+
72
+ def _card(node, x, y):
73
+ w = CARD_W; h = CARD_H
74
+ lines = max(1, node.title.count('\n') + 1)
75
+ h = 22 + lines * 16
76
+ e = _el(_nid(), RECT, x, y, w, h, bg=tc["bb"], sc=tc["br"])
77
+ els.append(e)
78
+ els.append(_el(_nid(), RECT, x, y, w, 20, bg=tc["hb"], sc=tc["hb"]))
79
+ title = node.title.replace('\n', ' ')
80
+ els.append(_tx(_nid(), x, y+1, w, 18, title, FONT, tc["hf"], True))
81
+ return {"eid": e["id"], "x": x, "y": y, "w": w, "h": h}
82
+
83
+ def _side(node, x, y):
84
+ e = _el(_nid(), RECT, x, y, SIDE_W, SIDE_H, bg=tc["hb"], sc=tc["br"])
85
+ els.append(e)
86
+ els.append(_tx(_nid(), x, y, SIDE_W, SIDE_H, node.title, FONT_S, "#ffffff"))
87
+ return {"eid": e["id"], "x": x, "y": y, "w": SIDE_W, "h": SIDE_H}
88
+
89
+ def _dept(node, x, y):
90
+ e = _el(_nid(), RECT, x, y, DEPT_W, DEPT_H, bg=tc["db"], sc=tc["dc"])
91
+ els.append(e)
92
+ els.append(_tx(_nid(), x, y, DEPT_W, DEPT_H, node.title, FONT_S, "#1e1e1e"))
93
+ return {"eid": e["id"], "x": x, "y": y, "w": DEPT_W, "h": DEPT_H}
94
+
95
+ def _chunk_layout(nodes, start_x, y, node_w, node_h, gap_x, max_cols, render_fn):
96
+ """Layout nodes in max_cols per row. Returns list of (x,y,info) and total_h."""
97
+ rows = [nodes[i:i+max_cols] for i in range(0, len(nodes), max_cols)]
98
+ results = []
99
+ for row in rows:
100
+ rw = len(row) * (node_w + gap_x) - gap_x
101
+ rx = start_x + (max_cols * (node_w + gap_x) - gap_x - rw) / 2
102
+ for j, n in enumerate(row):
103
+ info = render_fn(n, rx + j * (node_w + gap_x), y)
104
+ results.append(info)
105
+ y += node_h + ROW_GAP
106
+ return results, y
107
+
108
+ cur_y = PAGE_MARGIN
109
+
110
+ for group in chart.groups:
111
+ right_blocks = [b for b in group.blocks if b.position == "right"]
112
+ bottom_blocks = [b for b in group.blocks if b.position == "bottom"]
113
+ main_nodes = group.nodes
114
+ right_nodes = group.right_nodes
115
+
116
+ # Level starting Y
117
+ level_start_y = cur_y
118
+ if getattr(group, 'gap', None):
119
+ cur_y += group.gap * 15
120
+
121
+ # ── Collect ALL same-rank nodes (main + right_nodes + side block nodes) ──
122
+ all_same = list(main_nodes) + list(right_nodes)
123
+ for b in right_blocks:
124
+ all_same.extend(b.nodes)
125
+
126
+ # Determine if we need multi-row for side block wrapping
127
+ side_mc = right_blocks[0].max_columns if right_blocks else 0
128
+ side_wrap = side_mc > 0 and len([n for b in right_blocks for n in b.nodes]) > side_mc
129
+
130
+ if side_wrap:
131
+ # Layout: main node on left, side block nodes to the right, wrapped
132
+ total_nodes = len(main_nodes) + len(right_nodes)
133
+ total_side = len([n for b in right_blocks for n in b.nodes])
134
+ side_chunks = [[n for b in right_blocks for n in b.nodes][i:i+side_mc]
135
+ for i in range(0, total_side, side_mc)]
136
+
137
+ # Center the combined content
138
+ main_w = len(main_nodes) * (LARGE_W + GAP_X) - GAP_X if main_nodes else 0
139
+ side_total_w = side_mc * (SIDE_W + GAP_X)
140
+ total_w = main_w + GAP_X * 3 + side_total_w
141
+ start_x = (PAGE_W - total_w) / 2
142
+
143
+ # Main nodes at left
144
+ m_cur_y = cur_y
145
+ for i, node in enumerate(main_nodes):
146
+ x = start_x + i * (LARGE_W + GAP_X)
147
+ nr[node.id] = _large(node, x, m_cur_y)
148
+ m_cur_y = max(m_cur_y, cur_y)
149
+
150
+ # Side block nodes wrapping
151
+ s_cur_y = cur_y
152
+ side_info = []
153
+ side_x = start_x + main_w + GAP_X * 3
154
+ for chunk in side_chunks:
155
+ for j, node in enumerate(chunk):
156
+ x = side_x + j * (SIDE_W + GAP_X)
157
+ info = _card(node, x, s_cur_y)
158
+ nr[node.id] = info
159
+ side_info.append(info)
160
+ s_cur_y += SIDE_H + ROW_GAP
161
+
162
+ # Dashed block around side nodes
163
+ if side_info:
164
+ bx = min(i["x"] for i in side_info) - PAD_X
165
+ by = min(i["y"] for i in side_info) - PAD_Y
166
+ bw = max(i["x"] + i["w"] for i in side_info) - bx + PAD_X
167
+ bh = max(i["y"] + i["h"] for i in side_info) - by + PAD_Y
168
+ els.append(_el(_nid(), RECT, bx, by, bw, bh,
169
+ bg=tc["cb"], sc=tc["br"], ss="dashed"))
170
+
171
+ cur_y = max(s_cur_y, cur_y + LARGE_H) + GAP_Y
172
+
173
+ else:
174
+ # Standard: all nodes on one row, centered
175
+ n_total = len(all_same) if all_same else 1
176
+ total_w = n_total * (LARGE_W + GAP_X) - GAP_X
177
+ start_x = (PAGE_W - total_w) / 2
178
+ if group.offset and group.offset > 0:
179
+ start_x += group.offset * 72
180
+
181
+ for i, node in enumerate(all_same):
182
+ x = start_x + i * (LARGE_W + GAP_X)
183
+ nr[node.id] = _large(node, x, cur_y) if node.style == "large" else _card(node, x, cur_y)
184
+
185
+ # Dashed block for right blocks (non-wrapping)
186
+ for b in right_blocks:
187
+ if not b.nodes: continue
188
+ xs = [nr[n.id]["x"] for n in b.nodes if n.id in nr]
189
+ if not xs: continue
190
+ bx = min(xs) - PAD_X
191
+ bw = max(nr[n.id]["x"] + nr[n.id]["w"] for n in b.nodes if n.id in nr) - bx + PAD_X
192
+ by = min(nr[n.id]["y"] for n in b.nodes if n.id in nr) - PAD_Y
193
+ bh = max(nr[n.id]["y"] + nr[n.id]["h"] for n in b.nodes if n.id in nr) - by + PAD_Y
194
+ els.append(_el(_nid(), RECT, bx, by, bw, bh,
195
+ bg=tc["cb"], sc=tc["br"], ss="dashed"))
196
+
197
+ cur_y += LARGE_H + GAP_Y
198
+
199
+ # ── Bottom blocks ──
200
+ if bottom_blocks:
201
+ max_cols = group.max_columns or (layout.max_columns if layout else 3)
202
+ if max_cols <= 0: max_cols = len(bottom_blocks)
203
+ eff_cols = min(max_cols, len(bottom_blocks))
204
+
205
+ # Column width per block
206
+ col_w = DEPT_W * 2 + GAP_X # max_columns=2 → two depts per row
207
+ total_block_w = eff_cols * col_w + (eff_cols - 1) * GAP_X * 2
208
+ block_start_x = (PAGE_W - total_block_w) / 2
209
+
210
+ max_block_h = 0
211
+ block_anchors = []
212
+
213
+ for bi, b in enumerate(bottom_blocks):
214
+ col = bi % eff_cols
215
+ bx = block_start_x + col * (col_w + GAP_X * 2)
216
+ dept_y = cur_y
217
+ block_info = []
218
+
219
+ depts = [n for n in b.nodes if not n.is_cluster_head]
220
+ heads = [n for n in b.nodes if n.is_cluster_head]
221
+ mc = b.max_columns or 2
222
+
223
+ # Heads
224
+ for h in heads:
225
+ info = _large(h, bx + (col_w - LARGE_W)/2, dept_y)
226
+ nr[h.id] = info; block_info.append(info)
227
+ dept_y += LARGE_H + ROW_GAP
228
+
229
+ # Departments in rows
230
+ for ri in range(0, len(depts), mc):
231
+ row = depts[ri:ri+mc]
232
+ rw = len(row) * (DEPT_W + GAP_X) - GAP_X
233
+ rx = bx + (col_w - rw) / 2
234
+ for j, d in enumerate(row):
235
+ info = _dept(d, rx + j * (DEPT_W + GAP_X), dept_y)
236
+ nr[d.id] = info; block_info.append(info)
237
+ dept_y += DEPT_H + ROW_GAP
238
+
239
+ # Dashed block rect
240
+ if block_info:
241
+ minx = min(i["x"] for i in block_info) - PAD_X
242
+ miny = min(i["y"] for i in block_info) - PAD_Y
243
+ maxx = max(i["x"] + i["w"] for i in block_info) + PAD_X
244
+ maxy = max(i["y"] + i["h"] for i in block_info) + PAD_Y
245
+ els.append(_el(_nid(), RECT, minx, miny, maxx-minx, maxy-miny,
246
+ bg=tc["cb"], sc=tc["br"], ss="dashed"))
247
+ block_anchors.append((minx, miny, maxx, maxy))
248
+ max_block_h = max(max_block_h, maxy - cur_y)
249
+
250
+ cur_y += max_block_h + GAP_Y
251
+
252
+ # ── Arrows ──
253
+ for group in chart.groups:
254
+ for node in group.nodes + group.right_nodes:
255
+ if node.reports_to and node.reports_to in nr:
256
+ src, dst = nr[node.reports_to], nr[node.id]
257
+ els.append(_ar(_nid(), [
258
+ (src["x"]+src["w"]/2, src["y"]+src["h"]),
259
+ (dst["x"]+dst["w"]/2, dst["y"])
260
+ ], tc["ec"]))
261
+ for b in group.blocks:
262
+ for node in b.nodes:
263
+ if node.reports_to and node.reports_to in nr:
264
+ src, dst = nr[node.reports_to], nr[node.id]
265
+ els.append(_ar(_nid(), [
266
+ (src["x"]+src["w"]/2, src["y"]+src["h"]),
267
+ (dst["x"]+dst["w"]/2, dst["y"])
268
+ ], tc["ec"]))
269
+ if b.edge_from and b.nodes and b.edge_from in nr:
270
+ src, dst = nr[b.edge_from], nr[b.nodes[0].id]
271
+ els.append(_ar(_nid(), [
272
+ (src["x"]+src["w"]/2, src["y"]+src["h"]),
273
+ (dst["x"]+dst["w"]/2, dst["y"])
274
+ ], tc["ec"]))
275
+
276
+ return json.dumps({
277
+ "type": "excalidraw", "version": 2, "source": "https://structviz",
278
+ "elements": els,
279
+ "appState": {"viewBackgroundColor": "#ffffff"},
280
+ "files": {}
281
+ }, ensure_ascii=False, indent=2)
@@ -0,0 +1,300 @@
1
+ from typing import Optional, List, Tuple
2
+ from ..models import OrgChart, OrgNode, OrgBlock
3
+ from ..themes import THEMES, parse_paper_size
4
+
5
+
6
+ def _make_card(node: OrgNode, theme: dict, style_override: Optional[str] = None) -> str:
7
+ def _br(s):
8
+ return s.replace('\n', '<br/>')
9
+ style = style_override if style_override else node.style
10
+ if style == "external":
11
+ header_bg = theme["external_header_bg"]
12
+ elif style == "large":
13
+ header_bg = theme["card_header_bg"]
14
+ return (f'< <table border="0" cellborder="0" cellspacing="0" cellpadding="6">'
15
+ f'<tr><td bgcolor="{header_bg}"><font color="{theme["card_header_font"]}"><b>{_br(node.title)}</b></font></td></tr>'
16
+ f'</table> >')
17
+ else:
18
+ header_bg = theme["card_header_bg"]
19
+ body_bg = theme["card_body_bg"]
20
+ header_font = theme["card_header_font"]
21
+ label = f'< <table border="0" cellborder="0" cellspacing="0" cellpadding="3">'
22
+ label += f'<tr><td bgcolor="{header_bg}"><font color="{header_font}"><b>{_br(node.title)}</b></font></td></tr>'
23
+ if node.name:
24
+ label += f'<tr><td bgcolor="{body_bg}">{_br(node.name)}</td></tr>'
25
+ if node.role:
26
+ label += f'<tr><td bgcolor="{body_bg}"><font point-size="9">{_br(node.role)}</font></td></tr>'
27
+ label += '</table> >'
28
+ return label
29
+
30
+
31
+ def _emit_node_def(node: OrgNode, theme: dict, lines: List[str], all_edges: set,
32
+ in_block: bool = False):
33
+ card = _make_card(node, theme, style_override="large" if node.is_cluster_head else None)
34
+ if in_block and not node.is_cluster_head:
35
+ lines.append(f'"{node.id}" [label={card}, shape=box, '
36
+ f'style="rounded,filled", fillcolor="{theme["dept_fillcolor"]}", '
37
+ f'color="{theme["dept_border"]}"];')
38
+ else:
39
+ lines.append(f'"{node.id}" [label={card}, shape=plaintext];')
40
+ if node.reports_to:
41
+ all_edges.add((node.reports_to, node.id))
42
+
43
+
44
+ def _emit_ids_rank(ids: List[str], lines: List[str], prev_last: Optional[str] = None) -> Optional[str]:
45
+ if not ids:
46
+ return prev_last
47
+ lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
48
+ for i in range(len(ids) - 1):
49
+ lines.append(f'"{ids[i]}" -> "{ids[i+1]}" [style=invis, weight=10];')
50
+ if prev_last:
51
+ lines.append(f'"{prev_last}" -> "{ids[0]}" [style=invis, weight=100];')
52
+ return ids[-1]
53
+
54
+
55
+ def _group_anchor_ids(group) -> Tuple[Optional[str], Optional[str]]:
56
+ first = last = None
57
+ for lst in [group.nodes, group.right_nodes]:
58
+ if lst:
59
+ first = first or lst[0].id
60
+ last = lst[-1].id
61
+ for b in group.blocks:
62
+ if b.nodes:
63
+ first = first or b.nodes[0].id
64
+ last = b.nodes[-1].id
65
+ return first, last
66
+
67
+
68
+ def _emit_block_subgraph(block: OrgBlock, theme: dict, lines: List[str],
69
+ all_edges: set, is_right: bool, pad_w: float,
70
+ row_gap: float = 1.0):
71
+ lines.append(f'subgraph cluster_{block.id} {{')
72
+ lines.append(f'label="{block.label}"; fontsize=12; '
73
+ f'style="rounded,dashed"; color="{theme["card_border_color"]}"; '
74
+ f'bgcolor="{theme["cluster_bg"]}";')
75
+ in_blk = not is_right
76
+ heads = [n for n in block.nodes if n.is_cluster_head]
77
+ depts = [n for n in block.nodes if not n.is_cluster_head]
78
+
79
+ for n in heads:
80
+ _emit_node_def(n, theme, lines, all_edges, in_block=in_blk)
81
+
82
+ mc = block.max_columns or 0
83
+ if mc > 0 and len(depts) > mc and not is_right:
84
+ chunks = [depts[i:i + mc] for i in range(0, len(depts), mc)]
85
+ prev_anchor = None
86
+ for chunk in chunks:
87
+ anchor = f"_anc_{block.id}_{len(lines)}"
88
+ lines.append(f'"{anchor}" [shape=point, width=0, style=invis];')
89
+ if prev_anchor:
90
+ lines.append(f'"{prev_anchor}" -> "{anchor}" [style=invis, weight=100];')
91
+ lines.append(f'"{prev_anchor}" -> "{chunk[0].id}" [style=invis, minlen={row_gap}];')
92
+ lines.append('{ rank=same;')
93
+ lines.append(f'"{anchor}";')
94
+ for n in chunk:
95
+ _emit_node_def(n, theme, lines, all_edges, in_block=in_blk)
96
+ lines.append('}')
97
+ lines.append(f'"{anchor}" -> "{chunk[0].id}" [style=invis, weight=100];')
98
+ for i in range(len(chunk) - 1):
99
+ lines.append(f'"{chunk[i].id}" -> "{chunk[i+1].id}" [style=invis, weight=10];')
100
+ prev_anchor = anchor
101
+ else:
102
+ for n in depts:
103
+ _emit_node_def(n, theme, lines, all_edges, in_block=in_blk)
104
+ lines.append('}')
105
+
106
+
107
+ def generate_org_dot(chart: OrgChart, paper_size: Optional[str] = None,
108
+ dpi: Optional[int] = None) -> str:
109
+ theme = THEMES.get(chart.meta.theme, THEMES["corporate"])
110
+ layout = chart.meta.layout
111
+ nodesep = layout.nodesep if layout else theme["nodesep"]
112
+ ranksep = layout.ranksep if layout else theme["ranksep"]
113
+ pad_w = layout.block_pad if layout else 0.15
114
+ row_gap_cfg = layout.row_gap if layout else 1.0
115
+ row_gap = max(1, round(row_gap_cfg))
116
+ def _to_minlen(v):
117
+ return max(1, round(v)) if v is not None else row_gap
118
+ global_max_cols = layout.max_columns if layout else 0
119
+
120
+ has_edge_from = any(b.edge_from for g in chart.groups for b in g.blocks)
121
+ compound = ' compound=true;' if has_edge_from else ''
122
+
123
+ lines = ['digraph G {']
124
+ lines.append(f'label="{chart.meta.title}"; labelloc=t; fontsize=14;')
125
+ lines.append(f'rankdir=TB; splines=ortho; newrank=true; center=true; ordering=out;{compound}')
126
+ ps = parse_paper_size(paper_size) if paper_size else None
127
+ if ps:
128
+ lines.append(f'size="{ps["size"]}"; ratio={ps["ratio"]}; margin=0;')
129
+ lines.append(f'ranksep=0.3; nodesep=0.15;')
130
+ else:
131
+ lines.append(f'ranksep={ranksep}; nodesep={nodesep};')
132
+ lines.append(f'dpi={dpi};') if dpi else None
133
+ lines.append(f'node [shape=plaintext fontname="{theme["node_default"]["fontname"]}" '
134
+ f'fontsize={theme["node_default"]["fontsize"]}];')
135
+ lines.append(f'edge [color="{theme["edge_color"]}" penwidth={theme["edge_penwidth"]} arrowsize=0.8];')
136
+
137
+ all_edges = set()
138
+ gap_edges = []
139
+
140
+ for group in chart.groups:
141
+ left_blocks = [b for b in group.blocks if b.position == "left"]
142
+ right_blocks = [b for b in group.blocks if b.position == "right"]
143
+ bottom_blocks = [b for b in group.blocks if b.position == "bottom"]
144
+ max_rc = group.max_right_columns or 0
145
+
146
+ # ── side blocks 子图 (left + right) ──
147
+ lb_nodes, rb_nodes = [], []
148
+ for b in left_blocks + right_blocks:
149
+ brg = _to_minlen(b.row_gap)
150
+ _emit_block_subgraph(b, theme, lines, all_edges, is_right=True, pad_w=pad_w, row_gap=brg)
151
+ if b.edge_from and b.nodes:
152
+ if b.position == "left":
153
+ lines.append(f'"{b.edge_from}" -> "{b.nodes[0].id}";')
154
+ else:
155
+ lines.append(f'"{b.edge_from}" -> "{b.nodes[0].id}" [lhead=cluster_{b.id}];')
156
+ lb_nodes = [n for b in left_blocks for n in b.nodes]
157
+ rb_nodes = [n for b in right_blocks for n in b.nodes]
158
+
159
+ # ── 主节点 + right_nodes rank ──
160
+ m_ids = [n.id for n in group.nodes]
161
+ r_ids = [n.id for n in group.right_nodes]
162
+
163
+ if group.offset and group.offset > 0:
164
+ sid = f"_shift_L{group.level}"
165
+ lines.append(f'"{sid}" [shape=point, width={group.offset}, style=invis];')
166
+ first = m_ids[0] if m_ids else (r_ids[0] if r_ids else None)
167
+ if first:
168
+ lines.append(f'"{sid}" -> "{first}" [style=invis, weight=100];')
169
+ m_ids = [sid] + m_ids
170
+
171
+ if max_rc > 0 and len(group.right_nodes) > max_rc:
172
+ if m_ids:
173
+ _emit_ids_rank(m_ids, lines)
174
+ r_chunks = [r_ids[i:i + max_rc] for i in range(0, len(r_ids), max_rc)]
175
+ prev_last = m_ids[-1] if m_ids else None
176
+ for chunk in r_chunks:
177
+ prev_last = _emit_ids_rank(chunk, lines, prev_last)
178
+ else:
179
+ if m_ids + r_ids and not (lb_nodes or rb_nodes):
180
+ ids = list(m_ids + r_ids)
181
+ nsep = group.nodesep if group.nodesep is not None else (layout.nodesep if layout else None)
182
+ if nsep and nsep > 0 and len(ids) > 1:
183
+ spaced = [ids[0]]
184
+ for k in range(1, len(ids)):
185
+ gid = f"_gap_{group.level}_{k}"
186
+ lines.append(f'"{gid}" [shape=point, width={nsep}, style=invis];')
187
+ spaced.append(gid)
188
+ spaced.append(ids[k])
189
+ _emit_ids_rank(spaced, lines)
190
+ else:
191
+ _emit_ids_rank(ids, lines)
192
+
193
+ # ── left blocks rank 约束 ──
194
+ if lb_nodes:
195
+ lb_ids = [n.id for n in lb_nodes]
196
+ lb_mc = left_blocks[0].max_columns if left_blocks else 0
197
+ if lb_mc > 0 and len(lb_ids) > lb_mc:
198
+ chunks = [lb_ids[i:i + lb_mc] for i in range(0, len(lb_ids), lb_mc)]
199
+ prev_last = None
200
+ for ci, chunk in enumerate(chunks):
201
+ ids = chunk
202
+ if ci == len(chunks) - 1 and (m_ids or r_ids):
203
+ ids = ids + (m_ids if m_ids else r_ids)
204
+ # 只输出 rank=same,不加不可见边(靠自然排序)
205
+ lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
206
+ if prev_last:
207
+ lines.append(f'"{prev_last}" -> "{chunk[0]}" [style=invis, minlen={row_gap}];')
208
+ prev_last = chunk[-1]
209
+ else:
210
+ if m_ids or r_ids:
211
+ ids = lb_ids + (m_ids if m_ids else r_ids)
212
+ else:
213
+ ids = lb_ids
214
+ lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
215
+
216
+ # ── right blocks: 同 rank=same 但不加行内边 (交给 Graphviz) ──
217
+ if rb_nodes:
218
+ rb_ids = [n.id for n in rb_nodes]
219
+ rb_mc = right_blocks[0].max_columns if right_blocks else 0
220
+ rb_gap = _to_minlen(right_blocks[0].row_gap) if right_blocks else row_gap
221
+ all_rb_ids = list(rb_ids)
222
+ if rb_mc > 0 and len(rb_ids) > rb_mc:
223
+ chunks = [rb_ids[i:i + rb_mc] for i in range(0, len(rb_ids), rb_mc)]
224
+ prev_last = None
225
+ for ci, chunk in enumerate(chunks):
226
+ ids = chunk
227
+ if ci == 0 and (m_ids or r_ids):
228
+ ids = (m_ids if m_ids else r_ids) + chunk
229
+ lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
230
+ if prev_last:
231
+ lines.append(f'"{prev_last}" -> "{chunk[0]}" [style=invis, minlen={rb_gap}];')
232
+ prev_last = chunk[-1]
233
+ else:
234
+ ids = (m_ids if m_ids else r_ids) + rb_ids if (m_ids or r_ids) else rb_ids
235
+ lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
236
+
237
+ # ── bottom blocks ──
238
+ if bottom_blocks:
239
+ max_cols = group.max_columns if group.max_columns is not None else global_max_cols
240
+ eff_cols = max_cols if max_cols > 0 else len(bottom_blocks)
241
+ chunks = [bottom_blocks[i:i + eff_cols] for i in range(0, len(bottom_blocks), eff_cols)]
242
+ prev_row_heads = []
243
+ for chunk in chunks:
244
+ row_heads = [n for b in chunk for n in b.nodes if n.is_cluster_head]
245
+ if not row_heads:
246
+ # 无 cluster head → 取每个 block 首节点对齐
247
+ row_heads = [b.nodes[0] for b in chunk if b.nodes]
248
+ if row_heads:
249
+ off_w = group.offset if group.offset else 0
250
+ gap_w = group.nodesep if group.nodesep is not None else (layout.nodesep if layout else 0)
251
+ p_l = f"_bal_L{group.level}"
252
+ p_r = f"_bal_R{group.level}"
253
+ lines.append(f'"{p_l}" [shape=point, width={off_w}, style=invis];')
254
+ lines.append(f'"{p_r}" [shape=point, width={off_w}, style=invis];')
255
+ # 插入间隔节点控制 block 间距
256
+ rank_ids = [p_l]
257
+ for j, h in enumerate(row_heads):
258
+ rank_ids.append(h.id)
259
+ if j < len(row_heads) - 1 and gap_w > 0:
260
+ gid = f"_gap_{group.level}_{j}"
261
+ lines.append(f'"{gid}" [shape=point, width={gap_w}, style=invis];')
262
+ rank_ids.append(gid)
263
+ rank_ids.append(p_r)
264
+ _emit_ids_rank(rank_ids, lines)
265
+ if prev_row_heads and row_heads:
266
+ lines.append(f'"{prev_row_heads[-1].id}" -> "{row_heads[0].id}" [style=invis, weight=100];')
267
+ prev_row_heads = row_heads
268
+ for b in chunk:
269
+ brg = _to_minlen(b.row_gap)
270
+ _emit_block_subgraph(b, theme, lines, all_edges, is_right=False, pad_w=pad_w, row_gap=brg)
271
+ if b.edge_from and b.nodes:
272
+ lines.append(f'"{b.edge_from}" -> "{b.nodes[0].id}" [lhead=cluster_{b.id}];')
273
+
274
+ # ── reports_to edges ──
275
+ for group in chart.groups:
276
+ for node in group.nodes + group.right_nodes:
277
+ if node.reports_to:
278
+ all_edges.add((node.reports_to, node.id))
279
+
280
+ # ── gap edges ──
281
+ for i in range(len(chart.groups) - 1):
282
+ g_curr, g_next = chart.groups[i], chart.groups[i + 1]
283
+ if g_curr.gap:
284
+ _, last_id = _group_anchor_ids(g_curr)
285
+ next_ids = [n.id for n in g_next.nodes + g_next.right_nodes]
286
+ for b in g_next.blocks:
287
+ next_ids.extend(n.id for n in b.nodes if n.is_cluster_head)
288
+ first_id = next_ids[len(next_ids) // 2] if next_ids else None
289
+ if not first_id:
290
+ first_id, _ = _group_anchor_ids(g_next)
291
+ if last_id and first_id:
292
+ gap_edges.append((last_id, first_id, g_curr.gap))
293
+
294
+ for src, dst in all_edges:
295
+ lines.append(f'"{src}" -> "{dst}";')
296
+ for src, dst, gap in gap_edges:
297
+ lines.append(f'"{src}" -> "{dst}" [style=invis, minlen={gap}];')
298
+
299
+ lines.append('}')
300
+ return '\n'.join(lines)
@@ -0,0 +1,31 @@
1
+ HTML_TEMPLATE = """
2
+ <!DOCTYPE html>
3
+ <html>
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>{{ title }}</title>
7
+ <style>
8
+ body { margin: 0; display: flex; justify-content: center; background: #f5f5f5; }
9
+ .container { max-width: 100%; }
10
+ svg { max-height: 100vh; }
11
+ .node:hover { filter: brightness(0.95); cursor: pointer; }
12
+ .edge:hover { stroke-width: 2.5; }
13
+ text { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <div class="container">
18
+ {{ svg_content | safe }}
19
+ </div>
20
+ </body>
21
+ </html>
22
+ """
23
+
24
+ def generate_html(svg_path: str, title: str, output_html: str):
25
+ with open(svg_path, 'r', encoding='utf-8') as f:
26
+ svg = f.read()
27
+ from jinja2 import Template
28
+ template = Template(HTML_TEMPLATE)
29
+ html = template.render(title=title, svg_content=svg)
30
+ with open(output_html, 'w', encoding='utf-8') as f:
31
+ f.write(html)