kymo 0.1.0__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.
kymo/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """kymo — diagram-as-code DSL → animated SVG / WebP / Figma / Excalidraw.
2
+
3
+ Public API:
4
+
5
+ from kymo import parse, layout, resolve_alignments, render
6
+
7
+ diagram, layout_spec, external = parse(source_text)
8
+ if layout_spec:
9
+ layout(diagram, layout_spec, external)
10
+ resolve_alignments(diagram)
11
+ svg = render(diagram, animate=True)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from .alignment import resolve_alignments
16
+ from .dsl import parse
17
+ from .layout import layout
18
+ from .model import Component, Diagram, Edge, Region
19
+ from .to_svg import render
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "parse",
25
+ "layout",
26
+ "resolve_alignments",
27
+ "render",
28
+ "Component",
29
+ "Diagram",
30
+ "Edge",
31
+ "Region",
32
+ "__version__",
33
+ ]
kymo/alignment.py ADDED
@@ -0,0 +1,534 @@
1
+ """Local alignment — parent/child positioning + auto-bounded regions.
2
+
3
+ Components with a `parent` reference have their `pos` computed AFTER all
4
+ absolutely-positioned anchors are placed. Each child declares which side
5
+ of its parent it sits against (`align`) and how much breathing room
6
+ (`align_gap`).
7
+
8
+ Regions with a `contains` list have their `bounds` computed AFTER all
9
+ component positions are resolved — the bounding box automatically grows
10
+ to enclose every listed component INCLUDING its label area.
11
+
12
+ Resolution is depth-first with cycle detection; safe to call once per
13
+ diagram before rendering.
14
+
15
+ Example:
16
+
17
+ Component("orch", pos=(860, 200))
18
+ Component("researcher", parent="orch", align="right", align_gap=50)
19
+ Component("planner", parent="orch", align="bottom", align_gap=70)
20
+ Component("fs", parent="planner", align="right", align_gap=70)
21
+ Component("todo", parent="fs", align="right", align_gap=20)
22
+
23
+ Moving `orch` by 50 px moves every descendant by 50 px automatically.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from .model import Component, Diagram, LABEL_HEIGHT
28
+
29
+
30
+ # Approximate per-char widths used to estimate label extent. The renderer
31
+ # does not have access to real font metrics; these constants are tuned for
32
+ # the diagram's font stack (14 px bold name, 11.5 px regular subtitle).
33
+ # Slight overestimate is intentional — narrow labels never overlap, wide
34
+ # labels get just-enough breathing room.
35
+ _NAME_CHAR_W = 7 # 14 px bold sans average ≈ 7 px/char
36
+ _SUB_CHAR_W = 6 # 11.5 px regular sans ≈ 6 px/char
37
+
38
+
39
+ def _label_half_width(c: Component) -> int:
40
+ """Approximate half-width of the longest text line under `c`'s icon.
41
+ Used by the auto-layout resolver to prevent adjacent components' labels
42
+ from overlapping. Returns 0 for shapes that don't render labels
43
+ (annotation, badge)."""
44
+ if c.shape in ("annotation", "badge"):
45
+ return 0
46
+ name_w = len(c.name) * _NAME_CHAR_W
47
+ sub_w = len(c.subtitle) * _SUB_CHAR_W
48
+ return max(name_w, sub_w) // 2
49
+
50
+
51
+ def _effective_half(c: Component) -> tuple[int, int]:
52
+ """`Component.half` widened to whichever is bigger: icon or label.
53
+ Heights stay icon-only (`half[1]`); the label area below the icon is
54
+ accounted for separately via `LABEL_HEIGHT`."""
55
+ hw, hh = c.half
56
+ return (max(hw, _label_half_width(c)), hh)
57
+
58
+
59
+ def resolve_alignments(diagram: Diagram) -> None:
60
+ """Five-pass resolver:
61
+
62
+ 1. `_resolve_auto_layouts` — Figma-style: regions with `layout` direction
63
+ position every child component along that axis with `gap` spacing.
64
+ 2. `_resolve_component_alignments` — pairwise parent/child anchoring
65
+ (`align="right"` etc) for components not placed by auto-layout.
66
+ 3. `_resolve_region_bounds` — region bounding boxes computed from the
67
+ enclosed components (after their positions are final).
68
+ 4. `_stagger_fanin_edges` — when several edges converge on the same
69
+ anchor (e.g. 3 webs → 1 userdb), spread their dst attach points
70
+ so the arrowheads don't pile up at one pixel.
71
+ 5. `_auto_size_canvas` — if `diagram.width`/`height` are 0, derive
72
+ them from the resolved geometry (component + region + via extents)
73
+ plus a margin. Explicit `canvas W x H` directive overrides.
74
+
75
+ Mutates `diagram` in place."""
76
+ _resolve_auto_layouts(diagram)
77
+ _resolve_component_alignments(diagram)
78
+ _resolve_region_bounds(diagram)
79
+ _stagger_fanin_edges(diagram)
80
+ _stagger_trunk_lanes(diagram)
81
+ _auto_size_canvas(diagram)
82
+
83
+
84
+ def _stagger_trunk_lanes(diagram: Diagram, min_step: int = 8, max_step: int = 16) -> None:
85
+ """Sugiyama-style channel routing: when several Z-shape edges run
86
+ through the same vertical (or horizontal) corridor between two
87
+ component columns, assign each edge its own trunk-axis lane so the
88
+ long perpendicular segments stay parallel instead of stacking on
89
+ top of one another.
90
+
91
+ A "channel" is keyed by the pair of orthogonal coordinates the
92
+ edges enter/exit on — e.g. for horizontal anchors, all edges whose
93
+ `(src.right.x, dst.left.x)` collapse to the same gap share a
94
+ channel. Within each channel of N edges, lane offsets are
95
+ `(i − (N−1)/2) · step` (centred on the channel midpoint), sorted
96
+ by `(src.y, dst.y)` for a stable, geometrically sensible ordering.
97
+
98
+ Mutates each edge's `trunk_offset`; route_edge then shifts the
99
+ midpoint coord on the perpendicular axis."""
100
+ from collections import defaultdict
101
+ from .model import resolve_anchors
102
+
103
+ horiz: dict[tuple[int, int], list] = defaultdict(list)
104
+ vert: dict[tuple[int, int], list] = defaultdict(list)
105
+ for e in diagram.edges:
106
+ if e.via: # explicit routing wins
107
+ continue
108
+ src = diagram.get_node(e.src)
109
+ dst = diagram.get_node(e.dst)
110
+ if src is None or dst is None:
111
+ continue
112
+ sa, da = resolve_anchors(e, src, dst)
113
+ sp = src.anchor(sa)
114
+ dp = dst.anchor(da)
115
+ if sp[0] == dp[0] or sp[1] == dp[1]: # already axis-aligned
116
+ continue
117
+ if sa in ("left", "right"):
118
+ # Horizontal anchors → vertical trunk at midpoint x. Channel
119
+ # key snaps endpoints to the grid so near-equal x's group.
120
+ horiz[(round(sp[0] / 8) * 8, round(dp[0] / 8) * 8)].append((e, sp, dp))
121
+ else:
122
+ vert[(round(sp[1] / 8) * 8, round(dp[1] / 8) * 8)].append((e, sp, dp))
123
+
124
+ def assign(entries: list, sort_idx: int, channel_width: int) -> None:
125
+ n = len(entries)
126
+ if n <= 1:
127
+ return
128
+ # Only stagger when trunks would actually OVERLAP on the
129
+ # perpendicular axis. Two edges whose trunk-axis ranges don't
130
+ # intersect can share the channel midpoint — their trunks
131
+ # diverge from there (eg. one going up, one going down) and a
132
+ # forced lane offset just produces visible asymmetry (one
133
+ # bends early, the other late) without any overlap benefit.
134
+ def overlaps(t1, t2) -> bool:
135
+ r1 = sorted([t1[1][sort_idx], t1[2][sort_idx]])
136
+ r2 = sorted([t2[1][sort_idx], t2[2][sort_idx]])
137
+ return max(r1[0], r2[0]) < min(r1[1], r2[1])
138
+
139
+ if not any(overlaps(entries[i], entries[j])
140
+ for i in range(n) for j in range(i + 1, n)):
141
+ return
142
+
143
+ # Lane step is `max_step` (16 px) by default for clearly visible
144
+ # separation, but shrinks toward `min_step` (8 px) when the
145
+ # channel is too narrow to fit `(n+1)` lanes at the full step.
146
+ step = max(min_step, min(max_step, channel_width // (n + 1)))
147
+ entries.sort(key=lambda t: (t[1][sort_idx], t[2][sort_idx]))
148
+ mid = (n - 1) / 2
149
+ for i, (e, _sp, _dp) in enumerate(entries):
150
+ e.trunk_offset = round((i - mid) * step)
151
+
152
+ for (src_x, dst_x), entries in horiz.items():
153
+ assign(entries, sort_idx=1, channel_width=abs(dst_x - src_x))
154
+ for (src_y, dst_y), entries in vert.items():
155
+ assign(entries, sort_idx=0, channel_width=abs(dst_y - src_y))
156
+
157
+
158
+ def _stagger_fanin_edges(diagram: Diagram) -> None:
159
+ """ELK-style port distribution at both endpoints:
160
+
161
+ - **Fan-in** (N srcs → 1 dst, N ≥ 2): stagger `dst_offset` so
162
+ arrowheads land at distinct points on the destination edge.
163
+ - **Fan-out** (1 src → N dsts, N ≥ 3): stagger `src_offset` so
164
+ departing arrows leave the source edge at distinct ports.
165
+ The N ≥ 3 threshold keeps the "tree-branch" look for 2-edge
166
+ fan-out (one up, one down) — those naturally diverge and an
167
+ extra src spread just produces visible staircase steps.
168
+
169
+ Spacing is capped at 16 px per step and at the icon's cross-axis
170
+ dimension so offsets stay inside the icon. Pre-existing explicit
171
+ offsets are accumulated on top of, not replaced."""
172
+ from collections import defaultdict
173
+ from .model import resolve_anchors
174
+
175
+ fanin: dict[tuple[str, str], list[tuple]] = defaultdict(list)
176
+ fanout: dict[tuple[str, str], list[tuple]] = defaultdict(list)
177
+ for e in diagram.edges:
178
+ src = diagram.get_node(e.src)
179
+ dst = diagram.get_node(e.dst)
180
+ if src is None or dst is None:
181
+ continue
182
+ sa, da = resolve_anchors(e, src, dst)
183
+ fanin[(e.dst, da)].append((e, src, dst, sa, da))
184
+ fanout[(e.src, sa)].append((e, src, dst, sa, da))
185
+
186
+ STEP = 16
187
+
188
+ def spread(entries, anchor: str, attr: str, node_idx: int,
189
+ min_count: int) -> None:
190
+ """Stagger `attr` (src_offset/dst_offset) on edges sharing `anchor`.
191
+ Skipped unless `len(entries) >= min_count`. Sorted by the OTHER
192
+ endpoint's position on the perpendicular axis so ports come out
193
+ in a non-crossing order (a → c-d-e from top to bottom → port
194
+ from top to bottom on a.right).
195
+
196
+ Edges marked `{ shared }` opt out — they stay on the centre
197
+ port. They're still counted in `min_count` so a 3-edge fan-out
198
+ with one `shared` still triggers stagger for the other two."""
199
+ n = len(entries)
200
+ if n < min_count:
201
+ return
202
+ # For fan-out (attr == "src_offset"), drop shared-port edges.
203
+ if attr == "src_offset":
204
+ entries = [t for t in entries if not getattr(t[0], "shared_port", False)]
205
+ if len(entries) < 1:
206
+ return
207
+ n = len(entries)
208
+ node = entries[0][node_idx]
209
+ other_idx = 2 if node_idx == 1 else 1
210
+ if anchor in ("left", "right"):
211
+ cross_span = node.half[1] * 2
212
+ entries.sort(key=lambda t: t[other_idx].pos[1])
213
+ spread_total = min(cross_span - 16, STEP * (n - 1))
214
+ mid = (n - 1) / 2
215
+ for i, t in enumerate(entries):
216
+ e = t[0]
217
+ dy = round((i - mid) / max(mid, 1) * (spread_total / 2))
218
+ cur = getattr(e, attr)
219
+ setattr(e, attr, (cur[0], cur[1] + dy))
220
+ elif anchor in ("top", "bottom"):
221
+ cross_span = node.half[0] * 2
222
+ entries.sort(key=lambda t: t[other_idx].pos[0])
223
+ spread_total = min(cross_span - 16, STEP * (n - 1))
224
+ mid = (n - 1) / 2
225
+ for i, t in enumerate(entries):
226
+ e = t[0]
227
+ dx = round((i - mid) / max(mid, 1) * (spread_total / 2))
228
+ cur = getattr(e, attr)
229
+ setattr(e, attr, (cur[0] + dx, cur[1]))
230
+
231
+ for (_, da), entries in fanin.items():
232
+ spread(entries, da, "dst_offset", node_idx=2, min_count=2)
233
+ for (_, sa), entries in fanout.items():
234
+ spread(entries, sa, "src_offset", node_idx=1, min_count=3)
235
+
236
+
237
+ def _auto_size_canvas(diagram: Diagram, margin: int = 30) -> None:
238
+ """Compute canvas dimensions from resolved geometry when not specified.
239
+
240
+ Walks all components (icon + label extents), regions (rect bounds),
241
+ and edge via points / explicit label positions. Computes both min and
242
+ max extents — if the leftmost/topmost extent sits closer to the canvas
243
+ edge than `margin`, every element is shifted to give it that margin.
244
+ This lets orphan components (no region/layout) render correctly at
245
+ their default `(0, 0)` position. Only overwrites `diagram.width` /
246
+ `diagram.height` if they are 0 (sentinel)."""
247
+ if diagram.width > 0 and diagram.height > 0:
248
+ return
249
+
250
+ min_x = min_y = 10**9
251
+ max_x = max_y = -10**9
252
+
253
+ for c in diagram.components:
254
+ eff_hw = max(c.half[0], _label_half_width(c))
255
+ lh = LABEL_HEIGHT.get(c.shape, 0) if (c.name or c.subtitle) else 0
256
+ left = c.pos[0] - eff_hw
257
+ right = c.pos[0] + eff_hw
258
+ top = c.pos[1] - c.half[1]
259
+ bottom = c.pos[1] + c.half[1] + lh
260
+ if left < min_x: min_x = left
261
+ if top < min_y: min_y = top
262
+ if right > max_x: max_x = right
263
+ if bottom > max_y: max_y = bottom
264
+
265
+ for r in diagram.regions:
266
+ if r.bounds == (0, 0, 0, 0):
267
+ continue # invisible layout-only
268
+ x, y, w, h = r.bounds
269
+ if x < min_x: min_x = x
270
+ if y < min_y: min_y = y
271
+ if x + w > max_x: max_x = x + w
272
+ if y + h > max_y: max_y = y + h
273
+
274
+ for e in diagram.edges:
275
+ for vx, vy in e.via:
276
+ if vx < min_x: min_x = vx
277
+ if vy < min_y: min_y = vy
278
+ if vx > max_x: max_x = vx
279
+ if vy > max_y: max_y = vy
280
+ if e.label_pos is not None:
281
+ lx, ly = e.label_pos
282
+ if lx < min_x: min_x = lx
283
+ if ly < min_y: min_y = ly
284
+ if lx > max_x: max_x = lx
285
+ if ly > max_y: max_y = ly
286
+
287
+ if min_x > max_x: # nothing to size against
288
+ return
289
+
290
+ dx = margin - min_x if min_x < margin else 0
291
+ dy = margin - min_y if min_y < margin else 0
292
+ if dx or dy:
293
+ for c in diagram.components:
294
+ c.pos = (c.pos[0] + dx, c.pos[1] + dy)
295
+ for r in diagram.regions:
296
+ if r.bounds == (0, 0, 0, 0):
297
+ continue
298
+ x, y, w, h = r.bounds
299
+ r.bounds = (x + dx, y + dy, w, h)
300
+ for e in diagram.edges:
301
+ e.via = [(vx + dx, vy + dy) for vx, vy in e.via]
302
+ if e.label_pos is not None:
303
+ e.label_pos = (e.label_pos[0] + dx, e.label_pos[1] + dy)
304
+ max_x += dx
305
+ max_y += dy
306
+
307
+ if diagram.width == 0:
308
+ diagram.width = max_x + margin
309
+ if diagram.height == 0:
310
+ diagram.height = max_y + margin
311
+
312
+ # Enforce min 4:3 (landscape) aspect: pad width and re-center
313
+ # horizontally so a tall vertical stack doesn't look like a column.
314
+ # Wide canvases are left untouched.
315
+ if diagram.width * 3 < diagram.height * 4:
316
+ new_w = (diagram.height * 4) // 3
317
+ shift = (new_w - diagram.width) // 2
318
+ for c in diagram.components:
319
+ c.pos = (c.pos[0] + shift, c.pos[1])
320
+ for r in diagram.regions:
321
+ if r.bounds == (0, 0, 0, 0):
322
+ continue
323
+ x, y, w, h = r.bounds
324
+ r.bounds = (x + shift, y, w, h)
325
+ for e in diagram.edges:
326
+ e.via = [(vx + shift, vy) for vx, vy in e.via]
327
+ if e.label_pos is not None:
328
+ e.label_pos = (e.label_pos[0] + shift, e.label_pos[1])
329
+ diagram.width = new_w
330
+
331
+ _snap_to_grid(diagram)
332
+
333
+
334
+ def _snap_to_grid(diagram: Diagram, grid: int = 8) -> None:
335
+ """Round all coordinates to multiples of `grid` (default 8px — matches
336
+ Material Design / iOS / Figma defaults). Component centers, edge
337
+ waypoints, label positions and canvas dimensions snap directly.
338
+ Region bounds are **re-derived** from the snapped component
339
+ positions (using the region's padding) rather than snapped
340
+ independently — that prevents a region rect from drifting off-centre
341
+ relative to its content when `s(x)` rounds down but `s_up(w)` rounds
342
+ up. Shape widths (hex 70, circle 76, cube 80) are left alone so
343
+ their edges may sit a half-pixel off the grid."""
344
+ def s(v: int) -> int:
345
+ return round(v / grid) * grid
346
+
347
+ def s_up(v: int) -> int:
348
+ return ((v + grid - 1) // grid) * grid
349
+
350
+ for c in diagram.components:
351
+ c.pos = (s(c.pos[0]), s(c.pos[1]))
352
+ for e in diagram.edges:
353
+ e.via = [(s(vx), s(vy)) for vx, vy in e.via]
354
+ if e.label_pos is not None:
355
+ e.label_pos = (s(e.label_pos[0]), s(e.label_pos[1]))
356
+
357
+ by_id = {c.id: c for c in diagram.components}
358
+ for r in diagram.regions:
359
+ if r.bounds == (0, 0, 0, 0) or not r.contains:
360
+ continue
361
+ # Re-derive bounds from now-snapped positions so the rect stays
362
+ # centred on its content. Mirror `_resolve_region_bounds` (label-
363
+ # aware so unlabelled components don't reserve an empty band).
364
+ cells = [by_id[cid] for cid in r.contains if cid in by_id]
365
+ if not cells:
366
+ continue
367
+ pad_x, pad_y = r.padding
368
+ pad_b = r.padding_bottom if r.padding_bottom is not None else pad_y
369
+ eff_hws = [max(c.half[0], _label_half_width(c)) for c in cells]
370
+ xs_left = [c.pos[0] - ew for c, ew in zip(cells, eff_hws)]
371
+ xs_right = [c.pos[0] + ew for c, ew in zip(cells, eff_hws)]
372
+ ys_top = [c.pos[1] - c.half[1] for c in cells]
373
+ ys_bot = [c.pos[1] + c.half[1]
374
+ + (LABEL_HEIGHT.get(c.shape, 0) if (c.name or c.subtitle) else 0)
375
+ for c in cells]
376
+ x = min(xs_left) - pad_x
377
+ y = min(ys_top) - pad_y
378
+ w = max(xs_right) - x + pad_x
379
+ h = max(ys_bot) - y + pad_b
380
+ r.bounds = (x, y, w, h)
381
+
382
+ diagram.width = s_up(diagram.width)
383
+ diagram.height = s_up(diagram.height)
384
+
385
+
386
+ def _resolve_auto_layouts(diagram: Diagram) -> None:
387
+ """For each region with `layout` set, position every contained component
388
+ along the layout axis starting from the region's `pos` (top-left).
389
+
390
+ Regions without a `pos` are assumed to be positioned by an enclosing
391
+ `layout { … }` tree (which already laid out their children via
392
+ `apply_layout_tree` at parse time) — skip them silently."""
393
+ for r in diagram.regions:
394
+ if r.layout is None:
395
+ continue
396
+ if r.pos is None:
397
+ continue
398
+ if not r.contains:
399
+ continue
400
+
401
+ children = [diagram.get(cid) for cid in r.contains]
402
+ pad_x, pad_y = r.padding
403
+ ox, oy = r.pos
404
+ cursor_x = ox + pad_x
405
+ cursor_y = oy + pad_y
406
+
407
+ # Use EFFECTIVE half-width (max of icon and label) so adjacent
408
+ # children's labels don't overlap. `gap` then measures the clear
409
+ # distance between adjacent LABEL ends, not icon edges.
410
+ effs = [_effective_half(c) for c in children]
411
+
412
+ if r.layout == "horizontal":
413
+ # Cross-axis: vertical alignment based on the tallest child icon
414
+ max_h = max(eh for _, eh in effs)
415
+ for c, (ew, eh) in zip(children, effs):
416
+ _, ch = c.half
417
+ if r.align == "start":
418
+ cy = cursor_y + ch
419
+ elif r.align == "end":
420
+ cy = cursor_y + 2 * max_h - ch
421
+ else: # center (default)
422
+ cy = cursor_y + max_h
423
+ c.pos = (cursor_x + ew, cy)
424
+ cursor_x += ew * 2 + r.gap
425
+
426
+ else: # vertical
427
+ # Cross-axis: horizontal alignment based on the widest child
428
+ # (icon or label, whichever is wider).
429
+ max_w = max(ew for ew, _ in effs)
430
+ for c, (ew, eh) in zip(children, effs):
431
+ cw, _ = c.half
432
+ if r.align == "start":
433
+ cx = cursor_x + cw
434
+ elif r.align == "end":
435
+ cx = cursor_x + 2 * max_w - cw
436
+ else: # center
437
+ cx = cursor_x + max_w
438
+ c.pos = (cx, cursor_y + eh)
439
+ cursor_y += eh * 2 + r.gap
440
+
441
+
442
+ def _resolve_component_alignments(diagram: Diagram) -> None:
443
+ resolved: set[str] = set()
444
+
445
+ def resolve(cid: str, path: tuple[str, ...] = ()) -> None:
446
+ if cid in resolved:
447
+ return
448
+ if cid in path:
449
+ chain = " → ".join((*path, cid))
450
+ raise ValueError(f"alignment cycle: {chain}")
451
+
452
+ comp = diagram.get(cid)
453
+ if comp.parent is None:
454
+ resolved.add(cid)
455
+ return
456
+
457
+ # Resolve the parent first (depth-first).
458
+ resolve(comp.parent, path + (cid,))
459
+ parent = diagram.get(comp.parent)
460
+
461
+ if comp.align is None:
462
+ raise ValueError(
463
+ f"component {cid!r} has parent={comp.parent!r} but no align side"
464
+ )
465
+
466
+ comp.pos = _align_to(parent, comp, comp.align, comp.align_gap, comp.align_offset)
467
+ resolved.add(cid)
468
+
469
+ for c in diagram.components:
470
+ resolve(c.id)
471
+
472
+
473
+ def _resolve_region_bounds(diagram: Diagram) -> None:
474
+ """For each region with `contains`, compute its bounding box as the
475
+ envelope of all listed components (icon + label area), padded.
476
+
477
+ `padding_bottom` (when set on the region) overrides the default
478
+ bottom padding (`padding[1]`); useful to compensate visually for the
479
+ region label sitting ABOVE the rect."""
480
+ for r in diagram.regions:
481
+ if not r.contains:
482
+ continue
483
+ cells = [diagram.get(cid) for cid in r.contains]
484
+ pad_x, pad_y = r.padding
485
+ pad_b = r.padding_bottom if r.padding_bottom is not None else pad_y
486
+
487
+ # Use effective half-width so the rect wraps labels too, not just
488
+ # icons. Without this, long labels (e.g., "Academic Papers") would
489
+ # extend past the region rect.
490
+ eff_hws = [max(c.half[0], _label_half_width(c)) for c in cells]
491
+ xs_left = [c.pos[0] - ew for c, ew in zip(cells, eff_hws)]
492
+ xs_right = [c.pos[0] + ew for c, ew in zip(cells, eff_hws)]
493
+ ys_top = [c.pos[1] - c.half[1] for c in cells]
494
+ # Bottom extent INCLUDES label area when the component has one;
495
+ # an unlabelled component shouldn't reserve an empty band.
496
+ ys_bot = [c.pos[1] + c.half[1]
497
+ + (LABEL_HEIGHT.get(c.shape, 0) if (c.name or c.subtitle) else 0)
498
+ for c in cells]
499
+
500
+ x = min(xs_left) - pad_x
501
+ y = min(ys_top) - pad_y
502
+ w = max(xs_right) - x + pad_x
503
+ h = max(ys_bot) - y + pad_b
504
+ r.bounds = (x, y, w, h)
505
+
506
+
507
+ def _align_to(parent: Component, child: Component, side: str,
508
+ gap: int, offset: tuple[int, int]) -> tuple[int, int]:
509
+ """Return the (cx, cy) for `child` aligned on `side` of `parent`."""
510
+ px, py = parent.pos
511
+ p_hw, p_hh = parent.half
512
+ p_label = LABEL_HEIGHT.get(parent.shape, 0)
513
+ c_hw, c_hh = child.half
514
+ ox, oy = offset
515
+
516
+ match side:
517
+ case "right":
518
+ # parent's right edge + gap + child half-width
519
+ cx = px + p_hw + gap + c_hw
520
+ cy = py
521
+ case "left":
522
+ cx = px - p_hw - gap - c_hw
523
+ cy = py
524
+ case "bottom":
525
+ # parent's bottom INCLUDING label area; child sits below that
526
+ cx = px
527
+ cy = py + p_hh + p_label + gap + c_hh
528
+ case "top":
529
+ cx = px
530
+ cy = py - p_hh - gap - c_hh
531
+ case _:
532
+ raise ValueError(f"unknown align side: {side!r}")
533
+
534
+ return (cx + ox, cy + oy)
kymo/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Entry point: `kymo <path> [--animate] [--figma] [--excalidraw]`
2
+
3
+ Argument is a path to a `.diagram` (DSL) or `.py` (Python form) source.
4
+ Output is a SVG (or Figma Plugin JS / Excalidraw scene) written next to
5
+ the input file:
6
+
7
+ kymo samples/aws_1.diagram → samples/aws_1.svg
8
+ kymo samples/aws_1.diagram --animate → samples/aws_1-animated.svg
9
+ kymo samples/aws_1.diagram --figma → samples/aws_1.figma.js
10
+ kymo samples/aws_1.diagram --excalidraw → samples/aws_1.excalidraw
11
+
12
+ `--animate` emits a `-animated.svg` companion with flowing edge dashes
13
+ (CSS `stroke-dashoffset` animation). Static SVG (no JS); animation runs
14
+ in any modern browser.
15
+
16
+ `--figma` emits Figma Plugin API JavaScript. Pass it as the `code`
17
+ argument to the `use_figma` MCP tool, OR paste it into Figma's plugin
18
+ dev console (Plugins menu → Development → Open console).
19
+
20
+ `--excalidraw` emits an Excalidraw scene v2 JSON; open it directly in
21
+ excalidraw.com (Menu → Open) or the Excalidraw desktop app.
22
+ """
23
+ import sys
24
+ from importlib import import_module
25
+ from pathlib import Path
26
+
27
+ from .alignment import resolve_alignments
28
+ from .dsl import parse as parse_dsl
29
+ from .layout import layout
30
+ from .to_excalidraw import render as render_excalidraw
31
+ from .to_figma import render as render_figma
32
+ from .to_svg import render
33
+
34
+
35
+ def load(source: Path) -> tuple[object, object | None, object | None]:
36
+ """Load a diagram source. Returns (DIAGRAM, LAYOUT, EXTERNAL_LAYOUT)."""
37
+ if source.suffix == ".diagram":
38
+ return parse_dsl(source.read_text(encoding="utf-8"))
39
+ # For .py sources, the file's parent directory must be on sys.path so the
40
+ # module can import its siblings (e.g. samples/data.py importing model).
41
+ sys.path.insert(0, str(source.parent))
42
+ mod = import_module(source.stem)
43
+ return mod.DIAGRAM, getattr(mod, "LAYOUT", None), getattr(mod, "EXTERNAL_LAYOUT", None)
44
+
45
+
46
+ def main() -> None:
47
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
48
+ flags = {a for a in sys.argv[1:] if a.startswith("--")}
49
+
50
+ if not args or "--help" in flags or "-h" in flags:
51
+ print(__doc__)
52
+ sys.exit(0 if args else 1)
53
+
54
+ src = Path(args[0])
55
+ if not src.exists():
56
+ print(f"not found: {src}")
57
+ sys.exit(1)
58
+ if src.suffix not in (".diagram", ".py"):
59
+ print(f"unsupported source: {src} (expected .diagram or .py)")
60
+ sys.exit(1)
61
+
62
+ animate = "--animate" in flags
63
+ figma = "--figma" in flags
64
+ excalidraw = "--excalidraw" in flags
65
+
66
+ diagram, layout_spec, external_layout = load(src)
67
+ if layout_spec:
68
+ layout(diagram, layout_spec, external_layout)
69
+ resolve_alignments(diagram)
70
+
71
+ if excalidraw:
72
+ payload = render_excalidraw(diagram)
73
+ out = src.with_suffix(".excalidraw")
74
+ out.write_text(payload, encoding="utf-8")
75
+ rel = out.relative_to(Path.cwd()) if out.is_relative_to(Path.cwd()) else out
76
+ print(f"✓ wrote {rel} (excalidraw) ({diagram.width}×{diagram.height}, {len(payload):,} bytes)")
77
+ return
78
+
79
+ if figma:
80
+ payload = render_figma(diagram)
81
+ out = src.with_suffix(".figma.js")
82
+ out.write_text(payload, encoding="utf-8")
83
+ rel = out.relative_to(Path.cwd()) if out.is_relative_to(Path.cwd()) else out
84
+ print(f"✓ wrote {rel} (figma plugin js) ({diagram.width}×{diagram.height}, {len(payload):,} bytes)")
85
+ return
86
+
87
+ svg = render(diagram, animate=animate)
88
+
89
+ out = src.with_suffix(".svg")
90
+ if animate:
91
+ out = out.with_stem(out.stem + "-animated")
92
+ out.write_text(svg, encoding="utf-8")
93
+
94
+ rel = out.relative_to(Path.cwd()) if out.is_relative_to(Path.cwd()) else out
95
+ suffix = " (animated)" if animate else ""
96
+ print(f"✓ wrote {rel}{suffix} ({diagram.width}×{diagram.height}, {len(svg):,} bytes)")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()