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/tree.py ADDED
@@ -0,0 +1,277 @@
1
+ """Tidy trees (v0.4.0): ``Tree(root, child, Tree(sub, leaf, leaf), …)``.
2
+
3
+ Nodes are any primitives (Box, Circle, Icon, Image, Group …). The layout is a
4
+ Reingold–Tilford style tidy tree: subtrees are packed as close as their *contours*
5
+ allow (not their bounding boxes), parents are centred over their children, and each
6
+ depth level gets one row (``direction="down"``) or column (``"right"``, also "up" /
7
+ "left"). Edges are real :class:`~codefig.edges.Fork` buses (``edges="ortho"``, the
8
+ TikZ / draw.io tree look) or straight :class:`~codefig.edges.Edge` lines between the
9
+ true outlines (``edges="straight"``), or none. A Tree is a :class:`Diagram`, so it nests
10
+ anywhere and other edges can reach its nodes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
16
+
17
+ from .edges import Diagram, Edge, Fork
18
+ from .node import Node
19
+
20
+ _DIRS = ("down", "up", "right", "left")
21
+
22
+
23
+ class _Spec:
24
+ __slots__ = ("node", "kids", "edge_kw")
25
+
26
+ def __init__(self, node: Node, kids: List["_Spec"], edge_kw: Optional[Dict[str, Any]] = None):
27
+ self.node = node
28
+ self.kids = kids
29
+ self.edge_kw = edge_kw or {}
30
+
31
+
32
+ def _as_spec(item: Any) -> _Spec:
33
+ if isinstance(item, Tree):
34
+ return item._spec
35
+ if isinstance(item, (tuple, list)):
36
+ if not item:
37
+ raise ValueError("empty subtree spec")
38
+ return _Spec(item[0], [_as_spec(k) for k in item[1:]])
39
+ if isinstance(item, Node):
40
+ return _Spec(item, [])
41
+ raise TypeError(f"Tree children must be nodes, Trees or (node, *children) tuples, got {type(item).__name__}")
42
+
43
+
44
+ class _TreeLayout(Node):
45
+ """Layout-only content of a Tree: places every node; draws them (no frame)."""
46
+
47
+ def __init__(self, tree: "Tree", theme: Any = None) -> None:
48
+ super().__init__(theme=theme)
49
+ self.tree = tree
50
+ self._nodes: List[Node] = []
51
+ for s, _p in tree._walk():
52
+ self._nodes.append(s.node)
53
+ s.node.parent = self
54
+ self._pos: Dict[int, Tuple[float, float]] = {} # node id → (breadth centre, depth start)
55
+ self._sizes: Dict[int, Tuple[float, float]] = {}
56
+
57
+ def children(self) -> Sequence[Node]:
58
+ return self._nodes
59
+
60
+ def invalidate(self) -> None:
61
+ super().invalidate()
62
+ for n in self._nodes:
63
+ n.invalidate()
64
+
65
+ def _bd(self, n: Node) -> Tuple[float, float]:
66
+ w, h = n.measure()
67
+ return (w, h) if self.tree.direction in ("down", "up") else (h, w)
68
+
69
+ def _measure(self) -> Tuple[float, float]:
70
+ t = self.tree
71
+ gap, lgap = t.gap, t.level_gap
72
+ # depth thickness per level
73
+ levels: Dict[int, float] = {}
74
+
75
+ def walk(s: _Spec, d: int) -> None:
76
+ b, dd = self._bd(s.node)
77
+ levels[d] = max(levels.get(d, 0.0), dd)
78
+ for k in s.kids:
79
+ walk(k, d + 1)
80
+ walk(t._spec, 0)
81
+
82
+ offs: Dict[int, float] = {}
83
+
84
+ def lay(s: _Spec, d: int) -> Tuple[Dict[int, float], Dict[int, Tuple[float, float]]]:
85
+ b, _ = self._bd(s.node)
86
+ pos: Dict[int, float] = {id(s.node): 0.0}
87
+ cont: Dict[int, Tuple[float, float]] = {d: (-b / 2, b / 2)}
88
+ if not s.kids:
89
+ return pos, cont
90
+ merged: Dict[int, Tuple[float, float]] = {}
91
+ placed: List[Tuple[float, Dict[int, float]]] = []
92
+ for k in s.kids:
93
+ kp, kc = lay(k, d + 1)
94
+ if not merged:
95
+ o = 0.0
96
+ else:
97
+ o = max(merged[dd][1] - kc[dd][0] + gap for dd in kc if dd in merged) \
98
+ if any(dd in merged for dd in kc) else 0.0
99
+ for dd, (l, r) in kc.items():
100
+ if dd in merged:
101
+ merged[dd] = (min(merged[dd][0], l + o), max(merged[dd][1], r + o))
102
+ else:
103
+ merged[dd] = (l + o, r + o)
104
+ placed.append((o, kp))
105
+ first, last = placed[0][0], placed[-1][0]
106
+ if t.align == "first":
107
+ c = first
108
+ else:
109
+ c = (first + last) / 2
110
+ for o, kp in placed:
111
+ for nid, v in kp.items():
112
+ pos[nid] = v + o - c
113
+ for dd, (l, r) in merged.items():
114
+ cont[dd] = (l - c, r - c) if dd != d else cont[dd]
115
+ return pos, cont
116
+
117
+ pos, cont = lay(t._spec, 0)
118
+ lo = min(l for l, _ in cont.values())
119
+ hi = max(r for _, r in cont.values())
120
+ depth_start: Dict[int, float] = {}
121
+ acc = 0.0
122
+ for d in sorted(levels):
123
+ depth_start[d] = acc
124
+ acc += levels[d] + lgap
125
+ total_depth = acc - lgap
126
+
127
+ self._nodes = []
128
+ self._pos = {}
129
+
130
+ def collect(s: _Spec, d: int) -> None:
131
+ self._nodes.append(s.node)
132
+ s.node.parent = self
133
+ b, dd = self._bd(s.node)
134
+ self._pos[id(s.node)] = (pos[id(s.node)] - lo, depth_start[d] + (levels[d] - dd) / 2)
135
+ for k in s.kids:
136
+ collect(k, d + 1)
137
+ collect(t._spec, 0)
138
+ self._extent = (hi - lo, total_depth)
139
+ bw, dw = hi - lo, total_depth
140
+ return (bw, dw) if t.direction in ("down", "up") else (dw, bw)
141
+
142
+ def _layout_children(self) -> None:
143
+ g = self.geom
144
+ t = self.tree
145
+ bw, dw = self._extent
146
+ for n in self._nodes:
147
+ w, h = n.measure()
148
+ bc, ds = self._pos[id(n)]
149
+ b, dd = self._bd(n)
150
+ if t.direction == "down":
151
+ n.layout(g.x + bc - w / 2, g.y + ds, w, h)
152
+ elif t.direction == "up":
153
+ n.layout(g.x + bc - w / 2, g.y + dw - ds - h, w, h)
154
+ elif t.direction == "right":
155
+ n.layout(g.x + ds, g.y + bc - h / 2, w, h)
156
+ else:
157
+ n.layout(g.x + dw - ds - w, g.y + bc - h / 2, w, h)
158
+
159
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
160
+ for n in self._nodes:
161
+ n.draw(buf, ox, oy)
162
+
163
+
164
+ class Tree(Diagram):
165
+ """Tidy tree of arbitrary nodes.
166
+
167
+ ``Tree(root, a, Tree(b, c, d), (e, f))`` — children are nodes, nested Trees or
168
+ ``(node, *children)`` tuples. direction: down | up | right | left. gap: space
169
+ between neighbouring subtrees (contour to contour); level_gap: space between depth
170
+ levels. edges: "ortho" (one Fork bus per parent), "straight" (outline-to-outline
171
+ lines), "none". edge_style: Edge / Fork keyword dict (colour, width, style, head …;
172
+ trees default to no arrowheads). ``tree.style_edge(child, label=…, style="dashed")``
173
+ gives the edge into one child its own style / label. align: "center" (parent over the
174
+ middle of its children) or "first" (over the first child).
175
+ """
176
+
177
+ def __init__(self, root: Node, *children: Any, direction: str = "down",
178
+ gap: Optional[float] = None, level_gap: Optional[float] = None,
179
+ edges: str = "ortho", edge_style: Optional[Dict[str, Any]] = None,
180
+ align: str = "center", **kwargs: Any) -> None:
181
+ if direction not in _DIRS:
182
+ raise ValueError(f"direction must be one of {_DIRS}")
183
+ if edges not in ("ortho", "straight", "none"):
184
+ raise ValueError("edges must be ortho|straight|none")
185
+ if align not in ("center", "first"):
186
+ raise ValueError("align must be center|first")
187
+ self._spec = _Spec(root, [_as_spec(c) for c in children])
188
+ self.direction = direction
189
+ self.align = align
190
+ self.edge_mode = edges
191
+ self.edge_style = dict(edge_style or {})
192
+ theme = kwargs.get("theme")
193
+ self._gap_raw = None if gap is None else float(gap)
194
+ self._level_gap_raw = None if level_gap is None else float(level_gap)
195
+ super().__init__(_TreeLayout(self, theme), [], **kwargs)
196
+ self._rebuild_edges()
197
+
198
+ @property
199
+ def gap(self) -> float:
200
+ """Space between neighbouring subtrees (default ``theme.gap``)."""
201
+ return self._gap_raw if self._gap_raw is not None else float(self.theme.gap)
202
+
203
+ @gap.setter
204
+ def gap(self, v: Optional[float]) -> None:
205
+ self._gap_raw = None if v is None else float(v)
206
+
207
+ @property
208
+ def level_gap(self) -> float:
209
+ """Space between depth levels (default ``2.2 × theme.gap``)."""
210
+ return (self._level_gap_raw if self._level_gap_raw is not None
211
+ else float(self.theme.gap) * 2.2)
212
+
213
+ @level_gap.setter
214
+ def level_gap(self, v: Optional[float]) -> None:
215
+ self._level_gap_raw = None if v is None else float(v)
216
+
217
+ # ---- structure -------------------------------------------------------------
218
+ def _walk(self, s: Optional[_Spec] = None, parent: Optional[_Spec] = None):
219
+ s = s or self._spec
220
+ yield s, parent
221
+ for k in s.kids:
222
+ yield from self._walk(k, s)
223
+
224
+ def nodes(self) -> List[Node]:
225
+ return [s.node for s, _p in self._walk()]
226
+
227
+ def parent_of(self, node: Node) -> Optional[Node]:
228
+ for s, p in self._walk():
229
+ if s.node is node:
230
+ return p.node if p is not None else None
231
+ raise KeyError("node is not in this tree")
232
+
233
+ def style_edge(self, child: Node, **kw: Any) -> "Tree":
234
+ """Own style / label for the edge into ``child`` (e.g. ``style="dashed", label="evicted"``)."""
235
+ for s, p in self._walk():
236
+ if s.node is child:
237
+ if p is None:
238
+ raise ValueError("the root has no incoming edge")
239
+ s.edge_kw.update(kw)
240
+ self._rebuild_edges()
241
+ return self
242
+ raise KeyError("node is not in this tree")
243
+
244
+ def _sides(self) -> Tuple[str, str]:
245
+ return {"down": ("bottom", "top"), "up": ("top", "bottom"),
246
+ "right": ("right", "left"), "left": ("left", "right")}[self.direction]
247
+
248
+ def _rebuild_edges(self) -> None:
249
+ out: List[Any] = []
250
+ if self.edge_mode == "none":
251
+ self.edges = out
252
+ return
253
+ base = {"head": "none"}
254
+ base.update(self.edge_style)
255
+ ps, cs = self._sides()
256
+ for s, _p in self._walk():
257
+ if not s.kids:
258
+ continue
259
+ if self.edge_mode == "straight":
260
+ for k in s.kids:
261
+ kw = dict(base, **k.edge_kw)
262
+ out.append(Edge(s.node.port("auto"), k.node.port("auto"), route="straight",
263
+ stub=0, **kw))
264
+ continue
265
+ labels = [k.edge_kw.get("label") for k in s.kids]
266
+ styles = [{x: v for x, v in k.edge_kw.items() if x != "label"} or None
267
+ for k in s.kids]
268
+ for st_, lab in zip(styles, labels): # branch labels sit beside the branch
269
+ if lab is not None and "label_side" not in st_:
270
+ st_["label_side"] = "auto"
271
+ out.append(Fork(s.node.port(ps), [k.node.port(cs) for k in s.kids],
272
+ labels=labels if any(l is not None for l in labels) else None,
273
+ leaf_styles=styles if any(styles) else None, **base))
274
+ self.edges = out
275
+
276
+
277
+ __all__ = ["Tree"]
codefig/venues.py ADDED
@@ -0,0 +1,93 @@
1
+ """Venue presets: final printed widths (inches) and figure rules of common templates.
2
+
3
+ Numbers come from the official LaTeX templates (see ``docs/concepts/themes-venues.md``
4
+ and the research notes ``figure_style_rules.md`` §4). Widths are in inches; codefig
5
+ lays out in points (1 in = 72 pt), so ``Figure(venue="icml", width="column")`` is
6
+ 234 pt wide and every font size you see in the theme is the printed size.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, Optional, Tuple, Union
13
+
14
+ PT_PER_IN = 72.0
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Venue:
19
+ """A paper template: text / column width (inches) and figure constraints."""
20
+
21
+ name: str
22
+ text: float
23
+ column: Optional[float] = None
24
+ body_pt: float = 10.0
25
+ font_rule: str = "type1|truetype"
26
+ min_font_pt: float = 6.0
27
+ min_stroke_pt: float = 0.5
28
+ strict_stroke: bool = False
29
+ no_title: bool = False
30
+ no_gray_text_bg: bool = False
31
+ bw_legible: bool = True
32
+ alt_text: bool = False
33
+ aliases: Tuple[str, ...] = field(default=())
34
+
35
+ def width_in(self, width: Union[str, float, None] = "text") -> float:
36
+ """Width in inches for ``"text"`` / ``"column"`` / a fraction ≤ 1 of the
37
+ text width / an absolute number of inches (> 1)."""
38
+ if width is None or width == "text":
39
+ return self.text
40
+ if width == "column":
41
+ if self.column is None:
42
+ raise ValueError(f"venue {self.name!r} is single-column; use width='text' "
43
+ "or a fraction such as width=0.5")
44
+ return self.column
45
+ w = float(width)
46
+ if w <= 0:
47
+ raise ValueError("width must be positive")
48
+ return self.text * w if w <= 1.0 else w
49
+
50
+ def width_pt(self, width: Union[str, float, None] = "text") -> float:
51
+ return self.width_in(width) * PT_PER_IN
52
+
53
+
54
+ VENUES: Dict[str, Venue] = {
55
+ "neurips": Venue("neurips", 5.5, None, 10),
56
+ "iclr": Venue("iclr", 5.5, None, 10),
57
+ "icml": Venue("icml", 6.75, 3.25, 10, font_rule="type1", strict_stroke=True,
58
+ no_title=True, no_gray_text_bg=True),
59
+ "mlsys": Venue("mlsys", 6.75, 3.25, 10, font_rule="type1", strict_stroke=True,
60
+ no_title=True, no_gray_text_bg=True),
61
+ "vldb": Venue("vldb", 7.0, 3.33, 9, alt_text=True),
62
+ "sigmod": Venue("sigmod", 7.0, 3.33, 9, alt_text=True, aliases=("acm",)),
63
+ "osdi": Venue("osdi", 7.0, 3.335, 10, aliases=("usenix", "nsdi", "fast", "atc")),
64
+ "sosp": Venue("sosp", 7.0, 3.33, 10, alt_text=True),
65
+ }
66
+ _ALIASES = {a: v.name for v in VENUES.values() for a in v.aliases}
67
+
68
+
69
+ def get_venue(venue: Union[str, Venue, None]) -> Optional[Venue]:
70
+ """``"icml"`` / ``"acm"`` / ``"usenix"`` / a :class:`Venue` → Venue (None → None)."""
71
+ if venue is None or isinstance(venue, Venue):
72
+ return venue
73
+ key = str(venue).lower()
74
+ key = _ALIASES.get(key, key)
75
+ if key not in VENUES:
76
+ names = sorted(set(VENUES) | set(_ALIASES))
77
+ raise ValueError(f"unknown venue {venue!r}; choose one of {names}")
78
+ return VENUES[key]
79
+
80
+
81
+ def resolve_width_pt(venue: Union[str, Venue, None], width: Union[str, float, None]) -> Optional[float]:
82
+ """Final figure width in pt from ``venue`` + ``width`` (None if neither is given).
83
+
84
+ Without a venue, ``width`` must be a number of inches.
85
+ """
86
+ v = get_venue(venue)
87
+ if v is not None:
88
+ return v.width_pt(width)
89
+ if width is None:
90
+ return None
91
+ if isinstance(width, str):
92
+ raise ValueError(f"width={width!r} needs venue=; or pass inches, e.g. width=3.3")
93
+ return float(width) * PT_PER_IN