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.
Files changed (56) hide show
  1. figkit-0.1.0/AI_MANUAL.md +572 -0
  2. figkit-0.1.0/CHANGELOG.md +74 -0
  3. figkit-0.1.0/CONTRIBUTING.md +49 -0
  4. figkit-0.1.0/LICENSE +21 -0
  5. figkit-0.1.0/MANIFEST.in +6 -0
  6. figkit-0.1.0/PKG-INFO +277 -0
  7. figkit-0.1.0/README.md +232 -0
  8. figkit-0.1.0/examples/00_quickstart.py +23 -0
  9. figkit-0.1.0/examples/01_pipeline.py +176 -0
  10. figkit-0.1.0/examples/02_attribution.py +224 -0
  11. figkit-0.1.0/examples/03_data_and_plots.py +91 -0
  12. figkit-0.1.0/examples/04_themes.py +55 -0
  13. figkit-0.1.0/examples/05_rich_text_and_components.py +82 -0
  14. figkit-0.1.0/figkit/__init__.py +114 -0
  15. figkit-0.1.0/figkit/audit.py +611 -0
  16. figkit-0.1.0/figkit/colors.py +304 -0
  17. figkit-0.1.0/figkit/component.py +110 -0
  18. figkit-0.1.0/figkit/components.py +727 -0
  19. figkit-0.1.0/figkit/connectors.py +689 -0
  20. figkit-0.1.0/figkit/core.py +1021 -0
  21. figkit-0.1.0/figkit/export.py +301 -0
  22. figkit-0.1.0/figkit/figure.py +312 -0
  23. figkit-0.1.0/figkit/fonts.py +462 -0
  24. figkit-0.1.0/figkit/frame.py +376 -0
  25. figkit-0.1.0/figkit/geom.py +497 -0
  26. figkit-0.1.0/figkit/image.py +308 -0
  27. figkit-0.1.0/figkit/layout.py +529 -0
  28. figkit-0.1.0/figkit/mathtext.py +320 -0
  29. figkit-0.1.0/figkit/paint.py +136 -0
  30. figkit-0.1.0/figkit/py.typed +0 -0
  31. figkit-0.1.0/figkit/shapes.py +764 -0
  32. figkit-0.1.0/figkit/style.py +502 -0
  33. figkit-0.1.0/figkit/svgdoc.py +133 -0
  34. figkit-0.1.0/figkit/svgpath.py +470 -0
  35. figkit-0.1.0/figkit/text.py +625 -0
  36. figkit-0.1.0/figkit/themes.py +163 -0
  37. figkit-0.1.0/figkit.egg-info/PKG-INFO +277 -0
  38. figkit-0.1.0/figkit.egg-info/SOURCES.txt +54 -0
  39. figkit-0.1.0/figkit.egg-info/dependency_links.txt +1 -0
  40. figkit-0.1.0/figkit.egg-info/requires.txt +19 -0
  41. figkit-0.1.0/figkit.egg-info/top_level.txt +1 -0
  42. figkit-0.1.0/pyproject.toml +59 -0
  43. figkit-0.1.0/setup.cfg +4 -0
  44. figkit-0.1.0/tests/test_audit.py +283 -0
  45. figkit-0.1.0/tests/test_colors.py +52 -0
  46. figkit-0.1.0/tests/test_composition.py +194 -0
  47. figkit-0.1.0/tests/test_connectors.py +204 -0
  48. figkit-0.1.0/tests/test_core.py +215 -0
  49. figkit-0.1.0/tests/test_examples.py +64 -0
  50. figkit-0.1.0/tests/test_export.py +96 -0
  51. figkit-0.1.0/tests/test_figure_and_frame.py +239 -0
  52. figkit-0.1.0/tests/test_geom.py +81 -0
  53. figkit-0.1.0/tests/test_layout.py +161 -0
  54. figkit-0.1.0/tests/test_style.py +123 -0
  55. figkit-0.1.0/tests/test_svgpath_and_math.py +109 -0
  56. 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.