figkit 0.1.0__tar.gz
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.
- figkit-0.1.0/AI_MANUAL.md +572 -0
- figkit-0.1.0/CHANGELOG.md +74 -0
- figkit-0.1.0/CONTRIBUTING.md +49 -0
- figkit-0.1.0/LICENSE +21 -0
- figkit-0.1.0/MANIFEST.in +6 -0
- figkit-0.1.0/PKG-INFO +277 -0
- figkit-0.1.0/README.md +232 -0
- figkit-0.1.0/examples/00_quickstart.py +23 -0
- figkit-0.1.0/examples/01_pipeline.py +176 -0
- figkit-0.1.0/examples/02_attribution.py +224 -0
- figkit-0.1.0/examples/03_data_and_plots.py +91 -0
- figkit-0.1.0/examples/04_themes.py +55 -0
- figkit-0.1.0/examples/05_rich_text_and_components.py +82 -0
- figkit-0.1.0/figkit/__init__.py +114 -0
- figkit-0.1.0/figkit/audit.py +611 -0
- figkit-0.1.0/figkit/colors.py +304 -0
- figkit-0.1.0/figkit/component.py +110 -0
- figkit-0.1.0/figkit/components.py +727 -0
- figkit-0.1.0/figkit/connectors.py +689 -0
- figkit-0.1.0/figkit/core.py +1021 -0
- figkit-0.1.0/figkit/export.py +301 -0
- figkit-0.1.0/figkit/figure.py +312 -0
- figkit-0.1.0/figkit/fonts.py +462 -0
- figkit-0.1.0/figkit/frame.py +376 -0
- figkit-0.1.0/figkit/geom.py +497 -0
- figkit-0.1.0/figkit/image.py +308 -0
- figkit-0.1.0/figkit/layout.py +529 -0
- figkit-0.1.0/figkit/mathtext.py +320 -0
- figkit-0.1.0/figkit/paint.py +136 -0
- figkit-0.1.0/figkit/py.typed +0 -0
- figkit-0.1.0/figkit/shapes.py +764 -0
- figkit-0.1.0/figkit/style.py +502 -0
- figkit-0.1.0/figkit/svgdoc.py +133 -0
- figkit-0.1.0/figkit/svgpath.py +470 -0
- figkit-0.1.0/figkit/text.py +625 -0
- figkit-0.1.0/figkit/themes.py +163 -0
- figkit-0.1.0/figkit.egg-info/PKG-INFO +277 -0
- figkit-0.1.0/figkit.egg-info/SOURCES.txt +54 -0
- figkit-0.1.0/figkit.egg-info/dependency_links.txt +1 -0
- figkit-0.1.0/figkit.egg-info/requires.txt +19 -0
- figkit-0.1.0/figkit.egg-info/top_level.txt +1 -0
- figkit-0.1.0/pyproject.toml +59 -0
- figkit-0.1.0/setup.cfg +4 -0
- figkit-0.1.0/tests/test_audit.py +283 -0
- figkit-0.1.0/tests/test_colors.py +52 -0
- figkit-0.1.0/tests/test_composition.py +194 -0
- figkit-0.1.0/tests/test_connectors.py +204 -0
- figkit-0.1.0/tests/test_core.py +215 -0
- figkit-0.1.0/tests/test_examples.py +64 -0
- figkit-0.1.0/tests/test_export.py +96 -0
- figkit-0.1.0/tests/test_figure_and_frame.py +239 -0
- figkit-0.1.0/tests/test_geom.py +81 -0
- figkit-0.1.0/tests/test_layout.py +161 -0
- figkit-0.1.0/tests/test_style.py +123 -0
- figkit-0.1.0/tests/test_svgpath_and_math.py +109 -0
- figkit-0.1.0/tests/test_text_and_shapes.py +323 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# figkit — agent manual
|
|
2
|
+
|
|
3
|
+
figkit builds **publication-quality figures from Python code** and exports them
|
|
4
|
+
to SVG / PNG / PDF / HTML. It targets the diagrams in ML papers and research
|
|
5
|
+
blog posts: pipelines, boxes-and-arrows, annotated matrices, LaTeX labels,
|
|
6
|
+
data-driven graphics.
|
|
7
|
+
|
|
8
|
+
Use it when the user wants a *diagram, figure, schematic or pipeline drawing*
|
|
9
|
+
that should be reproducible and editable as code. It is not a plotting library
|
|
10
|
+
(though it can draw plots) and not a general canvas API.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from figkit import *
|
|
14
|
+
|
|
15
|
+
with Figure(pad=24) as fig: # auto-collects everything below
|
|
16
|
+
a = Box("Encoder", style="block", w=120)
|
|
17
|
+
b = Box("Decoder", style="blue").right_of(a, gap=60)
|
|
18
|
+
arrow(a.e, b.w, label="$z$")
|
|
19
|
+
|
|
20
|
+
print(fig.audit()) # ← check before you export
|
|
21
|
+
fig.save("figure.svg")
|
|
22
|
+
fig.save("figure.png", scale=2) # 2x pixel density
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Always run `fig.audit()` before you finish.** It catches, without rendering
|
|
26
|
+
anything, the mistakes you would otherwise only see by looking at the picture:
|
|
27
|
+
overlapping elements, labels sticking out of their boxes, unreadable colour
|
|
28
|
+
combinations, arrows through boxes they do not connect. It prints one line per
|
|
29
|
+
problem with coordinates, and `no issues` when the figure is clean.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. Mental model — read this before writing code
|
|
34
|
+
|
|
35
|
+
* **Units are px. `y` grows downward** (SVG convention): `n` is the top edge,
|
|
36
|
+
`s` the bottom.
|
|
37
|
+
* **Nothing is auto-laid-out.** You place things; figkit measures them
|
|
38
|
+
accurately (real font metrics from the font file) so relative placement is
|
|
39
|
+
exact.
|
|
40
|
+
* **Anchors are live references.** `box.e` is not a coordinate — it resolves
|
|
41
|
+
when read. Move the box afterwards and every arrow pointing at it follows.
|
|
42
|
+
* **Every placement call returns `self`**, so it chains:
|
|
43
|
+
`Box("x").right_of(a, gap=20).align_to(b, "top")`.
|
|
44
|
+
* **Sizes are intrinsic unless you pin them.** `Box("hello")` sizes to its
|
|
45
|
+
text plus padding; `Box("hello", w=200)` pins the width and wraps the text.
|
|
46
|
+
* **Paint order = child order** (later draws on top). `z=` sorts within a
|
|
47
|
+
parent; `to_front()` / `to_back()` reorder.
|
|
48
|
+
* **A figure auto-sizes to its contents.** Giving both `w` and `h` makes it a
|
|
49
|
+
fixed canvas whose origin is `(0, 0)`, so coordinates mean what they say;
|
|
50
|
+
pass `origin=` to move it or `origin="content"` for the auto-sized rule.
|
|
51
|
+
* **Create the `Figure` first**, then the elements. Elements resolve their
|
|
52
|
+
theme through their parent, so `with Figure(theme=T) as fig:` makes `T`
|
|
53
|
+
apply to everything created inside. (The ambient figure and theme live in
|
|
54
|
+
`contextvars`, so concurrent threads/tasks don't interfere.)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 2. Cheat sheet
|
|
59
|
+
|
|
60
|
+
| Need | Call |
|
|
61
|
+
|---|---|
|
|
62
|
+
| canvas | `Figure(w=None, h=None, pad=24, background=None, theme=None)` |
|
|
63
|
+
| fixed canvas | `Figure(764, 620, pad=0)` — origin is `(0, 0)` |
|
|
64
|
+
| box with text | `Box("label", w=..., h=..., style="blue")` |
|
|
65
|
+
| plain text | `Text("hi", bold=True, font_size=16, align="left")` |
|
|
66
|
+
| styled spans | `Text(["ok ", Span("bad", color="@bad", strike=True)])` |
|
|
67
|
+
| math | put `$...$` anywhere in a string: `Box("$F_{\mathcal{M}}$")` |
|
|
68
|
+
| absolute place | `el.at(x, y, anchor="nw")`, `el.center_at(x, y)` |
|
|
69
|
+
| relative place | `el.right_of(other, gap=24, align="top", dx=0, dy=0)` |
|
|
70
|
+
| put inside | `el.inside(other, anchor="se", pad=8)` |
|
|
71
|
+
| anchor | `el.n .s .e .w .ne .nw .se .sw .center`, `el.at_angle(30)`, `el.uv(u, v)` |
|
|
72
|
+
| anchor on an arrow | `c.mid`, `c.anchor_at(0.25)` — live, follows the arrow |
|
|
73
|
+
| offset anchor | `el.e + (6, 0)` |
|
|
74
|
+
| arrow | `arrow(a.e, b.w, label="x")` |
|
|
75
|
+
| orthogonal | `elbow(a.e, b.w, stub=14, corner=6)` |
|
|
76
|
+
| curved | `curve(a.s, b.s, bend=0.4)` |
|
|
77
|
+
| align a group | `align([a, b, c], "top")` |
|
|
78
|
+
| even spacing | `distribute_h(items, gap=20)` / `spread_h(items, x0, x1)` |
|
|
79
|
+
| row / column | `hstack(items, gap=16, align="center")` / `vstack(...)` |
|
|
80
|
+
| formula row | `hstack([lhs, matrix, rhs], gap=8, align="baseline")` |
|
|
81
|
+
| grid | `grid(items, cols=3, gap=(20, 14))` |
|
|
82
|
+
| container box | `fit(a, b, pad=20, label="stage 1", dash=True)` |
|
|
83
|
+
| self-loop | `self_loop(state, side="top", label="retry")` |
|
|
84
|
+
| brace over a set | `brace_around([a, b, c], side="top", label="stage")` |
|
|
85
|
+
| midpoint | `between(a, b)` |
|
|
86
|
+
| bounds only | `bbox_of([a, b, c])` |
|
|
87
|
+
| data space | `fr = Frame(w=400, h=220, xlim=(0,10), ylim=(0,1))`; `fr.pt(x, y)` |
|
|
88
|
+
| **check** | `print(fig.audit())` — do this before exporting |
|
|
89
|
+
| export | `fig.save("f.svg" / "f.png" / "f.pdf" / "f.html")` |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 3. Placement
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
el.at(x, y, anchor="nw") # anchor may be any anchor name
|
|
97
|
+
el.at((x, y)) # a point/anchor works too
|
|
98
|
+
el.center_at(x, y)
|
|
99
|
+
el.move(dx, dy)
|
|
100
|
+
el.set_x(120); el.set_y(40)
|
|
101
|
+
|
|
102
|
+
el.right_of(other, gap=24, align="center") # align: top | center | bottom | None
|
|
103
|
+
el.left_of(other, gap=24, align="top")
|
|
104
|
+
el.below_of(other, gap=12, align="left") # align: left | center | right | None
|
|
105
|
+
el.above_of(other, gap=12)
|
|
106
|
+
el.next_to(other, side="right", gap=10)
|
|
107
|
+
|
|
108
|
+
el.inside(other, anchor="nw", pad=10) # place within another element
|
|
109
|
+
el.align_to(other, "center_x") # left|right|top|bottom|center_x|center_y|center
|
|
110
|
+
el.span_x(a, b, pad=8) # stretch to cover a..b horizontally
|
|
111
|
+
el.resize(w=200, h=60, anchor="center") # anchor stays put
|
|
112
|
+
el.grow(dw=10, dh=0)
|
|
113
|
+
el.rotate(-90); el.scale_by(1.5); el.flip_h()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`align=None` leaves the cross axis untouched — useful when you already set it.
|
|
117
|
+
|
|
118
|
+
**Two axes, one call each.** `right_of` sets x (and the cross axis via
|
|
119
|
+
`align`); `below_of` sets y. Chaining both re-sets *both* axes, which is
|
|
120
|
+
usually not what you want:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
box.right_of(a, gap=40).above_of(a, gap=8) # the second call moves x too!
|
|
124
|
+
box.right_of(a, gap=40, dy=-30) # do this instead
|
|
125
|
+
box.right_of(a, gap=40).center_at(None, ROW) # or pin one axis explicitly
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
For figures with rows/columns, capture the lane coordinates once and reuse
|
|
129
|
+
them — it is far more robust than long chains:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
TOP, BOT = mesh_a.bbox.cy, mesh_b.bbox.cy
|
|
133
|
+
solver.right_of(feat, gap=60).center_at(None, TOP)
|
|
134
|
+
pmap.right_of(feat, gap=60).center_at(None, BOT)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 4. Anchors
|
|
140
|
+
|
|
141
|
+
Every element exposes `n s e w ne nw se sw center` plus:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
el.at_angle(35) # point on the border along a ray from the centre (0 = east, cw)
|
|
145
|
+
el.uv(0.25, 1.0) # fractional position inside the bounding box
|
|
146
|
+
el.e + (6, -2) # offset anchor (still live)
|
|
147
|
+
el.bbox # BBox(x, y, w, h) with .x0 .x1 .cx .cy .center .corners
|
|
148
|
+
el.width / el.height / el.x / el.y
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Anchors carry an outward **normal**, which is what makes `elbow` and `curve`
|
|
152
|
+
leave a box sensibly. Passing a whole *element* as a connector endpoint
|
|
153
|
+
(`arrow(a, b)`) picks the border point facing the other end automatically.
|
|
154
|
+
|
|
155
|
+
Connectors expose live anchors along their own path, so you can hang things
|
|
156
|
+
off an arrow and they keep up when its endpoints move:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
link = arrow(a.e, b.w)
|
|
160
|
+
Box("gate").center_at(link.mid) # live: follows the arrow
|
|
161
|
+
arrow(note.s, link.anchor_at(0.25))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 5. Elements
|
|
167
|
+
|
|
168
|
+
**Shapes** — all take an optional first `text` argument and auto-size to it:
|
|
169
|
+
`Box` (`Rect`), `Pill`/`Stadium`, `Ellipse`, `Circle(r=...)`, `Diamond`,
|
|
170
|
+
`Triangle`, `Hexagon`, `Parallelogram`, `Chevron`, `Star`, `Cylinder`, `Note`,
|
|
171
|
+
`Callout(text, target=point)`.
|
|
172
|
+
|
|
173
|
+
Common kwargs: `w, h, min_w, min_h, max_w, padding, wrap, align, valign,
|
|
174
|
+
radius, fill, stroke, stroke_width, stroke_dash, opacity, shadow, rotate, z,
|
|
175
|
+
name, style, theme`.
|
|
176
|
+
|
|
177
|
+
**Geometry** — `Line(a, b)`, `Polyline(points)`, `Polygon(points)`,
|
|
178
|
+
`Path("M0 0 L10 10 …")`, `Dot(center, r)`, `Marker(center, size, "diamond")`.
|
|
179
|
+
`Line`/`Polyline`/`Polygon` accept **live anchors** as points, so they track
|
|
180
|
+
whatever they were built from.
|
|
181
|
+
|
|
182
|
+
**Content** — `Text`, `Label`, `Image(path_or_bytes, w=...)`.
|
|
183
|
+
|
|
184
|
+
**Composites** — `Matrix(values, cell=16, cmap="viridis")`,
|
|
185
|
+
`LabelledMatrix(values, row_label=…, col_label=…, caption=…, brackets="round")`,
|
|
186
|
+
`Vector(values, orient="v")`, `ColorBar`, `Table(rows, header=True)`,
|
|
187
|
+
`Legend([(label, colour), …])`, `Brace(a, b, depth=12, label=...)`,
|
|
188
|
+
`Bracket`, `Panel(targets, pad, label)`, `Spacer(w, h)`, `Group(*children)`.
|
|
189
|
+
|
|
190
|
+
`Matrix` builds real cell elements: `m.cell(i, j)` is a `Box` you can anchor
|
|
191
|
+
to (`arrow(x.e, m.cell(0, 2).w)`) or restyle (`m.highlight(1, 1, stroke="red")`).
|
|
192
|
+
Paint kwargs passed to `Matrix(...)` style the **cells**.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 6. Text and LaTeX
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
Text("two\nlines", align="center", valign="center")
|
|
200
|
+
Text("wrapped prose …", wrap=260, line_height=1.4)
|
|
201
|
+
Text("plain **bold** and *italic*", markup=True) # opt-in markdown-lite
|
|
202
|
+
Box("$C_{\\mathcal{MN}} = \\phi^{\\dagger}\\Pi\\phi$") # inline math anywhere
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Spans** style part of a line. Pass a list wherever text is accepted —
|
|
206
|
+
including a `Box` label:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
Text(["accuracy ", Span("76.1", strike=True, color="@muted"), " → ",
|
|
210
|
+
Span("94.6", color="@good", bold=True)])
|
|
211
|
+
Box(["status: ", Span("FAILED", color="#fff", bold=True)], fill="@bad")
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`Span(text, color=, bold=, italic=, weight=, style=, size=, family=,
|
|
215
|
+
strike=, underline=)` — anything left unset is inherited. Strike-through and
|
|
216
|
+
underline are drawn as geometry, so they survive rasterising and outlining
|
|
217
|
+
(`text-decoration` does not).
|
|
218
|
+
|
|
219
|
+
* `$...$` spans are typeset with matplotlib's mathtext and emitted as **vector
|
|
220
|
+
outlines**, so exported files never depend on installed fonts. Escape a
|
|
221
|
+
literal dollar as `\$`.
|
|
222
|
+
* Use raw strings or double backslashes for TeX.
|
|
223
|
+
* `set_math_fontset("cm" | "stix" | "dejavusans")` changes the math font.
|
|
224
|
+
* Real LaTeX (`\begin{aligned}`, custom packages) is available when `latex` and
|
|
225
|
+
`dvisvgm` are installed: `Text("...", math_backend="latex")`, configured via
|
|
226
|
+
`set_latex_preamble(...)`. Check with `latex_available()`.
|
|
227
|
+
* `measure_text("hi", "sans-serif", 14)` returns the width in px if you need to
|
|
228
|
+
size something yourself. Measurement uses glyph advances (no kerning), so it
|
|
229
|
+
is accurate to roughly ±1%.
|
|
230
|
+
|
|
231
|
+
Text inside a `Box` is centred **optically** (on the cap-height band), which is
|
|
232
|
+
what looks right for short labels.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 7. Connectors
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
arrow(a.e, b.w) # straight
|
|
240
|
+
line(a.e, b.w) # no head
|
|
241
|
+
elbow(a.e, b.w, stub=14, corner=6) # orthogonal -| routing
|
|
242
|
+
curve(a.s, b.s, bend=0.4) # smooth
|
|
243
|
+
connect(a, b, route="arc") # straight | elbow | curve | arc
|
|
244
|
+
double_arrow(a.e, b.w)
|
|
245
|
+
arrow(a.e, b.w, waypoints=[(120, 40)], corner=8) # explicit routing
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Key kwargs: `head`/`tail` (`triangle`, `stealth`, `open`, `circle`, `diamond`,
|
|
249
|
+
`square`, `bar`, `cross`, `none`), `head_size`, `gap`/`start_gap`/`end_gap`
|
|
250
|
+
(pull the ends back), `start_side`/`end_side` (force an edge),
|
|
251
|
+
`label`, `label_pos` (0..1 along the path), `label_offset`, `label_side`
|
|
252
|
+
(`auto`/`above`/`below`/`center`), `label_rotate` (align the label to the
|
|
253
|
+
path), `label_bg`, plus any paint property (`stroke`, `stroke_width`,
|
|
254
|
+
`stroke_dash`, `opacity`).
|
|
255
|
+
|
|
256
|
+
On connectors and other line-like elements (`Line`, `Polyline`, `Path`,
|
|
257
|
+
`Brace`) there is no geometric width, so **`width=` means stroke width** —
|
|
258
|
+
`curve(src.s, dst.n, opacity=w, width=0.3 + 2 * w)` does what it looks like.
|
|
259
|
+
|
|
260
|
+
`bend` deepens the bow **along the anchors' facing direction** — `curve(a.s,
|
|
261
|
+
b.s, bend=0.5)` dips below both boxes. For plain points there is no normal to
|
|
262
|
+
follow so it bows sideways. `bow=` always pushes sideways (positive = left of
|
|
263
|
+
travel).
|
|
264
|
+
|
|
265
|
+
`self_loop(element, side="top", size=36, label="retry")` draws an arrow that
|
|
266
|
+
leaves an element and returns to it — the staple of state machines.
|
|
267
|
+
|
|
268
|
+
Useful readouts: `c.point_at(t)`, `c.direction_at(t)`, `c.mid`, `c.length`.
|
|
269
|
+
|
|
270
|
+
Data-driven edges are just a loop:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
for src, dst, weight in edges:
|
|
274
|
+
curve(src.s, dst.n, opacity=0.1 + 0.8 * weight,
|
|
275
|
+
stroke_width=0.3 + 2 * weight, head="none")
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## 8. Reusable components
|
|
281
|
+
|
|
282
|
+
A function returning a `Group` is a fine component. Subclass `Component` when
|
|
283
|
+
callers should be able to point at named parts of it:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
class Stage(Component):
|
|
287
|
+
def build(self, title, w=150): # group kwargs never reach build()
|
|
288
|
+
body = Box(title, w=w, style="block")
|
|
289
|
+
self.expose("body", body)
|
|
290
|
+
self.expose("out", Dot(body.e, r=4).center)
|
|
291
|
+
return [body]
|
|
292
|
+
|
|
293
|
+
a = Stage("Encode")
|
|
294
|
+
b = Stage("Solve", name="solver").right_of(a, gap=60)
|
|
295
|
+
arrow(a.out, b.body.w) # exposed anchors are live
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`expose(name, anchor_or_element_or_callable)`; read them as attributes or via
|
|
299
|
+
`component.anchor(name)`. A `Component` is an ordinary `Group`, so it places,
|
|
300
|
+
moves and audits like anything else.
|
|
301
|
+
|
|
302
|
+
## 9. Layout helpers
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
align([a, b, c], "top", to=None) # top|bottom|left|right|center_x|center_y|center
|
|
306
|
+
distribute_h(items, gap=20, start=None) # sequential, fixed gap
|
|
307
|
+
spread_h(items, x0, x1, mode="edges") # even gaps across a span ("centers" also)
|
|
308
|
+
hstack(items, gap=16, align="center") # -> Group
|
|
309
|
+
vstack(items, gap=12, align="left") # -> Group
|
|
310
|
+
grid(items, cols=3, gap=(20, 14), align="nw")
|
|
311
|
+
fit(a, b, pad=20, label="stage", label_pos="nw") # -> Group; .panel is the box
|
|
312
|
+
frame_around(items, pad=12) # just the tracking box
|
|
313
|
+
brace_around(items, side="top", gap=8, label="stage")
|
|
314
|
+
same_width(items); same_height(items); same_size(items)
|
|
315
|
+
center_on(el, target); between(a, b, t=0.5); shift(items, dx, dy)
|
|
316
|
+
circular(items, center=(0,0), radius=140)
|
|
317
|
+
bbox_of([a, b, c]) # read-only union
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`fit()` returns a `Group` containing a `Panel` (drawn behind, `z=-1000`) plus
|
|
321
|
+
the items, so the whole cluster moves as one. The panel keeps tracking its
|
|
322
|
+
targets, so it re-fits if they move later.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## 10. Styling and themes
|
|
327
|
+
|
|
328
|
+
Resolution order for any property, innermost first:
|
|
329
|
+
|
|
330
|
+
1. keyword on the element — `Box("x", fill="red")`
|
|
331
|
+
2. the element's `style=` when given a `Style`/dict
|
|
332
|
+
3. its **classes**, last one winning — `Box("x", classes="block accent")`
|
|
333
|
+
4. inherited text properties from enclosing groups (font, colour, alignment)
|
|
334
|
+
5. themes on the element / its ancestors: **role override**, then **base token**
|
|
335
|
+
6. the default theme, then a hard-coded fallback
|
|
336
|
+
|
|
337
|
+
**Classes** are named styles defined by the theme, applied CSS-style:
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
Box("solver", classes="stage accent") # or style="stage accent"
|
|
341
|
+
box.add_class("highlight"); box.remove_class("accent")
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
They resolve *lazily* against the live theme chain, so the same class can mean
|
|
345
|
+
different things inside a differently themed group. An unknown class is a
|
|
346
|
+
warning at render time, not an error. `.name` with a leading dot also works.
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
T = PAPER.derive( # or Theme(...), or DEFAULT_THEME.derive(...)
|
|
350
|
+
font_size=13, radius=4, # base tokens: apply to every role
|
|
351
|
+
palette={"brand": "#3B6EA5"}, # referenced as "@brand"
|
|
352
|
+
box=Style(fill="#fff", stroke="#222", padding=(9, 13)), # role overrides
|
|
353
|
+
arrow=Style(stroke="#222", head_size=8),
|
|
354
|
+
styles={"solver": Style(fill="@brand", stroke="#1b1b1b")}, # classes
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
with Figure(theme=T) as fig:
|
|
358
|
+
Box("FMap Solver", style="solver") # named style
|
|
359
|
+
Box("plain", fill="@brand") # palette token
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Roles: `box`, `text`, `label`, `ellipse`, `path`, `line`, `arrow`, `panel`,
|
|
363
|
+
`group`, `image`, `matrix`, `brace`, `axis`, `grid`, `marker`.
|
|
364
|
+
|
|
365
|
+
Property aliases are accepted everywhere: `bg`/`background` → `fill`,
|
|
366
|
+
`border`/`border_color` → `stroke`, `lw`/`border_width` → `stroke_width`,
|
|
367
|
+
`dash` → `stroke_dash`, `corner_radius` → `radius`, `text_color`/`fg` →
|
|
368
|
+
`color`, `font`/`family` → `font_family`, `align` → `text_align`,
|
|
369
|
+
`alpha` → `opacity`. Flags: `bold=True`, `italic=True`, `monospace=True`.
|
|
370
|
+
|
|
371
|
+
Dash presets: `"solid" "dashed" "dotted" "dashdot"`, or `[6, 4]`, or `True`.
|
|
372
|
+
|
|
373
|
+
Built-in themes: `DEFAULT_THEME`, `PAPER`, `SLIDE`, `DARK`, `BLUEPRINT`,
|
|
374
|
+
`MINIMAL`, `SOFT` — also `get_theme("paper")`. Built-in named styles present in
|
|
375
|
+
every theme: `block`, `blue`, `green`, `warm`, `slate`, `ghost`, `plain`,
|
|
376
|
+
`card`.
|
|
377
|
+
|
|
378
|
+
Extras: `shadow=True` or `shadow={"dy": 2, "blur": 6, "opacity": 0.15}` (an
|
|
379
|
+
SVG filter — see gotcha 11);
|
|
380
|
+
gradients via `fill={"type": "linear", "stops": ["#fff", "#333"], "angle": 90}`.
|
|
381
|
+
|
|
382
|
+
Colour helpers: `mix, lighten, darken, alpha, saturate, contrast_color,
|
|
383
|
+
colormap("viridis", t), palette("figkit", n), to_hex`.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## 11. Data-driven graphics
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
fr = Frame(w=430, h=240, xlim=(0, 50), ylim=(0.35, 1.0)) # a Group + a mapping
|
|
391
|
+
fr.at(0, 60) # move it like any element
|
|
392
|
+
|
|
393
|
+
fr.pt(x, y) # data -> world Point; fr.px(x) / fr.py(y) for one axis
|
|
394
|
+
fr.data(px, py) # world -> data
|
|
395
|
+
|
|
396
|
+
fr.gridlines(n=6)
|
|
397
|
+
fr.xaxis(n=6, title="epoch")
|
|
398
|
+
fr.yaxis(n=5, title="accuracy", fmt=lambda v: f"{v:.0%}")
|
|
399
|
+
fr.axes(xlabel="x", ylabel="y", grid=True) # both at once
|
|
400
|
+
|
|
401
|
+
fr.line(xs, ys, stroke="@primary", lw=2, smooth=False)
|
|
402
|
+
fr.scatter(xs, ys, size=7, values=weights, cmap="viridis")
|
|
403
|
+
fr.bars(xs, heights, width=0.7)
|
|
404
|
+
fr.area_fill(xs, ys, base=0)
|
|
405
|
+
fr.region(x0, x1, y0, y1, fill="#4C72B0", fill_opacity=0.1)
|
|
406
|
+
fr.hline(0.5); fr.vline(10)
|
|
407
|
+
fr.text("warm-up", 4, 0.96)
|
|
408
|
+
fr.at_data(some_box, x=6, y=0.8, anchor="s")
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Everything returned is an ordinary element, so you can annotate it:
|
|
412
|
+
`arrow(note.se, Dot(fr.pt(50, 0.94)).nw)`. Also `xscale="log"`,
|
|
413
|
+
`autoscale(xs, ys)`, `nice_ticks(lo, hi, n)`, and `clip_data=True` to clip
|
|
414
|
+
marks to the plot area.
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## 12. Images
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
Image("logo.svg", w=80) # SVGs are inlined as vectors (ids namespaced)
|
|
422
|
+
Image("plot.png", w=200) # rasters are base64-embedded; aspect preserved
|
|
423
|
+
Image(raw_bytes, mime="image/png")
|
|
424
|
+
Image("photo.jpg", w=200, h=120, fit="cover") # contain | cover | fill
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Pass only `w` **or** `h` to keep the aspect ratio. `im.natural_size` gives the
|
|
428
|
+
source dimensions.
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 13. Export
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
fig.save("f.svg") # native, self-contained
|
|
436
|
+
fig.save("f.png", scale=2) # or dpi=300
|
|
437
|
+
fig.save("f.pdf")
|
|
438
|
+
fig.save("f.html") # standalone responsive page
|
|
439
|
+
fig.to_svg(text_as_paths=True) # outline text: no font dependency
|
|
440
|
+
fig.to_svg(embed_fonts=True) # or embed the font as base64 @font-face
|
|
441
|
+
svg_string = fig.to_svg()
|
|
442
|
+
available_backends() # what this machine can rasterise with
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
PNG/PDF need a converter: `pip install "figkit[export]"` (cairosvg), or
|
|
446
|
+
`rsvg-convert` / `resvg` / `inkscape` / headless chromium on `PATH`.
|
|
447
|
+
PNG and PDF outline text automatically, so they always match the SVG.
|
|
448
|
+
|
|
449
|
+
Install: `pip install figkit` · `figkit[latex]` (math) · `figkit[export]`
|
|
450
|
+
(PNG/PDF) · `figkit[all]`.
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## 14. Checking your work: `fig.audit()`
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
report = fig.audit()
|
|
458
|
+
print(report) # one line per problem, or "no issues"
|
|
459
|
+
if report: ... # falsy when the figure is clean
|
|
460
|
+
report.raise_if_any() # turn it into an assertion, for tests
|
|
461
|
+
report.by_kind("overlap") # the raw findings, for programmatic use
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Each finding has `.kind`, `.message`, `.severity` (`error`/`warning`),
|
|
465
|
+
`.where` (a point to look at) and `.elements`. The checks:
|
|
466
|
+
|
|
467
|
+
| kind | what it means |
|
|
468
|
+
|---|---|
|
|
469
|
+
| `overlap` | two elements collide, or one is painted over something it hides |
|
|
470
|
+
| `overflow` | a label sticks out of the shape it belongs to |
|
|
471
|
+
| `contrast` | text is too close in luminance to what is behind it |
|
|
472
|
+
| `crossing` | a connector passes through an element it does not connect |
|
|
473
|
+
| `degenerate` | a zero-size shape or a zero-length arrow |
|
|
474
|
+
| `offscreen` | content outside a pinned canvas (auto-sized figures cannot have any) |
|
|
475
|
+
|
|
476
|
+
**It is built to stay quiet about deliberate overlap**, so a clean report is
|
|
477
|
+
meaningful. It already knows that labels sit inside boxes, that panels sit
|
|
478
|
+
behind their contents, that arrows touch the things they connect, that
|
|
479
|
+
adjacent matrix cells share edges, and that anything with `z < 0` is a
|
|
480
|
+
backdrop. Decorative shapes drawn together under one group may overlap freely.
|
|
481
|
+
|
|
482
|
+
When it flags something you meant, say so in the code:
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
arrow(spine_bottom, spine_top).ignore_audit() # runs behind the nodes on purpose
|
|
486
|
+
Box("watermark", audit=False)
|
|
487
|
+
fig.audit(ignore=[element]) # or skip specific elements
|
|
488
|
+
fig.audit(crossing=False, contrast=False) # or switch off a whole check
|
|
489
|
+
fig.audit(overlap="all") # stricter: every partial overlap
|
|
490
|
+
fig.audit(min_contrast=4.5) # WCAG AA for body text
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## 15. Gotchas
|
|
494
|
+
|
|
495
|
+
1. **`Group` takes ownership.** `Group(a, b)` and `fit(a, b)` *reparent* their
|
|
496
|
+
children. To only read a combined bounding box, use `bbox_of([a, b])`.
|
|
497
|
+
2. **Elements auto-add inside `with Figure()`.** Helper functions that create
|
|
498
|
+
elements also add them, including ones you then wrap in a `Group` — that is
|
|
499
|
+
fine (the group steals them), but a group created with `add=False` inside a
|
|
500
|
+
figure is *not* drawn.
|
|
501
|
+
3. **Nested groups paint as a unit.** A filled `Panel` inside a group drawn
|
|
502
|
+
later covers everything painted earlier, even elements with lower `z`.
|
|
503
|
+
`z` only sorts within one parent.
|
|
504
|
+
4. **Connectors are not in your groups by default.** If you move a cluster by
|
|
505
|
+
grouping it, include the arrows: `fit(row, *wires, pad=18)`.
|
|
506
|
+
5. **Don't `resize()` a `Group`** unless you want to *scale* it — it applies a
|
|
507
|
+
transform and stretches text. Size the children instead.
|
|
508
|
+
6. **Chaining `right_of().below_of()` sets both axes twice.** Use `dx`/`dy`, or
|
|
509
|
+
pin one axis with `center_at(None, y)`.
|
|
510
|
+
7. **`fit`/`Panel` keep tracking.** They re-fit when their targets move, so
|
|
511
|
+
place the contents before you rely on the panel's own bbox.
|
|
512
|
+
8. **Very light `Matrix` cells vanish on white.** Pass `stroke="#333"` (paint
|
|
513
|
+
kwargs on `Matrix` go to the cells).
|
|
514
|
+
9. **Set the theme on the `Figure`,** not after creating elements — sizes are
|
|
515
|
+
measured with the theme's font.
|
|
516
|
+
10. **8-digit hex and `rgba()` work** and are split into `fill` +
|
|
517
|
+
`fill-opacity` on output, so rasterisers handle them correctly.
|
|
518
|
+
11. **A viewer without your font can make words collide** in plain SVG:
|
|
519
|
+
positions are absolute, so a wider substitute overflows them. Export with
|
|
520
|
+
`embed_fonts=True`, or `text_as_paths=True` (what PNG/PDF already do).
|
|
521
|
+
12. **`shadow=` is an SVG filter, and cairosvg ignores filters.** It shows in
|
|
522
|
+
SVG and HTML but not in a cairosvg-rendered PNG/PDF; figkit warns when
|
|
523
|
+
that happens. Use `rsvg-convert`/`resvg`/chromium, or skip shadows for
|
|
524
|
+
figures headed to PNG.
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
528
|
+
## 16. Worked pattern
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
from figkit import *
|
|
532
|
+
|
|
533
|
+
T = PAPER.derive(font_size=13,
|
|
534
|
+
styles={"stage": Style(fill="#eef3f9", stroke="#3B6EA5")})
|
|
535
|
+
|
|
536
|
+
with Figure(theme=T, pad=26, background="#ffffff") as fig:
|
|
537
|
+
# 1. lay out the backbone, capturing lane coordinates
|
|
538
|
+
inp = Box("input\n$x$", style="block", w=110)
|
|
539
|
+
ROW = inp.bbox.cy
|
|
540
|
+
|
|
541
|
+
enc = Box("Encoder", style="stage", w=150).right_of(inp, gap=54)
|
|
542
|
+
lat = Vector([0.9, 0.3, 0.6, 0.1], cell=(44, 12), cmap="grays",
|
|
543
|
+
stroke="#333", stroke_width=0.7).right_of(enc, gap=40)
|
|
544
|
+
lat.center_at(None, ROW)
|
|
545
|
+
z_lab = Text("$z$", font_size=14).below_of(lat, gap=8)
|
|
546
|
+
|
|
547
|
+
dec = Box("Decoder", style="stage", w=150).right_of(lat, gap=40)
|
|
548
|
+
out = Box("output\n$\\hat{x}$", style="block", w=110).right_of(dec, gap=54)
|
|
549
|
+
|
|
550
|
+
# 2. wire it up — anchors stay glued to the boxes
|
|
551
|
+
arrow(inp.e, enc.w); arrow(enc.e, lat.w)
|
|
552
|
+
arrow(lat.e, dec.w); arrow(dec.e, out.w)
|
|
553
|
+
|
|
554
|
+
# 3. group and annotate
|
|
555
|
+
body = fit(enc, lat, z_lab, dec, pad=20, label="autoencoder",
|
|
556
|
+
label_pos="below", style="ghost")
|
|
557
|
+
loss = curve(out.s, inp.s, bend=0.45, label="reconstruction loss",
|
|
558
|
+
label_side="below", stroke="@accent", stroke_dash="dashed")
|
|
559
|
+
|
|
560
|
+
Text("Figure 1: the model.", font_size=11, color="@muted", align="left") \
|
|
561
|
+
.below_of(loss, gap=18).align_to(inp, "left")
|
|
562
|
+
|
|
563
|
+
print(fig.audit()) # catches overlaps, overflow, unreadable text
|
|
564
|
+
fig.save("figure.svg")
|
|
565
|
+
fig.save("figure.png", scale=2)
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
**Workflow:** build, then `print(fig.audit())` after each structural change.
|
|
569
|
+
A clean report means no element is covering another, no label has escaped its
|
|
570
|
+
box and no text is unreadable — the mistakes that are obvious in a picture and
|
|
571
|
+
invisible in the code. Export and look at the PNG for the things the audit
|
|
572
|
+
cannot judge: whether the composition actually reads well.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to figkit are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and figkit uses
|
|
5
|
+
[semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — unreleased
|
|
8
|
+
|
|
9
|
+
First public release.
|
|
10
|
+
|
|
11
|
+
### Layout and geometry
|
|
12
|
+
|
|
13
|
+
- Elements with live anchors (`box.e` re-resolves on read, so arrows follow
|
|
14
|
+
the things they connect), exact bounding boxes, and chainable relative
|
|
15
|
+
placement: `at`, `right_of`, `left_of`, `above_of`, `below_of`, `inside`,
|
|
16
|
+
`align_to`, `span_x`, `resize`, `rotate`, `scale_by`.
|
|
17
|
+
- Batch arrangement: `align`, `distribute_h/v`, `spread_h/v`, `hstack`,
|
|
18
|
+
`vstack`, `grid`, `fit`, `circular`, `same_size`, `brace_around`.
|
|
19
|
+
- `hstack(align="baseline")` sets mixed text and matrices like an equation.
|
|
20
|
+
- Groups that own their children and report live bounds, with `z`-ordering.
|
|
21
|
+
|
|
22
|
+
### Text
|
|
23
|
+
|
|
24
|
+
- Text measured from real glyph advances via fontTools, with font resolution
|
|
25
|
+
through registered fonts, the usual system directories and `fc-match`, and a
|
|
26
|
+
fallback to the PostScript core-font metrics.
|
|
27
|
+
- Multi-line text, word wrapping, optical (cap-height) centring inside shapes.
|
|
28
|
+
- Inline `$math$` anywhere, typeset to vector outlines through matplotlib's
|
|
29
|
+
mathtext, or a real `latex` + `dvisvgm` toolchain when one is installed.
|
|
30
|
+
- `Span` for per-word colour, weight, style, size, family, strike-through and
|
|
31
|
+
underline. Decorations are drawn as geometry, so they survive rasterising
|
|
32
|
+
and outlining.
|
|
33
|
+
|
|
34
|
+
### Drawing
|
|
35
|
+
|
|
36
|
+
- Shapes: `Box`, `Pill`, `Ellipse`, `Circle`, `Diamond`, `Triangle`,
|
|
37
|
+
`Hexagon`, `Parallelogram`, `Chevron`, `Star`, `Cylinder`, `Note`,
|
|
38
|
+
`Callout`; geometry primitives `Line`, `Polyline`, `Polygon`, `Path`,
|
|
39
|
+
`Dot`, `Marker`.
|
|
40
|
+
- Connectors: straight, orthogonal (`elbow`), curved, arcs, explicit
|
|
41
|
+
waypoints, nine arrow-head shapes, path labels, and `self_loop`.
|
|
42
|
+
- Composites: `Matrix`, `LabelledMatrix`, `Vector`, `ColorBar`, `Table`,
|
|
43
|
+
`Legend`, `Brace`, `Bracket`, `Panel`.
|
|
44
|
+
- `Component` for reusable units that publish named anchors.
|
|
45
|
+
- `Image` embeds rasters as data URIs and inlines SVGs as vectors, rewriting
|
|
46
|
+
internal ids so repeated copies never collide.
|
|
47
|
+
|
|
48
|
+
### Style
|
|
49
|
+
|
|
50
|
+
- A cascading theme: base tokens, per-role overrides, CSS-style classes and a
|
|
51
|
+
colour palette, resolved kwargs → style → classes → inherited → theme →
|
|
52
|
+
default.
|
|
53
|
+
- Seven built-in themes: default, `PAPER`, `SLIDE`, `DARK`, `BLUEPRINT`,
|
|
54
|
+
`MINIMAL`, `SOFT`.
|
|
55
|
+
- Colour helpers: `mix`, `lighten`, `darken`, `alpha`, `saturate`,
|
|
56
|
+
`contrast_color`, `colormap`, `palette`.
|
|
57
|
+
|
|
58
|
+
### Data
|
|
59
|
+
|
|
60
|
+
- `Frame` maps a data domain onto figure coordinates; `line`, `scatter`,
|
|
61
|
+
`bars`, `area_fill`, `region`, `axes`, `gridlines`, log scales and
|
|
62
|
+
`nice_ticks`. Every mark is an ordinary element you can anchor to.
|
|
63
|
+
|
|
64
|
+
### Checking and output
|
|
65
|
+
|
|
66
|
+
- `fig.audit()` reports overlapping elements, labels escaping their shapes,
|
|
67
|
+
unreadable colour combinations, connectors crossing unrelated elements,
|
|
68
|
+
degenerate geometry and content outside a pinned canvas — and is built to
|
|
69
|
+
stay quiet about deliberate overlap, so a clean report means something.
|
|
70
|
+
- Export to SVG and HTML with no dependencies; PNG and PDF through cairosvg,
|
|
71
|
+
`rsvg-convert`, `resvg`, `inkscape` or headless Chromium. Raster and PDF
|
|
72
|
+
output outlines text so it cannot depend on the renderer's fonts.
|
|
73
|
+
- `AI_MANUAL.md`, a system-prompt-sized guide for driving figkit from an
|
|
74
|
+
agent.
|