codefig 0.17.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.
codefig/figure.py ADDED
@@ -0,0 +1,1923 @@
1
+ """Figure and Panel — top-level composition API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import os as _os
7
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
8
+
9
+ from .constraints import (
10
+ ConstraintScope,
11
+ apply_size_constraints,
12
+ invalidate_tree,
13
+ solve_constraints,
14
+ translate_tree,
15
+ walk,
16
+ )
17
+ from .node import BoxGeom, Col, Grid, Node, Row, Stack
18
+ from .primitives import Arrow, Box, Ellipsis, Group, LoopArrow, Matrix, Spacer, Text
19
+ from .render_svg import draw_rect, draw_text, svg_footer, svg_header
20
+ from .styles import (
21
+ Theme,
22
+ ThemeLike,
23
+ current_theme,
24
+ get_theme,
25
+ use_theme,
26
+ apply_edge_overrides,
27
+ apply_spacing_overrides,
28
+ normalize_justify,
29
+ normalize_pad,
30
+ )
31
+ from .textutil import measure_text
32
+
33
+
34
+ class LayoutError(ValueError):
35
+ """A physical layout cannot preserve the requested minimum font size."""
36
+
37
+
38
+ class Panel(ConstraintScope):
39
+ """One lettered panel inside a Figure. Use as context manager or += content.
40
+
41
+ ``panel.constrain(...)`` adds relational constraints (see codefig.constraints);
42
+ constraints registered on the Figure or on any Diagram inside this panel are
43
+ solved here too, after flex layout and before edge routing.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ letter: str,
49
+ title: str = "",
50
+ *,
51
+ theme: Optional[Theme] = None,
52
+ caption: Optional[str] = "bottom", # bottom | top | none
53
+ frame: Union[bool, str] = True,
54
+ fit: Optional[bool] = None,
55
+ fit_mode: Optional[str] = None, # scale|none (reflow → falls back to scale; documented)
56
+ max_scale: Optional[float] = None,
57
+ align: str = "center", # horizontal placement of fitted content
58
+ justify: Optional[str] = None, # vertical placement: start|center|end (None → auto)
59
+ pack: str = "center", # start|center|end — leftover space when not scaling
60
+ pad: Optional[object] = None,
61
+ padding: Optional[object] = None,
62
+ overlap: Optional[str] = None, # resolve|warn|error|ignore (None → "resolve")
63
+ align_edges: Optional[bool] = None, # v0.11.0 node-nudging edge alignment
64
+ subtitle: Optional[str] = None, # v0.12.0 second, smaller caption line
65
+ frame_color: Optional[str] = None, # v0.13.0
66
+ letters: bool = True, # v0.14.0: False drops the "(a)" tag, keeps the title
67
+ ) -> None:
68
+ from .overlaps import check_policy
69
+
70
+ self.frame_color = frame_color
71
+ self.letters = bool(letters)
72
+ self.subtitle = subtitle
73
+
74
+ self.overlap = check_policy(overlap) or "resolve"
75
+ self.align_edges = True if align_edges is None else bool(align_edges)
76
+ self._auto_cons: list = []
77
+ self.letter = letter
78
+ self.title = title
79
+ self.theme = theme or current_theme()
80
+ self.content: Optional[Node] = None
81
+ self.geom = BoxGeom()
82
+ # caption: "bottom" (paper style, centered below content) or "top"
83
+ cap = "none" if caption in (None, False) else str(caption).lower()
84
+ if cap not in ("bottom", "top", "none"):
85
+ raise ValueError(f"caption must be 'bottom', 'top' or 'none', got {caption!r}")
86
+ self.caption = cap
87
+ # v0.13.0: frame="solid" | "dashed" | "dotted" (True = "solid")
88
+ if isinstance(frame, str):
89
+ fs_ = frame.lower()
90
+ if fs_ not in ("solid", "dashed", "dotted", "none"):
91
+ raise ValueError("panel frame must be True/False or 'solid'|'dashed'|'dotted'|'none'")
92
+ self.frame_style = None if fs_ == "none" else fs_
93
+ else:
94
+ self.frame_style = "solid" if frame else None
95
+ self.frame = self.frame_style is not None
96
+ # fit_mode: "scale" uniform zoom (default when fit=True); "none" content-size + pack;
97
+ # "reflow" is not implemented — falls back to scale (see README).
98
+ mode = fit_mode
99
+ if mode is None:
100
+ if fit is None: # physical themes keep content at print size (no zoom)
101
+ fit = not self.theme.physical
102
+ mode = "scale" if fit else "none"
103
+ mode = str(mode).lower()
104
+ if mode == "reflow":
105
+ mode = "scale" # honest fallback
106
+ self.fit_mode = mode
107
+ self.fit = mode == "scale" # BC
108
+ self.max_scale = float(self.theme.max_panel_scale if max_scale is None else max_scale)
109
+ self._force_scale: Optional[float] = None # Figure(panel_scale="uniform")
110
+ self.align = align # start|center|end
111
+ # v0.7.0: a top caption keeps its content right under it (leftover height goes
112
+ # below, where it separates the panel from the next row); otherwise centre.
113
+ if justify is None:
114
+ justify = "start" if (cap == "top" and self.theme.physical) else "center"
115
+ self.justify = normalize_justify(justify) # start|center|end
116
+ self.pack = pack if pack in ("start", "center", "end") else "center"
117
+ raw = padding if padding is not None else pad
118
+ self.padding = (
119
+ normalize_pad(raw) if raw is not None else normalize_pad(self.theme.panel_pad)
120
+ )
121
+ self._scale = 1.0
122
+ self._offset = (0.0, 0.0)
123
+ self._fig_constraints: list = [] # routed from Figure.constrain
124
+ self._overlays: list = []
125
+ self._bbox = (0.0, 0.0, 0.0, 0.0)
126
+
127
+ # ---- constraints ---------------------------------------------------------
128
+ def all_constraints(self) -> list:
129
+ """Panel + Figure-routed + Diagram-scoped constraints for this panel."""
130
+ out = list(self._cf_constraints()) + list(self._fig_constraints)
131
+ if self.content is not None:
132
+ for n in walk(self.content):
133
+ lst = n.__dict__.get("_constraints") if hasattr(n, "__dict__") else None
134
+ if lst:
135
+ out.extend(lst)
136
+ return out
137
+
138
+ def _flex_and_solve(self, w: float, h: float, *, warn: bool = True, cons: Optional[list] = None):
139
+ """Flex-layout content at the local origin, then solve constraints.
140
+
141
+ Returns the content bbox (x, y, w, h) in local (pre-scale) units.
142
+ """
143
+ if cons is None:
144
+ cons = self.all_constraints() + list(self._auto_cons)
145
+ self.content.layout(0.0, 0.0, w, h)
146
+ res = solve_constraints(self.content, cons, warn=warn)
147
+ self._overlays = res.overlays
148
+ self._last_grow = res.growth
149
+ return res.bbox
150
+
151
+ def _solve_tolerant(self, w: float, h: float, user: list, auto: list, warn: bool):
152
+ """Solve user + auto constraints; auto no_overlap constraints that clash with
153
+ required ones are downgraded to strong instead of raising."""
154
+ from .constraints import ConstraintConflictError
155
+
156
+ try:
157
+ return self._flex_and_solve(w, h, warn=warn, cons=user + auto)
158
+ except ConstraintConflictError:
159
+ for c in auto:
160
+ if c.kind != "align_edge":
161
+ c.strength = "strong"
162
+ return self._flex_and_solve(w, h, warn=warn, cons=user + auto)
163
+
164
+ def _resolve_overlaps(self, w: float, h: float,
165
+ room: Optional[Tuple[float, float]] = None) -> list:
166
+ """overlap='resolve': iteratively add no_overlap constraints (≤ 6 rounds)."""
167
+ from .overlaps import find_overlaps, resolution_for
168
+
169
+ self._auto_cons = []
170
+ if self.content is None:
171
+ return []
172
+ user = self.all_constraints()
173
+ auto: list = []
174
+ seen: set = set()
175
+ for _round in range(6 if self.overlap == "resolve" else 0):
176
+ if user or auto:
177
+ self._solve_tolerant(w, h, user, auto, warn=False)
178
+ else:
179
+ self.content.layout(0.0, 0.0, w, h)
180
+ self._overlays = []
181
+ found = find_overlaps(self.content, self._overlays, user)
182
+ new = []
183
+ for o in found:
184
+ key = frozenset((id(o.a), id(o.b)))
185
+ if key in seen:
186
+ continue
187
+ seen.add(key)
188
+ new.append(resolution_for(o, self._overlays))
189
+ if not new:
190
+ break
191
+ auto.extend(new)
192
+ auto = self._push_regions(w, h, user, auto)
193
+ auto = self._align_edges(w, h, user, auto, room)
194
+ self._auto_cons = auto
195
+ return auto
196
+
197
+ def _align_edges(self, w: float, h: float, user: list, auto: list,
198
+ room: Optional[Tuple[float, float]] = None) -> list:
199
+ """v0.11.0: nudge connected nodes so facing ports line up (see codefig.edge_align).
200
+
201
+ ``room`` is the panel's inner size during the final layout: containers may then
202
+ grow into space the panel has anyway (never during measuring, so a nudge never
203
+ makes a panel ask for more space)."""
204
+ from .constraints import invalidate_tree
205
+ from .edge_align import (breaks_flush, broken_sibling_pairs, candidates, flush_pairs,
206
+ flush_repairs, snapshot, visible_jogs)
207
+ from .overlaps import find_overlaps
208
+
209
+ if not self.align_edges or self.content is None:
210
+ return auto
211
+ key = (round(w, 1), round(h, 1), len(auto), len(user),
212
+ None if room is None else (round(room[0], 1), round(room[1], 1)))
213
+ cache = self.__dict__.setdefault("_align_cache", {})
214
+ if key in cache:
215
+ return auto + cache[key]
216
+
217
+ def solve(cons: list) -> Tuple[float, float, float, float]:
218
+ invalidate_tree(self.content)
219
+ self._last_grow = {}
220
+ if user or cons:
221
+ return self._solve_tolerant(w, h, user, cons, warn=False)
222
+ self.content.layout(0.0, 0.0, w, h)
223
+ self._overlays = []
224
+ g = self.content.geom
225
+ return (g.x, g.y, g.w, g.h)
226
+
227
+ bbox0 = solve(auto)
228
+ cands = candidates(self.content)
229
+ kept: list = []
230
+ if cands:
231
+ th = self.theme
232
+ max_shift = th.L(float(getattr(th, "align_max_shift", 24.0)))
233
+ if room is None: # measuring: a small, bounded growth budget
234
+ grow_max = th.L(float(getattr(th, "align_max_grow", 12.0)))
235
+ lim_w, lim_h = bbox0[2] + grow_max, bbox0[3] + grow_max
236
+ else: # final layout: only the space the panel already has
237
+ lim_w, lim_h = max(bbox0[2], room[0]), max(bbox0[3], room[1])
238
+ snap0 = snapshot(self.content)
239
+ flush0 = flush_pairs(self.content, snap0)
240
+ # v0.12.0 (A15): user constraints win — a nudge may not worsen any user
241
+ # relation, and nodes the user tied with an equality (same_x / same_y /
242
+ # align / place …) keep their position on that axis.
243
+ def _ures() -> List[float]:
244
+ out = []
245
+ for uc in user:
246
+ try:
247
+ out.append(max((r.residual() for r in uc.rels), default=0.0))
248
+ except Exception: # pragma: no cover - unresolvable custom anchor
249
+ out.append(0.0)
250
+ return out
251
+
252
+ ures0 = _ures()
253
+ pins: Dict[int, set] = {}
254
+ partners: Dict[Tuple[int, str], set] = {} # v0.13.0: nodes tied together
255
+ for uc in user:
256
+ for r in uc.rels:
257
+ if getattr(r, "op", "==") != "==":
258
+ continue
259
+ ids = [(id(a.node), a.axis) for a in r.anchors()
260
+ if getattr(a, "node", None) is not None]
261
+ for nid, ax in ids:
262
+ pins.setdefault(nid, set()).add(ax)
263
+ partners.setdefault((nid, ax), set()).update(
264
+ m for m, ax2 in ids if ax2 == ax and m != nid)
265
+ jogs = visible_jogs(self.content)
266
+ ov0 = len(find_overlaps(self.content, self._overlays, user))
267
+
268
+ dirty = False
269
+ dbg = _os.environ.get("CF_ALIGN_DEBUG")
270
+ for _prio, _e, c in cands:
271
+ if dirty:
272
+ solve(auto + kept)
273
+ dirty = False
274
+ if c.rels[0].residual() <= 0.05:
275
+ continue # already straight after earlier nudges
276
+ def check(extra: list) -> str:
277
+ bb = solve(auto + kept + extra)
278
+ if c.rels[0].residual() > 0.05:
279
+ return "unsat %.2f" % c.rels[0].residual()
280
+ if bb[2] > lim_w + 0.05 or bb[3] > lim_h + 0.05:
281
+ return "bbox"
282
+ if any(r1 > r0 + 0.05 for r0, r1 in zip(ures0, _ures())):
283
+ return "user"
284
+ grown = {k for k, g in (self._last_grow or {}).items()
285
+ if any(abs(v) > 0.05 for v in g)}
286
+ snap = snapshot(self.content)
287
+ # v0.13.0: a uniform shift of the whole content (re-centring in a
288
+ # roomier panel) moves nothing relative to anything else
289
+ tx, ty = bb[0] - bbox0[0], bb[1] - bbox0[1]
290
+
291
+ def _moved(nid: int, ax: str) -> float:
292
+ i = 0 if ax == "x" else 1
293
+ return snap[nid][i] - snap0.get(nid, snap[nid])[i] if nid in snap else 0.0
294
+
295
+ def _pin_broken(nid: int, ax: str) -> bool:
296
+ """A tied node may move only together with everything it is tied to
297
+ (the user relation keeps its shape); alone it stays put."""
298
+ d = _moved(nid, ax)
299
+ t = tx if ax == "x" else ty
300
+ if min(abs(d), abs(d - t)) <= 0.05:
301
+ return False
302
+ ps = partners.get((nid, ax), set())
303
+ return not ps or any(abs(_moved(m, ax) - d) > 0.05 for m in ps)
304
+
305
+ for nid, (x, y, gw, gh) in snap.items():
306
+ x0, y0, w0, h0 = snap0.get(nid, (x, y, gw, gh))
307
+ if (abs(gw - w0) > 0.05 or abs(gh - h0) > 0.05) and nid not in grown:
308
+ return "size"
309
+ if abs(x - x0) > max_shift or abs(y - y0) > max_shift:
310
+ return "shift %.1f" % max(abs(x - x0), abs(y - y0))
311
+ pa = pins.get(nid)
312
+ if pa and any(_pin_broken(nid, ax) for ax in pa):
313
+ return "pinned"
314
+ if breaks_flush(flush0, snap, snap0):
315
+ return "flush"
316
+ return ""
317
+
318
+ extra = [c]
319
+ dirty = True
320
+ why = check(extra)
321
+ # v0.11.0: a nudge that only breaks sibling flush lines may take those
322
+ # siblings along (two rounds, so short chains follow too)
323
+ for _round in range(2):
324
+ if why != "flush":
325
+ break
326
+ broken = broken_sibling_pairs(flush0, snapshot(self.content))
327
+ if not broken:
328
+ break
329
+ extra = extra + flush_repairs(self.content, broken)
330
+ why = check(extra)
331
+ if not why:
332
+ j = visible_jogs(self.content)
333
+ if j >= jogs:
334
+ why = "jogs %d>=%d" % (j, jogs)
335
+ elif len(find_overlaps(self.content, self._overlays, user)) > ov0:
336
+ why = "overlap"
337
+ if dbg:
338
+ print("ALIGN", key[:2], c.desc, why or "OK")
339
+ if not why and dbg == "2":
340
+ from .constraints import node_name as _nn, walk as _walk
341
+ snap = snapshot(self.content)
342
+ for n in _walk(self.content):
343
+ x, y, gw, gh = snap[id(n)]
344
+ x0, y0, w0, h0 = snap0[id(n)]
345
+ if max(abs(x - x0), abs(y - y0), abs(gw - w0), abs(gh - h0)) > 0.05:
346
+ print(" moved", _nn(n), round(x - x0, 1), round(y - y0, 1),
347
+ round(gw - w0, 1), round(gh - h0, 1))
348
+ if not why:
349
+ kept.extend(extra)
350
+ jogs = j
351
+ dirty = False
352
+ solve(auto + kept)
353
+ cache[key] = kept
354
+ return auto + kept
355
+
356
+ def _push_regions(self, w: float, h: float, user: list, auto: list) -> list:
357
+ """Move non-member boxes out of Diagram regions (``Region(exclude=)``).
358
+
359
+ v0.9.0: for every intruder two kinds of candidates are scored —
360
+
361
+ * *reorder*: move the intruder's flex unit (its ancestor that is a direct
362
+ child of the lowest container holding the members) just before / after the
363
+ members' span, so it leaves the region without changing the container shape;
364
+ * *push*: the four sides as strong no_overlap constraints against all members
365
+ (the v0.8.0 greedy push).
366
+
367
+ A candidate is valid when the intruder clears the region and the total
368
+ intrusion count drops; the cheapest valid one wins by
369
+ :meth:`_layout_cost` (intrusions ≫ edge-box hits ≫ crossings ≫ edge length ≫
370
+ footprint). Nothing valid → the carved outline handles it at draw time.
371
+ """
372
+ from .edges import Diagram
373
+ from .regions import nest_pads, push_constraints, push_sides, region_intruders
374
+
375
+ regs = [(d, r) for d in walk(self.content) if isinstance(d, Diagram)
376
+ for r in d.regions if r.exclude in ("auto", "push")]
377
+ if not regs:
378
+ return auto
379
+ diagrams = []
380
+ for d, _r in regs:
381
+ if all(d is not x for x in diagrams):
382
+ diagrams.append(d)
383
+
384
+ def solve(cons: list) -> None:
385
+ invalidate_tree(self.content)
386
+ if user or cons:
387
+ self._solve_tolerant(w, h, user, cons, warn=False)
388
+ else:
389
+ self.content.layout(0.0, 0.0, w, h)
390
+ self._overlays = []
391
+
392
+ def intrusions() -> int:
393
+ for d, _r in regs:
394
+ nest_pads(d.regions)
395
+ return sum(len(region_intruders(d, r)) for d, r in regs)
396
+
397
+ solve(auto)
398
+ tried: set = set()
399
+ moves = self.__dict__.setdefault("_region_moves", [])
400
+ for _round in range(8):
401
+ progress = False
402
+ before = intrusions()
403
+ if not before:
404
+ break
405
+ for d, r in regs:
406
+ for intr in region_intruders(d, r):
407
+ key = (id(r), id(intr))
408
+ if key in tried:
409
+ continue
410
+ tried.add(key)
411
+ margin = r._pad() + r.theme.L(3.0)
412
+ cands: list = ([("order", mv) for mv in _reorder_moves(self.content, r, intr)]
413
+ if r.exclude == "auto" else [])
414
+ cands += [("push", push_constraints(r, intr, side, margin), side)
415
+ for _disp, side in push_sides(r, intr)]
416
+ best = None
417
+ for cand in cands:
418
+ if cand[0] == "order":
419
+ cand[1].apply()
420
+ solve(auto)
421
+ else:
422
+ solve(auto + cand[1])
423
+ g = intr.geom
424
+ n_in = intrusions()
425
+ if not r.intrudes((g.x, g.y, g.w, g.h)) and n_in < before:
426
+ cost = self._layout_cost(diagrams, n_in)
427
+ if best is None or cost < best[0]:
428
+ best = (cost, cand)
429
+ if cand[0] == "order":
430
+ cand[1].undo()
431
+ if best is not None:
432
+ cand = best[1]
433
+ if cand[0] == "order":
434
+ cand[1].apply()
435
+ rec = ("reorder", intr, cand[1].where)
436
+ else:
437
+ auto = auto + cand[1]
438
+ rec = ("push", intr, cand[2])
439
+ # layout runs several passes: record each move once
440
+ if not any(m[0] == rec[0] and m[1] is rec[1] and m[2] == rec[2]
441
+ for m in moves):
442
+ moves.append(rec)
443
+ progress = True
444
+ solve(auto)
445
+ if progress:
446
+ break
447
+ if progress:
448
+ break
449
+ if not progress:
450
+ break
451
+ return auto
452
+
453
+ def _layout_cost(self, diagrams: list, n_intrusions: int) -> Tuple[float, ...]:
454
+ """Lexicographic cost of the current layout (see _push_regions)."""
455
+ from .regions import diagram_boxes
456
+ from .routing import count_crossings
457
+
458
+ length = 0.0
459
+ cross = 0
460
+ hits = 0
461
+ x0 = y0 = float("inf")
462
+ x1 = y1 = float("-inf")
463
+ for d in diagrams:
464
+ try:
465
+ routed = d._routed()[0]
466
+ except Exception: # pragma: no cover - routing failure: ignore edges
467
+ routed = []
468
+ paths = [pts for _e, pts in routed]
469
+ boxes, _parent = diagram_boxes(d)
470
+ leaves = [(n, rc) for n, rc in boxes if not list(n.children())]
471
+ for e, pts in routed:
472
+ for k in range(len(pts) - 1):
473
+ (ax, ay), (bx, by) = pts[k], pts[k + 1]
474
+ length += abs(bx - ax) + abs(by - ay)
475
+ for n, (rx, ry, rw, rh) in leaves:
476
+ if n is getattr(e.src, "node", e.src) or n is getattr(e.dst, "node", e.dst):
477
+ continue
478
+ if _seg_hits_rect(ax, ay, bx, by, rx, ry, rw, rh):
479
+ hits += 1
480
+ cross += count_crossings(paths)
481
+ for _n, (rx, ry, rw, rh) in boxes:
482
+ x0, y0 = min(x0, rx), min(y0, ry)
483
+ x1, y1 = max(x1, rx + rw), max(y1, ry + rh)
484
+ foot = (x1 - x0) + (y1 - y0) if x1 > x0 else 0.0
485
+ # edge length and footprint are compared in units of ~4 pt so tiny solver
486
+ # jitter does not decide between otherwise equal candidates
487
+ return (n_intrusions, hits, cross, round((length + foot) / 4.0))
488
+
489
+ def check_overlaps(self) -> list:
490
+ """Overlaps among visible nodes in the laid-out panel (see codefig.overlaps)."""
491
+ from .overlaps import find_overlaps
492
+
493
+ if self.content is None:
494
+ return []
495
+ from .edges import Diagram
496
+ from .overlaps import find_edge_issues
497
+
498
+ cons = self.all_constraints()
499
+ out = find_overlaps(self.content, self._overlays, cons)
500
+ self._publish_overlay_obstacles()
501
+ for n in walk(self.content):
502
+ if isinstance(n, Diagram):
503
+ out.extend(find_edge_issues(n, self._overlays, cons))
504
+ for o in out:
505
+ o.panel = self.letter
506
+ return out
507
+
508
+ def _publish_overlay_obstacles(self) -> None:
509
+ """Hand place() annotations to every Diagram so its edges route around them."""
510
+ from .edges import Diagram
511
+
512
+ obs = [o for o in self._overlays if getattr(o, "_cf_obstacle", True)]
513
+ for n in walk(self.content):
514
+ if isinstance(n, Diagram):
515
+ n._cf_overlay_obstacles = obs
516
+ n._cf_overlays_all = list(self._overlays)
517
+ n._placed_labels = None
518
+
519
+ def _after_layout(self) -> None:
520
+ from .overlaps import emit
521
+
522
+ self._publish_overlay_obstacles()
523
+ if self.overlap == "ignore":
524
+ return
525
+ found = self.check_overlaps()
526
+ emit(found, "warn" if self.overlap == "resolve" else self.overlap)
527
+
528
+ def _prepare_sizes(self) -> None:
529
+ if self.content is not None:
530
+ from .legend import Legend
531
+
532
+ corners = self.__dict__.setdefault("_corner_legends", [])
533
+ for parent in list(walk(self.content)):
534
+ if hasattr(parent, "_kids"):
535
+ for child in list(parent._kids):
536
+ if isinstance(child, Legend) and child.corner is not None and child.overlay:
537
+ parent._kids.remove(child)
538
+ corners.append(child)
539
+ parent.invalidate()
540
+ for legend in corners:
541
+ legend.collect(self.content)
542
+ cons = self.all_constraints()
543
+ trees = [self.content] + [c.overlay for c in cons if getattr(c, "overlay", None) is not None]
544
+ for t in trees:
545
+ for n in walk(t):
546
+ if isinstance(n, Legend):
547
+ if isinstance(n.source, str) and n.source == "figure":
548
+ fig = self.__dict__.get("_fig")
549
+ roots = ([q.content for q in (fig._panels[k] for k in fig._order)
550
+ if q.content is not None] if fig is not None
551
+ else [self.content])
552
+ n.collect(roots)
553
+ else:
554
+ n.collect(self.content)
555
+ apply_size_constraints(self.content, cons)
556
+
557
+ def __enter__(self) -> "Panel":
558
+ return self
559
+
560
+ def __exit__(self, *args: object) -> None:
561
+ return None
562
+
563
+ def __iadd__(self, node: Node) -> "Panel":
564
+ if self.content is None:
565
+ self.content = node
566
+ elif isinstance(self.content, Stack):
567
+ self.content.add(node)
568
+ else:
569
+ self.content = Stack([self.content, node], gap=self.theme.gap, align="center")
570
+ return self
571
+
572
+ def set(self, node: Node) -> "Panel":
573
+ self.content = node
574
+ return self
575
+
576
+ def _sub_fs(self) -> float:
577
+ return self.theme.panel_title_size * 0.85
578
+
579
+ def _sub_lines(self) -> List[str]:
580
+ """Subtitle lines: explicit ``\n`` breaks, plus (v0.13.0) automatic wrapping
581
+ to the panel width computed in :meth:`measure_content`."""
582
+ if not self.subtitle:
583
+ return []
584
+ cached = self.__dict__.get("_sub_cache")
585
+ if cached is not None and cached[0] == self.subtitle:
586
+ return list(cached[1])
587
+ return str(self.subtitle).split("\n")
588
+
589
+ def _wrap_sub(self, width: float) -> List[str]:
590
+ fs = self._sub_fs()
591
+ out: List[str] = []
592
+ for para in str(self.subtitle).split("\n"):
593
+ if measure_text(para, fs)[0] <= width + 0.5 or " " not in para:
594
+ out.append(para)
595
+ continue
596
+ cur = ""
597
+ for word in para.split(" "):
598
+ cand = (cur + " " + word) if cur else word
599
+ if cur and measure_text(cand, fs)[0] > width + 0.5:
600
+ out.append(cur)
601
+ cur = word
602
+ else:
603
+ cur = cand
604
+ if cur:
605
+ out.append(cur)
606
+ self.__dict__["_sub_cache"] = (self.subtitle, list(out))
607
+ return out
608
+
609
+ def _title_lines(self) -> List[str]:
610
+ """v0.13.0: a ``\n`` in a panel title gives a multi-line caption."""
611
+ return self.caption_text().split("\n")
612
+
613
+ def _sub_h(self) -> float:
614
+ """Height of the optional subtitle line(s) under the caption (v0.12.0)."""
615
+ if not self.subtitle or self.caption == "none":
616
+ return 0.0
617
+ return self._sub_fs() * 1.25 * len(self._sub_lines())
618
+
619
+ def _caption_h(self, k: float) -> float:
620
+ if self._no_caption():
621
+ return 0.0
622
+ th = self.theme
623
+ if th.physical: # one exact value for measure and layout
624
+ n = len(self._title_lines())
625
+ return th.panel_title_size * 1.2 * n + th.title_gap + self._sub_h()
626
+ return th.panel_title_size * k + self._sub_h()
627
+
628
+ def measure_content(self) -> Tuple[float, float]:
629
+ th = self.theme
630
+ if self.content is None:
631
+ return 120.0, 80.0 + self._caption_h(1.6)
632
+ self._prepare_sizes()
633
+ cw, ch = self.content.measure()
634
+ auto = self._resolve_overlaps(cw, ch)
635
+ user = self.all_constraints()
636
+ if user or auto:
637
+ _bx, _by, cw, ch = self._solve_tolerant(cw, ch, user, auto, warn=False)
638
+ # letter + title row needs space on the left/top; caption must fit too
639
+ label = self.caption_text()
640
+ if th.physical:
641
+ pt, pr, pb, pl = self.padding
642
+ lw = 0.0 if self._no_caption() else max(
643
+ measure_text(t, th.panel_title_size, weight="bold")[0] for t in self._title_lines())
644
+ if self.subtitle and self.caption != "none":
645
+ # v0.13.0: the subtitle wraps to the panel width instead of widening it
646
+ sfs = self._sub_fs()
647
+ widest = max(measure_text(t, sfs)[0] for t in str(self.subtitle).split("\n"))
648
+ wrap_w = max(cw, lw, min(widest, sfs * 14))
649
+ lw = max([lw] + [measure_text(t, sfs)[0] for t in self._wrap_sub(wrap_w)])
650
+ title_h = self._caption_h(1.6)
651
+ # v0.13.0: width parts for the CF-EXP-001 width budget
652
+ self._wparts = {"content": cw, "caption": lw, "pad": pl + pr}
653
+ return max(cw, lw) + pl + pr, ch + title_h + pt + pb
654
+ title_h = self._caption_h(1.6)
655
+ lw = 0.0 if self._no_caption() else measure_text(label, th.panel_title_size * 1.15)[0]
656
+ return max(cw, lw) + 2 * th.panel_pad, ch + title_h + 2 * th.panel_pad
657
+
658
+ def caption_text(self) -> str:
659
+ """``"(a) title"`` — the panel tag plus title as drawn (just the title with
660
+ ``letters=False`` / ``Figure(panel_letters=False)``)."""
661
+ if not self.letters:
662
+ return str(self.title or "")
663
+ return f"({self.letter}) {self.title}" if self.title else f"({self.letter})"
664
+
665
+ def _no_caption(self) -> bool:
666
+ return self.caption == "none" or (not self.caption_text() and not self.subtitle)
667
+
668
+ def _inner(self) -> Tuple[float, float, float, float]:
669
+ th = self.theme
670
+ g = self.geom
671
+ title_h = self._caption_h(1.8)
672
+ pt, pr, pb, pl = self.padding
673
+ if th.physical:
674
+ top = title_h if self.caption == "top" else 0.0
675
+ return g.x + pl, g.y + pt + top, g.w - pl - pr, g.h - title_h - pt - pb
676
+ if self.caption == "top":
677
+ return (
678
+ g.x + pl,
679
+ g.y + title_h + pt * 0.5,
680
+ g.w - pl - pr,
681
+ g.h - title_h - pt * 0.5 - pb,
682
+ )
683
+ return g.x + pl, g.y + pt, g.w - pl - pr, g.h - title_h - pt - pb
684
+
685
+ def _pack_offset(
686
+ self, ix: float, iy: float, iw: float, ih: float, sw: float, sh: float
687
+ ) -> Tuple[float, float]:
688
+ """Place content box in leftover panel space (pack / align / justify)."""
689
+ # Horizontal: prefer align; pack only applies when align is center-ish default
690
+ if self.align == "start":
691
+ ox = ix
692
+ elif self.align == "end":
693
+ ox = ix + (iw - sw)
694
+ else:
695
+ ox = ix + (iw - sw) / 2
696
+ # Vertical: pack is the leftover-space knob (distinct from fit-scale)
697
+ pack = self.pack
698
+ if self.justify == "start" or pack == "start":
699
+ # justify wins when explicitly start/end; pack used when justify=center
700
+ if self.justify == "start":
701
+ oy = iy
702
+ elif self.justify == "end":
703
+ oy = iy + (ih - sh)
704
+ elif pack == "start":
705
+ oy = iy
706
+ elif pack == "end":
707
+ oy = iy + (ih - sh)
708
+ else:
709
+ oy = iy + (ih - sh) / 2
710
+ elif self.justify == "end":
711
+ oy = iy + (ih - sh)
712
+ else:
713
+ if pack == "start":
714
+ oy = iy
715
+ elif pack == "end":
716
+ oy = iy + (ih - sh)
717
+ else:
718
+ oy = iy + (ih - sh) / 2
719
+ return ox, oy
720
+
721
+ def layout(self, x: float, y: float, w: float, h: float) -> None:
722
+ self.geom = BoxGeom(x, y, w, h)
723
+ if self.content is None:
724
+ return
725
+ ix, iy, iw, ih = self._inner()
726
+ self._prepare_sizes()
727
+ cw, ch = self.content.measure()
728
+ cw, ch = max(cw, 1.0), max(ch, 1.0)
729
+ self._overlays = []
730
+ self._bbox = (0.0, 0.0, cw, ch)
731
+ lw = cw if self.fit_mode == "scale" else max(cw, iw)
732
+ auto = self._resolve_overlaps(lw, ch, None if self.fit_mode == "scale" else (iw, ih))
733
+ user = self.all_constraints()
734
+ self._overlays = []
735
+ if user or auto:
736
+ # flex → constraint solve → fit the *solved* bbox
737
+ bx, by, bw, bh = self._solve_tolerant(lw, ch, user, auto, warn=True)
738
+ bw, bh = max(bw, 1.0), max(bh, 1.0)
739
+ self._bbox = (bx, by, bw, bh)
740
+ if self.fit_mode == "scale":
741
+ s = min(iw / bw, ih / bh, self.max_scale) * 0.95
742
+ if self._force_scale is not None:
743
+ s = min(s, self._force_scale)
744
+ self._scale = s
745
+ self._offset = self._pack_offset(ix, iy, iw, ih, bw * s, bh * s)
746
+ else:
747
+ self._scale = 1.0
748
+ ox, oy = self._pack_offset(ix, iy, iw, ih, bw, bh)
749
+ translate_tree(self.content, ox - bx, oy - by)
750
+ for o in self._overlays:
751
+ translate_tree(o, ox - bx, oy - by)
752
+ self._bbox = (ox, oy, bw, bh)
753
+ self._offset = (0.0, 0.0)
754
+ self._after_layout()
755
+ return
756
+ if self.fit_mode == "scale":
757
+ s = min(iw / cw, ih / ch, self.max_scale) * 0.95
758
+ if self._force_scale is not None:
759
+ s = min(s, self._force_scale)
760
+ self.content.layout(0.0, 0.0, cw, ch)
761
+ self._scale = s
762
+ sw, sh = cw * s, ch * s
763
+ self._offset = self._pack_offset(ix, iy, iw, ih, sw, sh)
764
+ else:
765
+ # fit_mode == "none": content-sized, pack leftover (no uniform scale)
766
+ self._scale = 1.0
767
+ # Give content at least its measure; allow fill children to use panel width
768
+ lw = cw if self.theme.physical else max(cw, iw)
769
+ self.content.layout(0.0, 0.0, lw, ch)
770
+ # Re-measure after layout? keep cw,ch natural for packing
771
+ sw, sh = cw, ch
772
+ ox, oy = self._pack_offset(ix, iy, iw, ih, sw, sh)
773
+ # Re-layout at absolute origin so draw needs no scale transform
774
+ self.content.layout(ox, oy, lw, ch)
775
+ self._offset = (0.0, 0.0)
776
+ self._after_layout()
777
+
778
+ def draw(self, buf: List[str]) -> None:
779
+ th = self.theme
780
+ g = self.geom
781
+ # Panel frame
782
+ if self.frame:
783
+ draw_rect(
784
+ buf,
785
+ g.x,
786
+ g.y,
787
+ g.w,
788
+ g.h,
789
+ fill="#ffffff",
790
+ stroke=self.frame_color or th.stroke,
791
+ stroke_width=th.frame_stroke,
792
+ radius=0,
793
+ dasharray={"dashed": "3,2", "dotted": "1,1.5"}.get(self.frame_style or ""),
794
+ )
795
+ # Caption: (a) title
796
+ label = self.caption_text()
797
+ pt, pr, pb, pl = self.padding
798
+ if self._no_caption():
799
+ pass
800
+ elif th.physical:
801
+ fs = th.panel_title_size
802
+ sh = self._sub_h()
803
+ tls = self._title_lines()
804
+ if self.caption == "top":
805
+ ty = g.y + pt + fs * 0.9
806
+ else:
807
+ ty = g.y + g.h - pb - fs * 0.25 - sh - fs * 1.2 * (len(tls) - 1)
808
+ for i, t in enumerate(tls):
809
+ draw_text(buf, t, g.x + pl, ty + fs * 1.2 * i, font_size=fs, weight="bold")
810
+ ty += fs * 1.2 * (len(tls) - 1)
811
+ if sh:
812
+ sfs = self._sub_fs()
813
+ for i, t in enumerate(self._sub_lines()):
814
+ draw_text(buf, t, g.x + pl, ty + fs * 0.3 + sfs * (1.0 + 1.25 * i),
815
+ font_size=sfs, fill=th.muted_color)
816
+ elif self.caption == "top":
817
+ draw_text(
818
+ buf,
819
+ label,
820
+ g.x + th.panel_pad,
821
+ g.y + th.panel_pad + th.panel_title_size,
822
+ font_size=th.panel_title_size,
823
+ weight="bold",
824
+ )
825
+ else:
826
+ fs = th.panel_title_size * 1.15
827
+ lw, _ = measure_text(label, fs)
828
+ draw_text(
829
+ buf,
830
+ label,
831
+ g.x + (g.w - lw) / 2,
832
+ g.y + g.h - th.panel_pad * 0.8,
833
+ font_size=fs,
834
+ )
835
+ if self.content is not None:
836
+ if self.fit_mode == "scale":
837
+ ox, oy = self._offset
838
+ bx, by = self._bbox[0], self._bbox[1]
839
+ tr = f"translate({ox:.2f},{oy:.2f}) scale({self._scale:.4f})"
840
+ if abs(bx) > 1e-6 or abs(by) > 1e-6:
841
+ tr += f" translate({-bx:.2f},{-by:.2f})"
842
+ buf.append(f'<g transform="{tr}">')
843
+ self.content.draw(buf)
844
+ for o in self._overlays + self.__dict__.get("_corner_legends", []):
845
+ o.draw(buf)
846
+ buf.append("</g>")
847
+ else:
848
+ self.content.draw(buf)
849
+ for o in self._overlays + self.__dict__.get("_corner_legends", []):
850
+ o.draw(buf)
851
+
852
+
853
+ def _grid_rects(rows: List[List[str]]) -> Dict[str, Tuple[int, int, int, int]]:
854
+ """letter → (row, col, rowspan, colspan) from a ``Figure(grid=...)`` spec."""
855
+ cells: Dict[str, List[Tuple[int, int]]] = {}
856
+ for r, row in enumerate(rows):
857
+ for c, k in enumerate(row):
858
+ if k not in (".", " ", ""):
859
+ cells.setdefault(k, []).append((r, c))
860
+ out: Dict[str, Tuple[int, int, int, int]] = {}
861
+ for k, cs in cells.items():
862
+ r0, r1 = min(r for r, _ in cs), max(r for r, _ in cs)
863
+ c0, c1 = min(c for _, c in cs), max(c for _, c in cs)
864
+ if len(cs) != (r1 - r0 + 1) * (c1 - c0 + 1):
865
+ raise ValueError(f"Figure grid: panel {k!r} does not form a rectangle")
866
+ out[k] = (r0, c0, r1 - r0 + 1, c1 - c0 + 1)
867
+ return out
868
+
869
+
870
+ class Figure(ConstraintScope):
871
+ """
872
+ Top-level figure canvas.
873
+
874
+ Physical height fitting parameters:
875
+
876
+ | Parameter | Default | Meaning |
877
+ | --- | --- | --- |
878
+ | aspect | None | Target width/height; mutually exclusive with height. |
879
+ | height | None | Target height in pt. |
880
+ | max_height | None | Height cap; never adds padding. |
881
+ | fit | "compact" | none, compact, scale or pad; active only with a target. |
882
+ | min_font | 5.0 | Minimum printed font size in pt for scale. |
883
+
884
+ Targets must be positive and finite. Compact compresses automatic spacing
885
+ only (down to 0.6); explicit gaps and padding stay unchanged. Scale then
886
+ shrinks uniformly or raises LayoutError at the font floor. Pad centers
887
+ shorter content vertically. layout_report() exposes fitting diagnostics.
888
+
889
+
890
+ Panels are arranged in a grid (default 2 columns, filled row-major). Layout is
891
+ constraint/flex based — the public API does not take absolute author coordinates.
892
+
893
+ v0.5.0 spans: ``fig.panel("c", colspan=2)`` makes a panel span several grid
894
+ columns (``rowspan=`` rows); auto placement takes the first free slot that fits,
895
+ row-major. Or give the whole arrangement as ``grid=["aab", "ccb"]`` (one string or
896
+ list per row, one panel letter per cell, ``"."`` for an empty cell; each letter
897
+ must form a rectangle) — ``cols`` then follows the grid.
898
+
899
+ Args (layout-related):
900
+ title: Figure title.
901
+ size: ``(w, h)`` in px or ``"auto"`` (canvas cropped to the content).
902
+ cols: Grid columns for auto placement (default 2; ignored with ``grid``).
903
+ grid: Explicit panel arrangement (see above). Default ``None``.
904
+ equal_heights: ``"row"`` (default — panels in one grid row share its height),
905
+ ``"all"`` (every row as tall as the tallest) or ``False`` (each panel
906
+ keeps its natural height, top-aligned in its row).
907
+ widths / heights (v0.12.0): column / row size ratios. ``widths`` is a
908
+ spare-room hint, not a strict ratio under a fixed venue width. For example,
909
+ ``Figure(grid=["ab"], widths=[5, 4])``: the venue width (minus panel gaps
910
+ and margins) is shared 5 : 4. Leave it out unless you want *unequal
911
+ spare room*: by default every column already gets its content's natural
912
+ width plus an equal part of the spare room. v0.13.0: a share narrower
913
+ than a column's content no longer inflates the figure — that column keeps
914
+ its natural width and the others split the rest; only when the natural
915
+ widths alone exceed the slot does everything shrink (CF-EXP-001 lists the
916
+ width budget). Without a fixed width (``size="auto"``) the shares are
917
+ exact and the figure grows until every column fits.
918
+ panel_dividers (v0.12.0): ``"solid"`` (or ``True``) / ``"dashed"`` /
919
+ ``"dotted"`` rule lines in the gaps between neighbouring panels;
920
+ ``None``/``False`` (default) draws none.
921
+
922
+ ``fig.panel("a", "title", subtitle="one-line detail")`` adds a smaller second caption
923
+ line (v0.12.0).
924
+
925
+ Example:
926
+ >>> from codefig import Figure, Box
927
+ >>> fig = Figure(size="auto", grid=["ab", "cc"])
928
+ >>> for k in "abc":
929
+ ... _ = fig.panel(k, f"panel {k}").set(Box(k))
930
+ >>> svg = fig.to_svg()
931
+ """
932
+
933
+ def __init__(
934
+ self,
935
+ title: str = "",
936
+ size: Union[Tuple[float, float], str, None] = None,
937
+ *,
938
+ venue: Optional[str] = None,
939
+ width: Union[str, float, None] = None,
940
+ cols: int = 2,
941
+ theme: ThemeLike = None,
942
+ edge: Optional[dict] = None,
943
+ spacing: Optional[dict] = None,
944
+ title_pos: str = "bottom",
945
+ panel_caption: Optional[str] = "inherit",
946
+ panel_frame: Optional[bool] = None,
947
+ panel_scale: str = "fit",
948
+ panel_align: str = "center",
949
+ panel_justify: Optional[str] = None,
950
+ canvas_pad: Optional[float] = None,
951
+ overlap: str = "resolve",
952
+ roles: Optional[Dict[str, Any]] = None,
953
+ knockout: Optional[bool] = None,
954
+ grid: Optional[Sequence[Union[str, Sequence[str]]]] = None,
955
+ equal_heights: Union[str, bool] = "row",
956
+ align_edges: bool = True,
957
+ widths: Optional[Sequence[float]] = None,
958
+ heights: Optional[Sequence[float]] = None,
959
+ panel_dividers: Union[str, bool, None] = None,
960
+ panel_letters: bool = True,
961
+ auto_wrap: bool = True,
962
+ font_size: Optional[float] = None,
963
+ categories: Optional[Sequence[str]] = None,
964
+ aspect: Optional[float] = None,
965
+ height: Optional[float] = None,
966
+ max_height: Optional[float] = None,
967
+ fit: str = "compact",
968
+ min_font: float = 5.0,
969
+ ) -> None:
970
+ if aspect is not None and height is not None:
971
+ raise ValueError("aspect= and height= are mutually exclusive; pass one")
972
+ import math
973
+ for name, value in (("aspect", aspect), ("height", height),
974
+ ("max_height", max_height), ("min_font", min_font)):
975
+ if value is not None and (not math.isfinite(value) or value <= 0):
976
+ raise ValueError(f"{name} must be positive and finite; received {value!r}")
977
+ if fit not in ("none", "compact", "scale", "pad"):
978
+ raise ValueError("fit must be none | compact | scale | pad")
979
+ self.target_aspect = aspect
980
+ self.target_height = height
981
+ self.max_height = max_height
982
+ self.fit = fit
983
+ self.min_font = min_font
984
+ from .overlaps import check_policy
985
+ from .policy import normalize_role
986
+
987
+ self.panel_letters = bool(panel_letters)
988
+ self.auto_wrap = bool(auto_wrap)
989
+ # v0.14.0: declared category order (palette index) — fig.color(name) appends
990
+ self._cat_order: List[str] = []
991
+ for c in categories or ():
992
+ if str(c) not in self._cat_order:
993
+ self._cat_order.append(str(c))
994
+
995
+ if panel_dividers is True: # v0.13.0: bool alias (True = "solid")
996
+ panel_dividers = "solid"
997
+ elif panel_dividers is False:
998
+ panel_dividers = None
999
+ if panel_dividers not in (None, "none", "solid", "dashed", "dotted"):
1000
+ raise ValueError("panel_dividers must be None/False, True/'solid', 'dashed' or 'dotted'")
1001
+ self.panel_dividers = None if panel_dividers == "none" else panel_dividers
1002
+ self.widths = [float(v) for v in widths] if widths is not None else None
1003
+ self.heights = [float(v) for v in heights] if heights is not None else None
1004
+ for nm, v in (("widths", self.widths), ("heights", self.heights)):
1005
+ if v is not None and (not v or any(x <= 0 for x in v)):
1006
+ raise ValueError(f"{nm} must be positive ratios, e.g. {nm}=[2, 1]")
1007
+ self.overlap = check_policy(overlap) or "resolve"
1008
+ self.align_edges = bool(align_edges)
1009
+ # figure-level layering defaults: {"Text": "hard", "Group": "none", …}
1010
+ self.roles = {str(k): normalize_role(v) for k, v in (roles or {}).items()}
1011
+ self.knockout = knockout
1012
+ from .venues import get_venue, resolve_width_pt
1013
+
1014
+ if theme is None:
1015
+ th = current_theme()
1016
+ if venue is not None and not th.physical:
1017
+ th = get_theme("paper") # a venue implies print units
1018
+ else:
1019
+ th = get_theme(theme)
1020
+ th = apply_spacing_overrides(th, spacing)
1021
+ th = apply_edge_overrides(th, edge)
1022
+ if font_size is not None: # v0.14.0 (C10): figure-wide base text size
1023
+ from dataclasses import replace as _replace
1024
+
1025
+ fsz = float(font_size)
1026
+ if th.font_sizes and fsz not in th.font_sizes:
1027
+ raise ValueError(f"font_size={font_size!r}: the {th.name} theme uses the size roles "
1028
+ f"{tuple(th.font_sizes)} pt (lint CF-TXT-002); pick one of them")
1029
+ th = _replace(th, font_size=fsz)
1030
+ self.theme = th
1031
+ self.venue = get_venue(venue)
1032
+ self.venue_width = width if width is not None else ("text" if venue else None)
1033
+ # physical: units are pt at final print size (paper theme, venue= or width=)
1034
+ self.physical = size is None and (th.physical or venue is not None or width is not None)
1035
+ self.target_width: Optional[float] = (
1036
+ resolve_width_pt(venue, self.venue_width) if self.physical else None)
1037
+ self.global_scale = 1.0 # < 1 when content had to shrink to fit the venue width
1038
+ if panel_scale not in ("fit", "uniform"):
1039
+ raise ValueError("panel_scale must be 'fit' or 'uniform'")
1040
+ self.panel_scale = panel_scale
1041
+ self.title = title
1042
+ self.title_pos = title_pos
1043
+ self.panel_caption = th.panel_caption if panel_caption == "inherit" else panel_caption
1044
+ self.panel_frame = th.panel_frame if panel_frame is None else bool(panel_frame)
1045
+ self.panel_align = panel_align
1046
+ self.panel_justify = panel_justify
1047
+ if size is None:
1048
+ size = "auto" if self.physical else (1600, 1200)
1049
+ self._auto_size = isinstance(size, str) and str(size).lower() == "auto"
1050
+ if self._auto_size:
1051
+ # Placeholder; layout() computes from content + pad (paper crop)
1052
+ self.width, self.height = 800.0, 600.0
1053
+ else:
1054
+ self.width, self.height = float(size[0]), float(size[1]) # type: ignore[index]
1055
+ if equal_heights not in ("row", "all", False):
1056
+ raise ValueError("equal_heights must be 'row', 'all' or False")
1057
+ self.equal_heights = equal_heights
1058
+ self.grid: Optional[List[List[str]]] = None
1059
+ if grid is not None:
1060
+ rows_ = [list(r) for r in grid]
1061
+ if not rows_ or any(len(r) != len(rows_[0]) for r in rows_) or not rows_[0]:
1062
+ raise ValueError("Figure grid rows must be non-empty and equally long")
1063
+ self.grid = rows_
1064
+ cols = len(rows_[0])
1065
+ self._grid_rects = _grid_rects(rows_)
1066
+ self.cols = cols
1067
+ if self.widths is not None and len(self.widths) != cols:
1068
+ raise ValueError(f"widths needs one ratio per grid column ({cols}), got {len(self.widths)}")
1069
+ self.canvas_pad = canvas_pad # None → theme.panel_gap (physical: 1 pt crop margin)
1070
+ self._panels: Dict[str, Panel] = {}
1071
+ self._order: List[str] = []
1072
+
1073
+ def panel(
1074
+ self,
1075
+ letter: str,
1076
+ title: str = "",
1077
+ *,
1078
+ align: Optional[str] = None,
1079
+ justify: Optional[str] = None,
1080
+ pack: str = "center",
1081
+ fit: Optional[bool] = None,
1082
+ fit_mode: Optional[str] = None,
1083
+ pad: Optional[object] = None,
1084
+ overlap: Optional[str] = None,
1085
+ frame: Union[bool, str, None] = None,
1086
+ caption: Optional[str] = "inherit",
1087
+ colspan: int = 1,
1088
+ rowspan: int = 1,
1089
+ max_scale: Optional[float] = None,
1090
+ align_edges: Optional[bool] = None,
1091
+ subtitle: Optional[str] = None,
1092
+ frame_color: Optional[str] = None,
1093
+ letters: Optional[bool] = None,
1094
+ ) -> Panel:
1095
+ """Create (or replace) panel ``letter``.
1096
+
1097
+ Args:
1098
+ letter: Panel letter, shown as ``(a)`` in the caption.
1099
+ title: Caption text.
1100
+ colspan: Grid columns the panel spans (default 1; v0.5.0).
1101
+ rowspan: Grid rows the panel spans (default 1; v0.5.0).
1102
+ align_edges: Nudge nodes so connected ports line up (v0.11.0;
1103
+ default: the figure's ``align_edges``, itself True).
1104
+ max_scale: Largest zoom ``fit_mode="scale"`` may apply (default: the
1105
+ theme's ``max_panel_scale`` — 1.8 classic, 1.0 paper).
1106
+ Use ``1.0`` to keep a wide spanning panel's text at the same size as
1107
+ its neighbours (v0.5.0).
1108
+ align / justify / pack / fit / fit_mode / pad / overlap / frame / caption:
1109
+ Per-panel layout options (see :class:`Panel`).
1110
+ frame: ``True`` / ``"solid"``, ``"dashed"``, ``"dotted"`` or ``False`` — a
1111
+ frame around the whole panel, caption included (v0.13.0 styles);
1112
+ ``frame_color=`` colours it, e.g. ``frame="dashed",
1113
+ frame_color=theme.accent("vermillion")``.
1114
+ subtitle: smaller second caption line; wraps to the panel width
1115
+ (v0.13.0). A ``"\n"`` in ``title`` gives a two-line caption.
1116
+ letters: ``False`` drops the ``(a)`` tag but keeps the title (default:
1117
+ ``Figure(panel_letters=)``, itself True). ``caption="none"`` hides the
1118
+ whole caption — tag *and* title (a title given then warns).
1119
+
1120
+ With ``Figure(grid=...)`` the span comes from the grid and ``colspan`` /
1121
+ ``rowspan`` must be left at 1.
1122
+ """
1123
+ if int(colspan) < 1 or int(rowspan) < 1:
1124
+ raise ValueError("colspan / rowspan must be >= 1")
1125
+ if self.grid is not None:
1126
+ if (colspan, rowspan) != (1, 1):
1127
+ raise ValueError("with Figure(grid=...) the grid defines spans; drop colspan/rowspan")
1128
+ if letter not in self._grid_rects:
1129
+ raise ValueError(f"panel {letter!r} is not in the figure grid")
1130
+ elif int(colspan) > self.cols:
1131
+ raise ValueError(f"colspan={colspan} exceeds cols={self.cols}")
1132
+ cap = self.panel_caption if caption == "inherit" else caption
1133
+ if title and (cap in (None, False) or str(cap).lower() == "none"):
1134
+ import warnings as _w
1135
+
1136
+ from .lint import CaptionWarning
1137
+
1138
+ _w.warn(f"panel {letter!r}: title {str(title)[:40]!r} is not drawn because the caption "
1139
+ f"is 'none' (it hides the (a) tag AND the title); use "
1140
+ f"Figure(panel_letters=False) / fig.panel(..., letters=False) to drop only the "
1141
+ f"letters, or caption='top'/'bottom'", CaptionWarning, stacklevel=2)
1142
+ p = Panel(
1143
+ letter,
1144
+ title,
1145
+ theme=self.theme,
1146
+ letters=self.panel_letters if letters is None else letters,
1147
+ caption=cap,
1148
+ frame=self.panel_frame if frame is None else frame,
1149
+ align=align or self.panel_align,
1150
+ justify=justify or self.panel_justify,
1151
+ pack=pack,
1152
+ fit=fit,
1153
+ fit_mode=fit_mode,
1154
+ pad=pad,
1155
+ overlap=overlap or self.overlap,
1156
+ max_scale=max_scale,
1157
+ align_edges=self.align_edges if align_edges is None else align_edges,
1158
+ subtitle=subtitle,
1159
+ frame_color=frame_color,
1160
+ )
1161
+ p.colspan, p.rowspan = int(colspan), int(rowspan)
1162
+ p.__dict__["_fig"] = self # Legend(source="figure") collects across panels
1163
+ if letter in self._panels:
1164
+ self._order.remove(letter)
1165
+ self._panels[letter] = p
1166
+ self._order.append(letter)
1167
+ return p
1168
+
1169
+ def _placements(self) -> Tuple[Dict[str, Tuple[int, int, int, int]], int]:
1170
+ """letter → (row, col, rowspan, colspan); and the number of grid rows."""
1171
+ if self.grid is not None:
1172
+ out = {k: self._grid_rects[k] for k in self._order}
1173
+ return out, len(self.grid)
1174
+ cols = self.cols
1175
+ occ: Dict[Tuple[int, int], str] = {}
1176
+ out = {}
1177
+ for k in self._order:
1178
+ p = self._panels[k]
1179
+ cs, rs = getattr(p, "colspan", 1), getattr(p, "rowspan", 1)
1180
+ r = 0
1181
+ placed = False
1182
+ while not placed:
1183
+ for c in range(cols - cs + 1):
1184
+ if all((r + i, c + j) not in occ for i in range(rs) for j in range(cs)):
1185
+ for i in range(rs):
1186
+ for j in range(cs):
1187
+ occ[(r + i, c + j)] = k
1188
+ out[k] = (r, c, rs, cs)
1189
+ placed = True
1190
+ break
1191
+ r += 1
1192
+ rows = max([r + rs for r, _c, rs, _cs in out.values()] + [1])
1193
+ return out, rows
1194
+
1195
+ def add_panel(self, letter: str, title: str = "", content: Optional[Node] = None) -> Panel:
1196
+ p = self.panel(letter, title)
1197
+ if content is not None:
1198
+ p.set(content)
1199
+ return p
1200
+
1201
+ def _title_h(self) -> float:
1202
+ if self.physical:
1203
+ return self.theme.title_size * 1.25 + self.theme.title_gap if self.title else 0.0
1204
+ return self.theme.title_size * 2.0 if self.title else 12.0
1205
+
1206
+ def _route_constraints(self) -> None:
1207
+ """Assign Figure-level constraints to the panel whose tree holds their nodes."""
1208
+ for letter in self._order:
1209
+ self._panels[letter]._fig_constraints = []
1210
+ trees = {}
1211
+ for letter in self._order:
1212
+ p = self._panels[letter]
1213
+ ids = {id(n) for n in walk(p.content)} if p.content is not None else set()
1214
+ # place() annotations registered on the panel / its Diagrams belong here too
1215
+ for c in p.all_constraints():
1216
+ if c.overlay is not None:
1217
+ ids |= {id(n) for n in walk(c.overlay)}
1218
+ trees[letter] = ids
1219
+ # place() first so later constraints (e.g. allow_overlap(note)) can name the
1220
+ # annotation it creates
1221
+ fig_cs = list(self._cf_constraints())
1222
+ homes: Dict[int, str] = {}
1223
+ for c in sorted(fig_cs, key=lambda c: c.overlay is None):
1224
+ nodes = [n for n in c.nodes() if c.overlay is None or n is not c.overlay]
1225
+ home = None
1226
+ for letter, ids in trees.items():
1227
+ if nodes and all(id(n) in ids for n in nodes):
1228
+ home = letter
1229
+ break
1230
+ if home is None:
1231
+ from .constraints import ConstraintConflictError, node_name
1232
+
1233
+ raise ConstraintConflictError(
1234
+ f"{c.describe()}: nodes {', '.join(node_name(n) for n in nodes)} are not all "
1235
+ "inside one panel (panels are scaled independently; constraints are per panel)"
1236
+ )
1237
+ if c.overlay is not None:
1238
+ trees[home] |= {id(n) for n in walk(c.overlay)}
1239
+ homes[id(c)] = home
1240
+ for c in fig_cs: # keep the author's order inside each panel
1241
+ self._panels[homes[id(c)]]._fig_constraints.append(c)
1242
+
1243
+ def _policy(self):
1244
+ from .policy import use_policy
1245
+
1246
+ return use_policy(self.roles, self.knockout)
1247
+
1248
+ def categories(self) -> List[str]:
1249
+ """Distinct ``category=`` values (→ palette index): the ones declared with
1250
+ ``Figure(categories=[...])`` or asked for with :meth:`color` first, then the rest in
1251
+ panel order."""
1252
+ out: List[str] = list(self.__dict__.get("_cat_order", []))
1253
+ for letter in self._order:
1254
+ p = self._panels[letter]
1255
+ if p.content is None:
1256
+ continue
1257
+ for n in walk(p.content):
1258
+ c = getattr(n, "category", None)
1259
+ if c is not None and str(c) not in out:
1260
+ out.append(str(c))
1261
+ return out
1262
+
1263
+ def color(self, category: str) -> str:
1264
+ """The fill colour of ``category`` in this figure (v0.14.0): the same colour a
1265
+ ``Box(category=...)`` / ``Circle(category=...)`` gets, so a chart series, an edge
1266
+ or a hand-drawn swatch can share it across panels::
1267
+
1268
+ fig = Figure(venue="osdi", categories=["gate", "baseline"])
1269
+ ... Circle("G", category="gate") ... # panel (a)
1270
+ LineChart(..., colors=[fig.color("baseline"), fig.color("gate")]) # panel (b)
1271
+
1272
+ The palette index is fixed on first use (``Figure(categories=)`` order, then calls
1273
+ to ``color()``, then ``category=`` in panel order), so it does not change when
1274
+ later panels add categories. Past the palette (6 paper fills) categories cycle the
1275
+ fills with a hatch; ``color()`` returns the fill only."""
1276
+ name = str(category)
1277
+ if name not in self._cat_order:
1278
+ # keep the indices of categories already used by content stable
1279
+ for c in self.categories():
1280
+ if c not in self._cat_order:
1281
+ self._cat_order.append(c)
1282
+ if name not in self._cat_order:
1283
+ self._cat_order.append(name)
1284
+ from .styles import category_style, use_categories
1285
+ with use_categories(self._cat_order):
1286
+ return category_style(name, self.theme)[0]
1287
+
1288
+ def _ctx(self):
1289
+ """Theme + typeface + layering policy for everything this figure does."""
1290
+ from contextlib import ExitStack
1291
+
1292
+ from .textutil import use_typeface
1293
+
1294
+ from .styles import use_categories
1295
+
1296
+ st = ExitStack()
1297
+ st.enter_context(use_theme(getattr(self, "_fitted_theme", self.theme)))
1298
+ st.enter_context(use_categories(self.categories()))
1299
+ st.enter_context(use_typeface(self.theme.typeface))
1300
+ st.enter_context(self._policy())
1301
+ from .styles import use_output_scale
1302
+
1303
+ st.enter_context(use_output_scale(self.__dict__.get("_script_k", 1.0)))
1304
+ return st
1305
+
1306
+ def layout(self) -> None:
1307
+ from .constraints import invalidate_tree
1308
+ # Recompute row budgets when a figure is reused at a different width.
1309
+ for panel in self._panels.values():
1310
+ if panel.content is not None:
1311
+ changed = False
1312
+ for node in walk(panel.content):
1313
+ if node.__dict__.pop("_slot_width", None) is not None:
1314
+ changed = True
1315
+ if changed:
1316
+ invalidate_tree(panel.content)
1317
+ self._layout_once()
1318
+ # v0.14.0: wrap long labels that set the width before anything shrinks
1319
+ kept = self.__dict__.setdefault("_autowrapped", [])
1320
+ if self.physical and self.auto_wrap and self.target_width is not None \
1321
+ and self.global_scale < 0.9995:
1322
+ from .autowrap import auto_wrap
1323
+
1324
+ kept.extend(auto_wrap(self, self._layout_once))
1325
+ if self.physical and self.target_width is not None and self.global_scale < 0.9995:
1326
+ self._compress_width_gaps = True
1327
+ self._layout_once()
1328
+ if self.physical and self.auto_wrap and self.global_scale < 0.9:
1329
+ # Use each panel's share of the available slot. Reflow only rows that
1330
+ # exceed that budget; fixed child dimensions remain authoritative.
1331
+ from .constraints import invalidate_tree
1332
+ from .node import Container
1333
+ def reflow(node, budget):
1334
+ if isinstance(node, Container):
1335
+ pt, pr, pb, pl = node.padding
1336
+ budget = max(1.0, budget - pl - pr)
1337
+ if node.direction == "row" and node.measure()[0] > budget + pl + pr:
1338
+ node._slot_width = budget
1339
+ if node.direction == "row" and not node.__dict__.get("_slot_width"):
1340
+ return
1341
+ for child in node.children():
1342
+ reflow(child, budget)
1343
+ for panel in self._panels.values():
1344
+ if panel.content is not None:
1345
+ budget = panel.content.measure()[0] * self.global_scale
1346
+ reflow(panel.content, budget)
1347
+ invalidate_tree(panel.content)
1348
+ self._layout_once()
1349
+ # v0.12.0 (A14): a physical figure shrunk to fit keeps its sub/superscripts at
1350
+ # the theme's printed floor — lay out once more with the scale known
1351
+ for _it in range(3):
1352
+ if not (self.physical and self.theme.script_min > 0):
1353
+ break
1354
+ k = min(1.0, float(self.global_scale))
1355
+ if abs(k - self.__dict__.get("_script_k", 1.0)) <= 0.005:
1356
+ break
1357
+ from .constraints import invalidate_tree
1358
+
1359
+ # a growing script widens the content and lowers the scale: start from
1360
+ # the smaller of the two so the loop settles from below
1361
+ self._script_k = min(k, self.__dict__.get("_script_k", 1.0)) if _it else k
1362
+ for p in self._panels.values():
1363
+ if p.content is not None:
1364
+ invalidate_tree(p.content)
1365
+ self._layout_once()
1366
+
1367
+ def _layout_once(self) -> None:
1368
+ from dataclasses import replace
1369
+ from .constraints import invalidate_tree
1370
+ self.__dict__.pop("_fitted_theme", None)
1371
+ original = self.theme
1372
+ requested_fit = self.fit
1373
+ width_search = self.physical and self.__dict__.get("_compress_width_gaps", False)
1374
+ search = self.physical and (requested_fit in ("compact", "scale") or width_search)
1375
+ def solve(factor):
1376
+ self.theme = replace(original, gap_scale=original.gap_scale * factor,
1377
+ pad_scale=original.pad_scale * factor,
1378
+ box_pad=tuple(v * factor for v in original.box_pad) if original.box_pad else None,
1379
+ corner_radius=original.corner_radius * factor)
1380
+ for panel in self._panels.values():
1381
+ if panel.content is not None:
1382
+ invalidate_tree(panel.content)
1383
+ self._layout_unfitted()
1384
+ if search:
1385
+ self.fit = "compact"
1386
+ try:
1387
+ self._layout_unfitted()
1388
+ if not search:
1389
+ return
1390
+ natural = self._layout_report["natural_size"]
1391
+ target = self.width / self.target_aspect if self.target_aspect else self.target_height
1392
+ if self.max_height is not None:
1393
+ target = min(target, self.max_height) if target is not None else self.max_height
1394
+ chosen = 1.0
1395
+ if width_search and self.global_scale < 0.9995:
1396
+ low, high = 0.6, 1.0
1397
+ solve(low)
1398
+ chosen = low
1399
+ if self.global_scale >= 0.9995:
1400
+ for _ in range(8):
1401
+ mid = (low + high) / 2
1402
+ solve(mid)
1403
+ if self.global_scale >= 0.9995:
1404
+ low = mid
1405
+ else:
1406
+ high = mid
1407
+ chosen = low
1408
+ if target is not None and natural[1] > target:
1409
+ low, high = 0.6, 1.0
1410
+ solve(low)
1411
+ chosen = low
1412
+ if self.height <= target:
1413
+ for _ in range(8):
1414
+ mid = (low + high) / 2
1415
+ solve(mid)
1416
+ if self.height <= target:
1417
+ low = mid
1418
+ else:
1419
+ high = mid
1420
+ chosen = low
1421
+ self.fit = requested_fit
1422
+ solve(chosen)
1423
+ self._layout_report["natural_size"] = natural
1424
+ self._layout_report["gap_scale_used"] = chosen
1425
+ self._fitted_theme = replace(self.theme, fit_font_scale=self.global_scale) if requested_fit == "scale" and self._layout_report["scale_used"] < 1 else self.theme
1426
+ finally:
1427
+ self.fit = requested_fit
1428
+ self.theme = original
1429
+
1430
+ def _layout_unfitted(self) -> None:
1431
+ with self._ctx():
1432
+ self._layout_impl()
1433
+ if self._fit_edge_margins():
1434
+ self._layout_impl()
1435
+ if self.panel_scale == "uniform" and not self.physical:
1436
+ scaled = [p for p in self._panels.values()
1437
+ if p.content is not None and p.fit_mode == "scale"]
1438
+ if scaled:
1439
+ s = min(p._scale for p in scaled)
1440
+ for p in scaled:
1441
+ p._force_scale = s
1442
+ self._layout_impl()
1443
+ for p in scaled:
1444
+ p._force_scale = None
1445
+
1446
+ self._position_corner_legends()
1447
+
1448
+ def _position_corner_legends(self) -> None:
1449
+ from .legend import position_corners
1450
+ for panel in self._panels.values():
1451
+ position_corners(panel)
1452
+
1453
+ def _fit_edge_margins(self) -> bool:
1454
+ """Size every Diagram's edge margin to what its routed edges / labels really
1455
+ need (instead of the loop-clearance estimate). True if anything changed."""
1456
+ from .constraints import invalidate_tree
1457
+ from .edges import Diagram
1458
+
1459
+ changed = False
1460
+ for letter in self._order:
1461
+ p = self._panels[letter]
1462
+ if p.content is None:
1463
+ continue
1464
+ for n in walk(p.content):
1465
+ if isinstance(n, Diagram) and getattr(n, "_fit_margins", None) is None:
1466
+ cur = n._margins()
1467
+ need = n.edge_overflow()
1468
+ fit = tuple(max(0.0, x) for x in need)
1469
+ if any(abs(a - b) > 1.0 for a, b in zip(cur, fit)):
1470
+ n._fit_margins = fit
1471
+ changed = True
1472
+ if changed:
1473
+ invalidate_tree(p.content)
1474
+ return changed
1475
+
1476
+ def _layout_impl(self) -> None:
1477
+ th = self.theme
1478
+ title_h = self._title_h()
1479
+ cols = self.cols
1480
+ place, rows = self._placements()
1481
+ gap = float(self.canvas_pad if self.canvas_pad is not None else th.panel_gap)
1482
+ self._route_constraints()
1483
+
1484
+ # Natural content sizes per panel
1485
+ needs = {k: self._panels[k].measure_content() for k in self._order}
1486
+
1487
+ def spread(sizes: List[float], start: int, n: int, need: float) -> None:
1488
+ have = sum(sizes[start:start + n]) + gap * (n - 1)
1489
+ if need > have:
1490
+ extra = (need - have) / n
1491
+ for i in range(start, start + n):
1492
+ sizes[i] += extra
1493
+
1494
+ top = 0.0
1495
+ if self.physical:
1496
+ self._layout_physical(needs, place, rows, spread)
1497
+ return
1498
+ if self._auto_size:
1499
+ # Paper crop: size canvas to content + gaps + title.
1500
+ col_w = [0.0] * cols
1501
+ row_h = [0.0] * rows
1502
+ # single-cell panels first, then spans claim what they still lack
1503
+ for multi in (False, True):
1504
+ for k in self._order:
1505
+ r, c, rs, cs = place[k]
1506
+ cw, ch = needs[k]
1507
+ if (cs > 1) == multi:
1508
+ spread(col_w, c, cs, cw)
1509
+ if (rs > 1) == multi:
1510
+ spread(row_h, r, rs, max(ch, 80.0))
1511
+ if self.equal_heights == "all":
1512
+ row_h = [max(row_h)] * rows
1513
+ self.width = sum(col_w) + gap * (cols + 1)
1514
+ title_h = title_h if self.title else 0.0
1515
+ self.height = sum(row_h) + title_h + gap * (rows + 1)
1516
+ top = title_h if self.title_pos == "top" else 0.0
1517
+ else:
1518
+ avail_w = self.width - gap * (cols + 1)
1519
+ avail_h = self.height - title_h - gap * (rows + 1)
1520
+ col_w = [avail_w / cols] * cols
1521
+ # Content-driven row heights: weight each row by max natural content height
1522
+ # so empty air shrinks when some panels are shorter.
1523
+ row_need = [1.0] * rows
1524
+ for multi in (False, True):
1525
+ for k in self._order:
1526
+ r, _c, rs, _cs = place[k]
1527
+ if (rs > 1) == multi:
1528
+ spread(row_need, r, rs, max(needs[k][1], 80.0))
1529
+ equal = avail_h / rows
1530
+ weights = [max(need, equal * 0.35) for need in row_need]
1531
+ if self.equal_heights == "all":
1532
+ weights = [1.0] * rows
1533
+ total = sum(weights) or 1.0
1534
+ row_h = [avail_h * w / total for w in weights]
1535
+ top = title_h if self.title_pos == "top" else 0.0
1536
+
1537
+ col_x = [gap + sum(col_w[:c]) + gap * c for c in range(cols)]
1538
+ row_y = [top + gap + sum(row_h[:r]) + gap * r for r in range(rows)]
1539
+ for k in self._order:
1540
+ r, c, rs, cs = place[k]
1541
+ w = sum(col_w[c:c + cs]) + gap * (cs - 1)
1542
+ h = sum(row_h[r:r + rs]) + gap * (rs - 1)
1543
+ if self.equal_heights is False:
1544
+ h = min(h, needs[k][1])
1545
+ self._panels[k].layout(col_x[c], row_y[r], w, h)
1546
+
1547
+ def _layout_physical(self, needs, place, rows, spread) -> None:
1548
+ """Print-size layout: panels at natural size (no per-panel zoom). Extra venue
1549
+ width is shared by the columns; if content is wider than the venue, ONE
1550
+ figure-wide scale < 1 shrinks everything uniformly (lint reports it)."""
1551
+ th = self.theme
1552
+ cols = self.cols
1553
+ gap = float(th.panel_gap)
1554
+ margin = float(self.canvas_pad) if self.canvas_pad is not None else 1.0
1555
+ col_w = [0.0] * cols
1556
+ row_h = [0.0] * rows
1557
+
1558
+ def spread_prop(sizes: List[float], start: int, n: int, need: float) -> None:
1559
+ """v0.7.0: a span's missing size goes to its tracks in proportion to what
1560
+ they already hold, so a short panel beside a tall spanning one stays
1561
+ compact (equal shares when the tracks are still empty)."""
1562
+ have = sum(sizes[start:start + n]) + gap * (n - 1)
1563
+ if need <= have:
1564
+ return
1565
+ extra = need - have
1566
+ cur = sizes[start:start + n]
1567
+ tot = sum(cur)
1568
+ for i in range(n):
1569
+ sizes[start + i] += extra * (cur[i] / tot if tot > 1e-9 else 1.0 / n)
1570
+
1571
+ for multi in (False, True):
1572
+ for k in self._order:
1573
+ r, c, rs, cs = place[k]
1574
+ cw, ch = needs[k]
1575
+ if (cs > 1) == multi:
1576
+ spread_prop(col_w, c, cs, cw)
1577
+ if (rs > 1) == multi:
1578
+ spread_prop(row_h, r, rs, ch)
1579
+ if self.equal_heights == "all":
1580
+ row_h = [max(row_h)] * rows
1581
+ if self.heights is not None and len(self.heights) == rows:
1582
+ k = max(h / r for h, r in zip(row_h, self.heights))
1583
+ row_h = [r * k for r in self.heights]
1584
+ used = [c for c in range(cols) if col_w[c] > 0] or list(range(cols))
1585
+ nat_cols = list(col_w)
1586
+ if self.widths is not None:
1587
+ used = list(range(cols))
1588
+ self._widths_clamped = []
1589
+ if self.target_width is not None:
1590
+ # v0.13.0: widths= are shares of the *slot*. A column whose share is
1591
+ # narrower than its content keeps its natural width and the rest of the
1592
+ # slot is shared among the other columns (CSS-flex style) — the shares
1593
+ # never inflate the figure past the slot and force a global shrink.
1594
+ avail = self.target_width - 2 * margin - gap * (cols - 1)
1595
+ if sum(col_w) >= avail - 1e-6:
1596
+ self._widths_clamped = list(range(cols))
1597
+ else:
1598
+ fixed: dict = {}
1599
+ while True:
1600
+ rest = avail - sum(fixed.values())
1601
+ free = [c for c in range(cols) if c not in fixed]
1602
+ tot = sum(self.widths[c] for c in free) or 1.0
1603
+ bad = [c for c in free if self.widths[c] / tot * rest < col_w[c] - 1e-6]
1604
+ if not bad:
1605
+ break
1606
+ for c in bad:
1607
+ fixed[c] = col_w[c]
1608
+ self._widths_clamped = sorted(fixed)
1609
+ col_w = [fixed[c] if c in fixed else self.widths[c] / tot * rest
1610
+ for c in range(cols)]
1611
+ else:
1612
+ k = max(w / r for w, r in zip(col_w, self.widths)) # every column fits its content
1613
+ col_w = [r * k for r in self.widths]
1614
+ natural_w = sum(col_w) + gap * (len(used) - 1) + 2 * margin
1615
+ self.natural_width = natural_w
1616
+ # v0.13.0: what the natural width is made of (CF-EXP-001 width budget)
1617
+ self._wbudget = {"col_w": list(col_w), "col_nat": list(nat_cols), "gap": gap,
1618
+ "margin": margin, "used": list(used), "needs": dict(needs),
1619
+ "place": dict(place)}
1620
+ s = 1.0
1621
+ if self.target_width is not None:
1622
+ if natural_w <= self.target_width + 1e-6:
1623
+ extra = (self.target_width - natural_w) / max(1, len(used))
1624
+ for c in used:
1625
+ col_w[c] += extra
1626
+ else:
1627
+ s = self.target_width / natural_w
1628
+ self.global_scale = s
1629
+ title_h = self._title_h()
1630
+ top = title_h if (self.title and self.title_pos == "top") else 0.0
1631
+ inner_w = sum(col_w) + gap * (len(used) - 1)
1632
+ col_x = []
1633
+ x = margin
1634
+ for c in range(cols):
1635
+ col_x.append(x)
1636
+ if c in used:
1637
+ x += col_w[c] + gap
1638
+ row_y = [margin + top + sum(row_h[:r]) + gap * r for r in range(rows)]
1639
+ for k in self._order:
1640
+ r, c, rs, cs = place[k]
1641
+ w = sum(col_w[c:c + cs]) + gap * (cs - 1)
1642
+ h = sum(row_h[r:r + rs]) + gap * (rs - 1)
1643
+ if self.equal_heights is False:
1644
+ h = min(h, needs[k][1])
1645
+ self._panels[k].layout(col_x[c], row_y[r], w, h)
1646
+ self._content_w = inner_w + 2 * margin
1647
+ self._content_h = sum(row_h) + gap * (rows - 1) + 2 * margin + title_h
1648
+ self._grid_geom = (col_x, col_w, row_y, row_h, gap, used)
1649
+ self.width = self._content_w * s
1650
+ self.height = self._content_h * s
1651
+ self._layout_report = dict(natural_size=(self.width, self.height),
1652
+ final_size=(self.width, self.height),
1653
+ target_aspect=self.target_aspect,
1654
+ gap_scale_used=1.0, scale_used=1.0)
1655
+ target = self.width / self.target_aspect if self.target_aspect else self.target_height
1656
+ if self.max_height is not None:
1657
+ target = min(target, self.max_height) if target is not None else self.max_height
1658
+ self._height_offset = 0.0
1659
+ if target is not None and self.fit != "none":
1660
+ if self.height > target and self.fit == "scale":
1661
+ factor = target / self.height
1662
+ smallest = min(self.theme.font_sizes or (self.theme.font_size,)) * self.global_scale * factor
1663
+ if smallest < self.min_font:
1664
+ raise LayoutError(f"required scale {factor:.1%}; minimum font {smallest:.2f} pt is below min_font={self.min_font}")
1665
+ self.global_scale *= factor
1666
+ self.height = target
1667
+ self._layout_report["scale_used"] = factor
1668
+ elif self.height < target and self.fit == "pad" and self.max_height is None:
1669
+ self._height_offset = (target - self.height) / (2 * self.global_scale)
1670
+ self.height = target
1671
+ self._layout_report["final_size"] = (self.width, self.height)
1672
+
1673
+ def layout_report(self) -> dict:
1674
+ """Return a copy of natural and final physical layout diagnostics."""
1675
+ self.layout()
1676
+ return dict(self._layout_report)
1677
+
1678
+ def _draw_dividers(self, buf: List[str]) -> None:
1679
+ """``panel_dividers=``: a rule centred in each gap between neighbouring panels,
1680
+ as long as the panels on both sides of it."""
1681
+ if not self.panel_dividers or getattr(self, "_grid_geom", None) is None:
1682
+ return
1683
+ from .render_svg import draw_line
1684
+
1685
+ th = self.theme
1686
+ da = {"solid": None, "dashed": "3,2", "dotted": "1,1.5"}[self.panel_dividers]
1687
+ place, _rows = self._placements()
1688
+ col_x, col_w, row_y, row_h, gap, used = self._grid_geom
1689
+ sw = max(th.stroke_width * 0.75, 0.5)
1690
+ for k in self._order:
1691
+ r, c, rs, cs = place[k]
1692
+ g = self._panels[k].geom
1693
+ right = [o for o in self._order if place[o][1] == c + cs and
1694
+ place[o][0] < r + rs and r < place[o][0] + place[o][2]]
1695
+ if right:
1696
+ x = g.x + g.w + gap / 2
1697
+ y0 = max([g.y] + [self._panels[o].geom.y for o in right])
1698
+ y1 = min([g.y + g.h] + [self._panels[o].geom.y + self._panels[o].geom.h for o in right])
1699
+ if y1 > y0:
1700
+ draw_line(buf, x, y0, x, y1, stroke=th.muted_color, stroke_width=sw, dasharray=da)
1701
+ below = [o for o in self._order if place[o][0] == r + rs and
1702
+ place[o][1] < c + cs and c < place[o][1] + place[o][3]]
1703
+ if below:
1704
+ y = g.y + g.h + gap / 2
1705
+ x0 = max([g.x] + [self._panels[o].geom.x for o in below])
1706
+ x1 = min([g.x + g.w] + [self._panels[o].geom.x + self._panels[o].geom.w for o in below])
1707
+ if x1 > x0:
1708
+ draw_line(buf, x0, y, x1, y, stroke=th.muted_color, stroke_width=sw, dasharray=da)
1709
+
1710
+ def check_overlaps(self) -> list:
1711
+ """Lay out (if needed) and return every remaining overlap, all panels.
1712
+ Each item names the two nodes, the panel and the penetration in px."""
1713
+ import warnings
1714
+
1715
+ from .overlaps import OverlapWarning
1716
+
1717
+ with warnings.catch_warnings():
1718
+ warnings.simplefilter("ignore", OverlapWarning)
1719
+ self.layout()
1720
+ out = []
1721
+ with self._ctx():
1722
+ for letter in self._order:
1723
+ out.extend(self._panels[letter].check_overlaps())
1724
+ return out
1725
+
1726
+ def to_svg(self, *, alt: Optional[str] = None) -> str:
1727
+ """SVG text. Physical figures carry ``width="…pt"`` (1 user unit = 1 pt)."""
1728
+ with self._ctx():
1729
+ return self._to_svg_impl(alt=alt)
1730
+
1731
+ def _to_svg_impl(self, alt: Optional[str] = None) -> str:
1732
+ self.layout()
1733
+ th = self.theme
1734
+ buf = svg_header(self.width, self.height, th, units="pt" if self.physical else "",
1735
+ title=alt)
1736
+ if self.physical:
1737
+ if self.global_scale < 0.9999:
1738
+ buf.append(f'<g transform="scale({self.global_scale:.5f})">')
1739
+ if getattr(self, "_height_offset", 0):
1740
+ buf.append(f'<g transform="translate(0,{self._height_offset:.5f})">')
1741
+ if self.title:
1742
+ tw, _ = measure_text(self.title, th.title_size, weight="bold")
1743
+ cw, ch = self._content_w, self._content_h
1744
+ ty = (1.0 + th.title_size) if self.title_pos == "top" else ch - 1.0 - th.title_size * 0.3
1745
+ draw_text(buf, self.title, (cw - tw) / 2, ty, font_size=th.title_size,
1746
+ weight="bold")
1747
+ self._draw_dividers(buf)
1748
+ for letter in self._order:
1749
+ self._panels[letter].draw(buf)
1750
+ if getattr(self, "_height_offset", 0):
1751
+ buf.append("</g>")
1752
+ if self.global_scale < 0.9999:
1753
+ buf.append("</g>")
1754
+ buf.extend(svg_footer())
1755
+ return "\n".join(buf)
1756
+ if self.title:
1757
+ tw, _ = measure_text(self.title, th.title_size)
1758
+ ty = th.title_size + 8 if self.title_pos == "top" else self.height - th.title_size * 0.7
1759
+ draw_text(
1760
+ buf,
1761
+ self.title,
1762
+ (self.width - tw) / 2,
1763
+ ty,
1764
+ font_size=th.title_size,
1765
+ weight="bold",
1766
+ )
1767
+ for letter in self._order:
1768
+ self._panels[letter].draw(buf)
1769
+ buf.extend(svg_footer())
1770
+ return "\n".join(buf)
1771
+
1772
+ def save(
1773
+ self,
1774
+ path: Union[str, Path],
1775
+ *,
1776
+ scale: Optional[float] = None,
1777
+ dpi: Optional[float] = None,
1778
+ lint: Optional[str] = "auto",
1779
+ alt: Optional[str] = None,
1780
+ venue: Optional[str] = None,
1781
+ ) -> Path:
1782
+ """Save as ``.svg``, ``.png`` or ``.pdf``.
1783
+
1784
+ Args:
1785
+ scale: PNG pixel density for px figures (2 → 2×).
1786
+ dpi: PNG resolution for physical (pt) figures (default 300).
1787
+ lint: ``"error"`` refuses to write when :meth:`lint` finds errors (the
1788
+ LintError's last line summarises each error with its fix and the preview
1789
+ path), ``"warn"`` writes anyway, emits a :class:`~codefig.lint.LintWarning`
1790
+ per finding and writes ``<stem>.lint_preview.png`` when there are errors
1791
+ (the iteration mode; see also :meth:`preview`),
1792
+ ``None`` / ``"off"`` skips it; ``"auto"`` (default) = ``"error"`` for
1793
+ physical figures, off for legacy px figures.
1794
+ alt: Alt text: written as the SVG ``<title>`` and to ``<path>.alt.txt``.
1795
+ venue: Venue for the save-time lint (default: the figure's venue).
1796
+ """
1797
+ from .export import save_figure
1798
+
1799
+ return save_figure(self, path, scale=scale, dpi=dpi, lint=lint, alt=alt, venue=venue)
1800
+
1801
+ def preview(
1802
+ self,
1803
+ path: Union[str, Path] = "fig.png",
1804
+ *,
1805
+ alt: Optional[str] = None,
1806
+ venue: Optional[str] = None,
1807
+ scale: Optional[float] = None,
1808
+ dpi: Optional[float] = None,
1809
+ ) -> "Any":
1810
+ """Iteration helper: lint, **always** write the figure, never raise.
1811
+
1812
+ Writes ``path`` (``.png`` / ``.svg`` / ``.pdf``) even when lint finds errors, plus
1813
+ ``<stem>.lint_preview.png`` with every error / warning outlined when there are any
1814
+ (a stale preview is removed when the figure is clean). Returns the
1815
+ :class:`~codefig.lint.LintReport` (``report.image`` / ``report.preview`` are the
1816
+ paths), so ``print(fig.preview("fig.png"))`` shows every finding with its fix.
1817
+ Use it while iterating; make the final call ``fig.save(path, alt=...)``, which
1818
+ refuses on errors.
1819
+ """
1820
+ from .export import save_figure, write_lint_preview
1821
+
1822
+ path = Path(path)
1823
+ rep = self.lint(venue=venue, alt=alt)
1824
+ save_figure(self, path, scale=scale, dpi=dpi, lint=None, alt=alt, venue=venue)
1825
+ rep.image = str(path)
1826
+ if any(f.severity in ("error", "warn") for f in rep.findings):
1827
+ prev = write_lint_preview(self, rep, path, alt)
1828
+ rep.preview = str(prev) if prev is not None else None
1829
+ return rep
1830
+
1831
+ def lint(
1832
+ self,
1833
+ venue: Optional[str] = None,
1834
+ width: Union[str, float, None] = None,
1835
+ scale: float = 1.0,
1836
+ ignore: Optional[Sequence[str]] = None,
1837
+ thresholds: Optional[Dict[str, Dict[str, float]]] = None,
1838
+ *,
1839
+ pdf: Optional[Union[str, Path]] = None,
1840
+ alt: Optional[str] = None,
1841
+ ) -> "Any":
1842
+ """Check the figure against the publication style rules → :class:`~codefig.lint.LintReport`.
1843
+
1844
+ ``venue`` / ``width`` default to the figure's own; ``scale`` is the LaTeX
1845
+ ``\\includegraphics`` scale (e.g. 0.8). See ``docs/lint-rules.md``.
1846
+ """
1847
+ from .lint import lint_figure
1848
+
1849
+ return lint_figure(self, venue=venue, width=width, scale=scale, ignore=ignore,
1850
+ thresholds=thresholds, pdf=pdf, alt=alt)
1851
+
1852
+
1853
+ def _seg_hits_rect(ax: float, ay: float, bx: float, by: float,
1854
+ rx: float, ry: float, rw: float, rh: float, inset: float = 1.0) -> bool:
1855
+ """Axis-aligned segment passes through the (inset) rectangle interior."""
1856
+ x0, x1 = sorted((ax, bx))
1857
+ y0, y1 = sorted((ay, by))
1858
+ return (x0 < rx + rw - inset and x1 > rx + inset and
1859
+ y0 < ry + rh - inset and y1 > ry + inset)
1860
+
1861
+
1862
+ class _Reorder:
1863
+ """Move one child of a container to index ``to`` (undoable)."""
1864
+
1865
+ def __init__(self, cont: Any, unit: Any, to: int, where: str) -> None:
1866
+ self.cont, self.unit, self.to, self.where = cont, unit, to, where
1867
+ self._old: Optional[list] = None
1868
+
1869
+ def apply(self) -> None:
1870
+ kids = self.cont._kids
1871
+ self._old = list(kids)
1872
+ kids.remove(self.unit)
1873
+ kids.insert(self.to, self.unit)
1874
+ self.cont.invalidate()
1875
+
1876
+ def undo(self) -> None:
1877
+ if self._old is not None:
1878
+ self.cont._kids[:] = self._old
1879
+ self._old = None
1880
+ self.cont.invalidate()
1881
+
1882
+
1883
+ def _reorder_moves(root: Any, region: Any, intruder: Any) -> list:
1884
+ """Candidate sibling reorders that take ``intruder`` out of ``region``'s span."""
1885
+ from .node import Container
1886
+
1887
+ parent: dict = {}
1888
+ for n in walk(root):
1889
+ for k in n.children():
1890
+ parent[id(k)] = n
1891
+
1892
+ def chain(n: Any) -> list:
1893
+ out = [n]
1894
+ while id(out[-1]) in parent:
1895
+ out.append(parent[id(out[-1])])
1896
+ return out
1897
+
1898
+ members = list(region.members)
1899
+ if not members:
1900
+ return []
1901
+ ichain = chain(intruder)
1902
+ mchains = [chain(m) for m in members]
1903
+ for depth in range(1, len(ichain)):
1904
+ cont = ichain[depth]
1905
+ if not all(any(a is cont for a in mc) for mc in mchains):
1906
+ continue
1907
+ if not isinstance(cont, Container) or not hasattr(cont, "_kids"):
1908
+ return []
1909
+ unit = ichain[depth - 1]
1910
+ kids = list(cont._kids)
1911
+ mpos = []
1912
+ for mc in mchains:
1913
+ i = next(j for j, a in enumerate(mc) if a is cont)
1914
+ mpos.append(kids.index(mc[i - 1]) if i > 0 else -1)
1915
+ if -1 in mpos or any(kids[p] is unit for p in mpos):
1916
+ return [] # a member *is* the container, or shares the intruder's unit
1917
+ cur = kids.index(unit)
1918
+ lo, hi = min(mpos), max(mpos)
1919
+ if not lo < cur < hi:
1920
+ return []
1921
+ moves = [_Reorder(cont, unit, lo, "before"), _Reorder(cont, unit, hi, "after")]
1922
+ return moves
1923
+ return []