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 +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
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]]
|