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/__init__.py ADDED
@@ -0,0 +1,210 @@
1
+ """codefig — Python-first code-to-diagram library for research figures."""
2
+
3
+ from .constraints import (
4
+ Anchor,
5
+ Constraint,
6
+ ConstraintConflictError,
7
+ ConstraintWarning,
8
+ above,
9
+ align,
10
+ allow_overlap,
11
+ below,
12
+ distribute,
13
+ left_of,
14
+ nudge,
15
+ place,
16
+ right_of,
17
+ same_height,
18
+ same_width,
19
+ align_columns,
20
+ align_rows,
21
+ same_x,
22
+ same_y,
23
+ solve_constraints,
24
+ )
25
+ from .edges import Diagram, Edge, Fork, Guide, Join, Port
26
+ from .legend import Hatch, Key, Legend
27
+ from .policy import POLICY_TABLE, ROLES, role_of, use_policy
28
+ from .overlaps import Overlap, OverlapError, OverlapWarning
29
+ from .figure import Figure, Panel, LayoutError
30
+ from .node import Col, Grid, Row, Stack
31
+ from .primitives import (
32
+ Arrow,
33
+ Box,
34
+ Brace,
35
+ Callout,
36
+ CircleHighlight,
37
+ DotGrid,
38
+ Ellipsis,
39
+ Group,
40
+ LoopArrow,
41
+ Matrix,
42
+ Spacer,
43
+ Strip,
44
+ Table,
45
+ Text,
46
+ TokenStrip,
47
+ )
48
+ from .glyphs import Badge, BarChart, Divider, Swatch, VectorFan
49
+ from . import icons
50
+ from .icon import Icon, Image
51
+ from .fills import Gradient, Split
52
+ from .shapes import (BlockArrow, ChatBubble, Circle, Cylinder, Diamond, Document, Ellipse,
53
+ Hexagon, Op, OpCircle, Parallelogram, Polygon, Shape, Trapezoid, Triangle)
54
+ from .compose import Repeat, Series, Stacked
55
+ from .solids import Cuboid, Pyramid, SlabStack
56
+ from .charts import GroupedBarChart, LineChart, Scale, Sparkline, StackedBarChart
57
+ from .regions import Region
58
+ from .flows import WrapFlow
59
+ from .tree import Tree
60
+ from .styles import (CLASSIC_THEME, DEFAULT_THEME, PAPER_THEME, THEMES, Head, Theme, shade, tint,
61
+ current_theme, get_theme, register_theme, set_theme, use_theme)
62
+ from .venues import VENUES, Venue, get_venue
63
+ from .lint import RULES, Finding, LintError, LintReport, LintWarning, lint_config
64
+ from .steps import Step, StepCounter
65
+ from .code import CODE_LANGS, CODE_THEMES, CodeBlock, tokenize
66
+ from .dim import Dim
67
+ from .timeline import Lane, LaneBand, Lanes, Marker, Task, TimeSpan, Timeline, TimelineWarning
68
+
69
+ __version__ = "0.17.0"
70
+
71
+ __all__ = [
72
+ "Figure",
73
+ "Panel",
74
+ "Box",
75
+ "Text",
76
+ "Arrow",
77
+ "LoopArrow",
78
+ "Matrix",
79
+ "Stack",
80
+ "Row",
81
+ "Col",
82
+ "Grid",
83
+ "Ellipsis",
84
+ "DotGrid",
85
+ "Strip",
86
+ "Group",
87
+ "Callout",
88
+ "CircleHighlight",
89
+ "Brace",
90
+ "Spacer",
91
+ "Badge",
92
+ "Divider",
93
+ "BarChart",
94
+ "VectorFan",
95
+ "Swatch",
96
+ "Port",
97
+ "Edge",
98
+ "Guide",
99
+ # v0.3.0
100
+ "Fork",
101
+ "Join",
102
+ "Head",
103
+ "Legend",
104
+ "Key",
105
+ "Hatch",
106
+ "TokenStrip",
107
+ "Table",
108
+ "ROLES",
109
+ "POLICY_TABLE",
110
+ "role_of",
111
+ "use_policy",
112
+ "Diagram",
113
+ "Theme",
114
+ "DEFAULT_THEME",
115
+ # v0.6.0
116
+ "PAPER_THEME",
117
+ "CLASSIC_THEME",
118
+ "THEMES",
119
+ "current_theme",
120
+ "get_theme",
121
+ "register_theme",
122
+ "set_theme",
123
+ "tint",
124
+ "shade",
125
+ "Cuboid",
126
+ "SlabStack",
127
+ "Pyramid",
128
+ "StackedBarChart",
129
+ "GroupedBarChart",
130
+ "Scale",
131
+ "LineChart",
132
+ "Sparkline",
133
+ "Region",
134
+ "WrapFlow",
135
+ "use_theme",
136
+ "VENUES",
137
+ "Venue",
138
+ "get_venue",
139
+ "RULES",
140
+ "Finding",
141
+ "LintError",
142
+ "LintReport",
143
+ "LintWarning",
144
+ "lint_config",
145
+ # v0.4.0
146
+ "icons",
147
+ "Icon",
148
+ "Image",
149
+ "Gradient",
150
+ "Split",
151
+ "Shape",
152
+ "Ellipse",
153
+ "Circle",
154
+ "OpCircle",
155
+ "Cylinder",
156
+ "Document",
157
+ "Trapezoid",
158
+ "Triangle",
159
+ "Polygon",
160
+ "Op",
161
+ "Parallelogram",
162
+ "Hexagon",
163
+ "Diamond",
164
+ "ChatBubble",
165
+ "BlockArrow",
166
+ "Stacked",
167
+ "Repeat",
168
+ "Series",
169
+ "Tree",
170
+ # v0.5.0
171
+ "Timeline",
172
+ "Task",
173
+ "TimeSpan",
174
+ "Marker",
175
+ "LaneBand",
176
+ "TimelineWarning",
177
+ "Lanes",
178
+ "Lane",
179
+ "CodeBlock",
180
+ "CODE_LANGS",
181
+ "CODE_THEMES",
182
+ "tokenize",
183
+ "Step",
184
+ "StepCounter",
185
+ "Dim",
186
+ # constraints (v0.1.8; distribute / overlaps v0.1.9)
187
+ "Anchor",
188
+ "Constraint",
189
+ "ConstraintConflictError",
190
+ "ConstraintWarning",
191
+ "align",
192
+ "same_x",
193
+ "same_y",
194
+ "same_width",
195
+ "align_columns",
196
+ "align_rows",
197
+ "same_height",
198
+ "below",
199
+ "above",
200
+ "right_of",
201
+ "left_of",
202
+ "place",
203
+ "nudge",
204
+ "solve_constraints",
205
+ "distribute",
206
+ "allow_overlap",
207
+ "Overlap",
208
+ "OverlapError",
209
+ "OverlapWarning",
210
+ ]
codefig/_validate.py ADDED
@@ -0,0 +1,12 @@
1
+ """Shared enum diagnostics."""
2
+ from difflib import get_close_matches
3
+
4
+
5
+ def check_enum(name, value, valid, aliases=None):
6
+ """Resolve aliases or raise an actionable enumeration error."""
7
+ value = (aliases or {}).get(value, value)
8
+ if value not in valid:
9
+ close = get_close_matches(str(value), valid, n=1, cutoff=0.3)
10
+ hint = f"; did you mean {close[0]!r}?" if close else ""
11
+ raise ValueError(f"{name}={value!r}: not one of {list(valid)!r}{hint}")
12
+ return value
codefig/autowrap.py ADDED
@@ -0,0 +1,277 @@
1
+ """v0.14.0: wrap long labels at word boundaries before a figure shrinks to fit.
2
+
3
+ When a physical figure's natural width exceeds the venue slot, everything would be
4
+ scaled down uniformly (and text may fall below the 6 pt floor). Before that happens
5
+ :func:`auto_wrap` breaks the widest single-line labels *that actually set the width*
6
+ (Box / Text labels on the critical path of Rows, the widest member of a Col, edge
7
+ labels that stick out of their Diagram, panel titles wider than their content) into
8
+ two balanced lines, re-lays the figure out and keeps a wrap only when the figure got
9
+ narrower. It stops as soon as the content fits; whatever is left shrinks as before.
10
+
11
+ Opt out with ``Figure(auto_wrap=False)``; a label that already contains ``"\\n"`` is
12
+ never touched, and ``auto_wrap=False`` on any node (``Box``, ``Text`` …) or ``Edge`` pins one label.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Callable, List, Optional, Tuple
18
+
19
+ from .textutil import measure_text
20
+
21
+ MAX_ROUNDS = 6
22
+
23
+
24
+ def split_two(text: str, width_of: Callable[[str], float]) -> Optional[str]:
25
+ """Best two-line split of ``text`` at a space outside markup (``**…**``, ``*…*``,
26
+ ``[…]``, ``{…}``, ``$…$``, backticks), minimising the wider line; ``None`` if the
27
+ text has no usable break or already has a line break."""
28
+ if not text or "\n" in text or " " not in text.strip():
29
+ return None
30
+ breaks: List[int] = []
31
+ depth = 0
32
+ toggles = {"$": False, "`": False}
33
+ bold = italic = False
34
+ i = 0
35
+ s = text
36
+ while i < len(s):
37
+ ch = s[i]
38
+ if ch == "\\":
39
+ i += 2
40
+ continue
41
+ if ch in "[{(":
42
+ depth += 1
43
+ elif ch in "]})":
44
+ depth = max(0, depth - 1)
45
+ elif ch in toggles:
46
+ toggles[ch] = not toggles[ch]
47
+ elif ch == "*":
48
+ if s.startswith("**", i):
49
+ bold = not bold
50
+ i += 2
51
+ continue
52
+ italic = not italic
53
+ elif ch == " " and depth == 0 and not any(toggles.values()) and not bold and not italic:
54
+ if s[:i].strip() and s[i + 1:].strip():
55
+ breaks.append(i)
56
+ i += 1
57
+ if not breaks:
58
+ return None
59
+ best = None
60
+ for b in breaks:
61
+ a, c = s[:b].rstrip(), s[b + 1:].lstrip()
62
+ w = max(width_of(a), width_of(c))
63
+ if best is None or w < best[0] - 1e-6:
64
+ best = (w, a + "\n" + c)
65
+ return best[1] if best else None
66
+
67
+
68
+ def _children(n: Any) -> List[Any]:
69
+ try:
70
+ return [k for k in n.children() if not getattr(k, "_cf_guide", False)]
71
+ except Exception: # pragma: no cover
72
+ return []
73
+
74
+
75
+ def _critical(n: Any, out: List[Any]) -> None:
76
+ """Nodes whose width can set ``n``'s width: every member of a Row (or Grid), the
77
+ widest member(s) of anything else."""
78
+ from .node import Container, Grid
79
+
80
+ out.append(n)
81
+ kids = _children(n)
82
+ if not kids:
83
+ return
84
+ if isinstance(n, Grid) or (isinstance(n, Container) and getattr(n, "direction", "") == "row"):
85
+ for k in kids:
86
+ _critical(k, out)
87
+ return
88
+ ws = [(k.measure()[0], k) for k in kids]
89
+ m = max(w for w, _ in ws)
90
+ for w, k in ws:
91
+ if w >= m - 1.0:
92
+ _critical(k, out)
93
+
94
+
95
+ class _Cand:
96
+ __slots__ = ("kind", "obj", "attr", "old", "new", "gain", "name")
97
+
98
+ def __init__(self, kind: str, obj: Any, attr: str, old: str, new: str, gain: float, name: str):
99
+ self.kind, self.obj, self.attr = kind, obj, attr
100
+ self.old, self.new, self.gain, self.name = old, new, gain, name
101
+
102
+ def apply(self) -> None:
103
+ setattr(self.obj, self.attr, self.new)
104
+
105
+ def revert(self) -> None:
106
+ setattr(self.obj, self.attr, self.old)
107
+
108
+
109
+ def _label_node_cands(n: Any, out: List[_Cand], done: set) -> None:
110
+ from .constraints import node_name
111
+ from .primitives import Box, Text
112
+
113
+ if id(n) in done or getattr(n, "auto_wrap", True) is False:
114
+ return
115
+ if isinstance(n, Box):
116
+ if n.fixed_w is not None or getattr(n, "label_rotate", 0) or not isinstance(n.text, str):
117
+ return
118
+ fs, mono, weight = n._fs(), n.mono, n.weight
119
+ elif isinstance(n, Text):
120
+ if n.rotate or not isinstance(n.text, str):
121
+ return
122
+ fs, mono, weight = n._fs(), n.mono, n.weight
123
+ else:
124
+ return
125
+ if n.text.count(" ") > 12: # paragraphs are the author's business
126
+ return
127
+
128
+ def wof(t: str) -> float:
129
+ return measure_text(t, fs, mono=mono, weight=weight)[0]
130
+
131
+ new = split_two(n.text, wof)
132
+ if new is None:
133
+ return
134
+ gain = wof(n.text) - max(wof(x) for x in new.split("\n"))
135
+ if gain > 1.0:
136
+ out.append(_Cand("label", n, "text", n.text, new, gain, node_name(n)))
137
+
138
+
139
+ def _edge_cands(d: Any, out: List[_Cand], done: set) -> None:
140
+ from .overlaps import edge_name
141
+
142
+ try:
143
+ l, _t, r, _b = d._margins()
144
+ except Exception: # pragma: no cover
145
+ return
146
+ if l + r < 2.0:
147
+ return
148
+ th = d.theme
149
+ for e in getattr(d, "edges", []) or []:
150
+ lab = getattr(e, "label", None)
151
+ if not isinstance(lab, str) or id(e) in done or getattr(e, "auto_wrap", True) is False:
152
+ continue
153
+ try:
154
+ fs = e._label_fs(th)
155
+ except Exception: # pragma: no cover
156
+ fs = th.font_size * 0.9
157
+
158
+ def wof(t: str, fs: float = fs) -> float:
159
+ return measure_text(t, fs)[0]
160
+
161
+ new = split_two(lab, wof)
162
+ if new is None:
163
+ continue
164
+ gain = min(l + r, wof(lab) - max(wof(x) for x in new.split("\n")))
165
+ if gain > 1.0:
166
+ out.append(_Cand("edge", e, "label", lab, new, gain, edge_name(e)))
167
+
168
+
169
+ def _candidates(fig: Any, done: set) -> List[_Cand]:
170
+ from .edges import Diagram
171
+
172
+ out: List[_Cand] = []
173
+ for k in fig._order:
174
+ p = fig._panels[k]
175
+ if p.content is None:
176
+ continue
177
+ wp = getattr(p, "_wparts", None) or {}
178
+ if p.title and "\n" not in str(p.title) and wp.get("caption", 0) > wp.get("content", 0) + 1.0 \
179
+ and id(p) not in done and not p._no_caption():
180
+ fs = fig.theme.panel_title_size
181
+ pre = f"({p.letter}) " if p.letters else ""
182
+
183
+ def wof(t: str, fs: float = fs) -> float:
184
+ return measure_text(t, fs, weight="bold")[0]
185
+
186
+ new = split_two(pre + str(p.title), wof)
187
+ if new is not None and (not pre or new.startswith(pre.rstrip() + " ") or new.startswith(pre)):
188
+ new_t = new[len(pre):] if pre and new.startswith(pre) else None
189
+ if new_t:
190
+ gain = min(wp["caption"] - wp["content"],
191
+ wof(pre + str(p.title)) - max(wof(x) for x in new.split("\n")))
192
+ if gain > 1.0:
193
+ out.append(_Cand("title", p, "title", p.title, new_t, gain, f"panel ({k}) title"))
194
+ crit: List[Any] = []
195
+ _critical(p.content, crit)
196
+ for n in crit:
197
+ if isinstance(n, Diagram):
198
+ _edge_cands(n, out, done)
199
+ else:
200
+ _label_node_cands(n, out, done)
201
+ out.sort(key=lambda c: -c.gain)
202
+ return out
203
+
204
+
205
+ def auto_wrap(fig: Any, relayout: Callable[[], None]) -> List[Tuple[str, str, str]]:
206
+ """Greedy wrap pass (see module docstring). Returns ``[(name, old, new)]`` kept."""
207
+ from .constraints import invalidate_tree
208
+
209
+ kept: List[Tuple[str, str, str]] = []
210
+ done: set = set()
211
+ tw = fig.target_width
212
+ for _round in range(MAX_ROUNDS):
213
+ nat = float(getattr(fig, "natural_width", 0.0) or 0.0)
214
+ if tw is None or nat <= tw + 0.05:
215
+ break
216
+ over = nat - tw
217
+ with fig._ctx():
218
+ cands = _candidates(fig, done)
219
+ if not cands:
220
+ break
221
+ pick: List[_Cand] = []
222
+ acc = 0.0
223
+ for c in cands:
224
+ pick.append(c)
225
+ acc += c.gain
226
+ if acc >= over * 1.1:
227
+ break
228
+ for c in pick:
229
+ c.apply()
230
+ done.add(id(c.obj))
231
+ for p in fig._panels.values():
232
+ if p.content is not None:
233
+ invalidate_tree(p.content)
234
+ p.__dict__.pop("_sub_cache", None)
235
+ relayout()
236
+ nat2 = float(getattr(fig, "natural_width", 0.0) or 0.0)
237
+ if nat2 < nat - 0.5:
238
+ kept.extend((c.name, c.old, c.new) for c in pick)
239
+ continue
240
+ for c in pick: # no gain (another member sets the width): undo
241
+ c.revert()
242
+ for p in fig._panels.values():
243
+ if p.content is not None:
244
+ invalidate_tree(p.content)
245
+ relayout()
246
+ return kept
247
+
248
+
249
+ def width_setters(fig: Any, limit: int = 4) -> List[str]:
250
+ """Labels on the critical width path that could still be shortened or wrapped
251
+ (for the CF-EXP-001 hint), widest first."""
252
+ from .constraints import node_name
253
+ from .primitives import Box, Text
254
+
255
+ rows: List[Tuple[float, str]] = []
256
+ try:
257
+ with fig._ctx():
258
+ for k in fig._order:
259
+ p = fig._panels[k]
260
+ if p.content is None:
261
+ continue
262
+ crit: List[Any] = []
263
+ _critical(p.content, crit)
264
+ seen = set()
265
+ for n in crit:
266
+ if id(n) in seen or not isinstance(n, (Box, Text)) or not isinstance(n.text, str):
267
+ continue
268
+ seen.add(id(n))
269
+ w = n.geom.w if getattr(n, "geom", None) is not None else n.measure()[0]
270
+ if w < 18 or getattr(n, "fixed_w", None) is not None or len(n.text.strip()) < 4:
271
+ continue # a fixed size / tiny label is not what sets the width
272
+ lab = n.text.replace("\n", "⏎")
273
+ rows.append((w, f"{node_name(n)} '{lab[:30]}' {w:.0f} pt"))
274
+ except Exception: # pragma: no cover - diagnostics must never break lint
275
+ return []
276
+ rows.sort(key=lambda r: -r[0])
277
+ return [r[1] for r in rows[:limit]]