figkit 0.1.0__tar.gz → 0.2.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 (59) hide show
  1. {figkit-0.1.0 → figkit-0.2.0}/AI_MANUAL.md +120 -4
  2. figkit-0.2.0/CHANGELOG.md +181 -0
  3. {figkit-0.1.0 → figkit-0.2.0}/CONTRIBUTING.md +27 -1
  4. {figkit-0.1.0 → figkit-0.2.0}/PKG-INFO +2 -2
  5. {figkit-0.1.0 → figkit-0.2.0}/README.md +1 -1
  6. {figkit-0.1.0 → figkit-0.2.0}/examples/05_rich_text_and_components.py +2 -2
  7. {figkit-0.1.0 → figkit-0.2.0}/figkit/__init__.py +5 -5
  8. {figkit-0.1.0 → figkit-0.2.0}/figkit/audit.py +27 -14
  9. {figkit-0.1.0 → figkit-0.2.0}/figkit/components.py +19 -6
  10. {figkit-0.1.0 → figkit-0.2.0}/figkit/connectors.py +175 -35
  11. {figkit-0.1.0 → figkit-0.2.0}/figkit/core.py +20 -0
  12. {figkit-0.1.0 → figkit-0.2.0}/figkit/export.py +9 -3
  13. {figkit-0.1.0 → figkit-0.2.0}/figkit/figure.py +15 -4
  14. {figkit-0.1.0 → figkit-0.2.0}/figkit/fonts.py +79 -10
  15. {figkit-0.1.0 → figkit-0.2.0}/figkit/frame.py +43 -8
  16. {figkit-0.1.0 → figkit-0.2.0}/figkit/image.py +5 -1
  17. {figkit-0.1.0 → figkit-0.2.0}/figkit/layout.py +28 -14
  18. {figkit-0.1.0 → figkit-0.2.0}/figkit/mathtext.py +5 -5
  19. {figkit-0.1.0 → figkit-0.2.0}/figkit/shapes.py +32 -5
  20. {figkit-0.1.0 → figkit-0.2.0}/figkit/style.py +73 -5
  21. {figkit-0.1.0 → figkit-0.2.0}/figkit/text.py +90 -15
  22. {figkit-0.1.0 → figkit-0.2.0}/figkit.egg-info/PKG-INFO +2 -2
  23. {figkit-0.1.0 → figkit-0.2.0}/figkit.egg-info/SOURCES.txt +2 -0
  24. {figkit-0.1.0 → figkit-0.2.0}/pyproject.toml +6 -1
  25. figkit-0.2.0/tests/test_audit_fixes.py +220 -0
  26. {figkit-0.1.0 → figkit-0.2.0}/tests/test_connectors.py +144 -1
  27. figkit-0.2.0/tests/test_release_prep.py +104 -0
  28. {figkit-0.1.0 → figkit-0.2.0}/tests/test_style.py +55 -1
  29. {figkit-0.1.0 → figkit-0.2.0}/tests/test_text_and_shapes.py +76 -2
  30. figkit-0.1.0/CHANGELOG.md +0 -74
  31. {figkit-0.1.0 → figkit-0.2.0}/LICENSE +0 -0
  32. {figkit-0.1.0 → figkit-0.2.0}/MANIFEST.in +0 -0
  33. {figkit-0.1.0 → figkit-0.2.0}/examples/00_quickstart.py +0 -0
  34. {figkit-0.1.0 → figkit-0.2.0}/examples/01_pipeline.py +0 -0
  35. {figkit-0.1.0 → figkit-0.2.0}/examples/02_attribution.py +0 -0
  36. {figkit-0.1.0 → figkit-0.2.0}/examples/03_data_and_plots.py +0 -0
  37. {figkit-0.1.0 → figkit-0.2.0}/examples/04_themes.py +0 -0
  38. {figkit-0.1.0 → figkit-0.2.0}/figkit/colors.py +0 -0
  39. {figkit-0.1.0 → figkit-0.2.0}/figkit/component.py +0 -0
  40. {figkit-0.1.0 → figkit-0.2.0}/figkit/geom.py +0 -0
  41. {figkit-0.1.0 → figkit-0.2.0}/figkit/paint.py +0 -0
  42. {figkit-0.1.0 → figkit-0.2.0}/figkit/py.typed +0 -0
  43. {figkit-0.1.0 → figkit-0.2.0}/figkit/svgdoc.py +0 -0
  44. {figkit-0.1.0 → figkit-0.2.0}/figkit/svgpath.py +0 -0
  45. {figkit-0.1.0 → figkit-0.2.0}/figkit/themes.py +0 -0
  46. {figkit-0.1.0 → figkit-0.2.0}/figkit.egg-info/dependency_links.txt +0 -0
  47. {figkit-0.1.0 → figkit-0.2.0}/figkit.egg-info/requires.txt +0 -0
  48. {figkit-0.1.0 → figkit-0.2.0}/figkit.egg-info/top_level.txt +0 -0
  49. {figkit-0.1.0 → figkit-0.2.0}/setup.cfg +0 -0
  50. {figkit-0.1.0 → figkit-0.2.0}/tests/test_audit.py +0 -0
  51. {figkit-0.1.0 → figkit-0.2.0}/tests/test_colors.py +0 -0
  52. {figkit-0.1.0 → figkit-0.2.0}/tests/test_composition.py +0 -0
  53. {figkit-0.1.0 → figkit-0.2.0}/tests/test_core.py +0 -0
  54. {figkit-0.1.0 → figkit-0.2.0}/tests/test_examples.py +0 -0
  55. {figkit-0.1.0 → figkit-0.2.0}/tests/test_export.py +0 -0
  56. {figkit-0.1.0 → figkit-0.2.0}/tests/test_figure_and_frame.py +0 -0
  57. {figkit-0.1.0 → figkit-0.2.0}/tests/test_geom.py +0 -0
  58. {figkit-0.1.0 → figkit-0.2.0}/tests/test_layout.py +0 -0
  59. {figkit-0.1.0 → figkit-0.2.0}/tests/test_svgpath_and_math.py +0 -0
@@ -34,9 +34,12 @@ problem with coordinates, and `no issues` when the figure is clean.
34
34
 
35
35
  * **Units are px. `y` grows downward** (SVG convention): `n` is the top edge,
36
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.
37
+ * **Place things relative to each other, not at coordinates.** figkit measures
38
+ text from the real font file, so `right_of`, `hstack`, `grid` and `fit` land
39
+ exactly. Typing absolute numbers works but it is the slow path: a redesign
40
+ then means editing every number by hand, which is the single biggest source
41
+ of pain reported by people using this library. Reach for §3 and §9 first and
42
+ drop to coordinates only for the few things that genuinely need them.
40
43
  * **Anchors are live references.** `box.e` is not a coordinate — it resolves
41
44
  when read. Move the box afterwards and every arrow pointing at it follows.
42
45
  * **Every placement call returns `self`**, so it chains:
@@ -55,6 +58,25 @@ problem with coordinates, and `no issues` when the figure is clean.
55
58
 
56
59
  ---
57
60
 
61
+ ### Use the layout engine
62
+
63
+ Most figures are rows, columns, grids and enclosures. Say that, and a later
64
+ change to one label re-flows the rest:
65
+
66
+ ```python
67
+ row = hstack([enc, mid, dec], gap=60, align="center") # a Group
68
+ col = vstack([row, caption], gap=18, align="center")
69
+ grid(cells, cols=4, gap=(12, 12))
70
+ fit(enc, mid, pad=20, label="Encoder stack") # boxed cluster
71
+ align([a, b, c], "top"); distribute_h([a, b, c], gap=24)
72
+ ```
73
+
74
+ The full list is §9. `Matrix`, `LabelledMatrix`, `Table`, `Brace`, `Bracket`,
75
+ `Legend`, `ColorBar` and `Panel` (§5, §8) already exist — check before building
76
+ your own.
77
+
78
+ ---
79
+
58
80
  ## 2. Cheat sheet
59
81
 
60
82
  | Need | Call |
@@ -74,6 +96,7 @@ problem with coordinates, and `no issues` when the figure is clean.
74
96
  | arrow | `arrow(a.e, b.w, label="x")` |
75
97
  | orthogonal | `elbow(a.e, b.w, stub=14, corner=6)` |
76
98
  | curved | `curve(a.s, b.s, bend=0.4)` |
99
+ | shaped curve | `curve(a.e, b.w, start_handle=0.6, end_handle=(0.2, -30))` |
77
100
  | align a group | `align([a, b, c], "top")` |
78
101
  | even spacing | `distribute_h(items, gap=20)` / `spread_h(items, x0, x1)` |
79
102
  | row / column | `hstack(items, gap=16, align="center")` / `vstack(...)` |
@@ -174,6 +197,55 @@ Common kwargs: `w, h, min_w, min_h, max_w, padding, wrap, align, valign,
174
197
  radius, fill, stroke, stroke_width, stroke_dash, opacity, shadow, rotate, z,
175
198
  name, style, theme`.
176
199
 
200
+ **Every style property, and the aliases that reach it.** A name outside this
201
+ table raises `UnknownProperty` with a suggestion rather than being accepted
202
+ and ignored, so a typo costs you a traceback instead of a wrong figure. The
203
+ booleans `bold=`, `italic=`, `underline=` and `monospace=` expand into these.
204
+
205
+ | Property | Also accepted as |
206
+ |---|---|
207
+ | **Paint** | |
208
+ | `fill` | `background`, `background_color`, `bg`, `facecolor`, `fill_color` |
209
+ | `fill_opacity` | — |
210
+ | `stroke` | `border`, `border_color`, `edgecolor`, `line_color`, `stroke_color` |
211
+ | `stroke_width` | `border_width`, `linewidth`, `lw`, `stroke_w` |
212
+ | `stroke_opacity` | — |
213
+ | `stroke_dash` | `dash`, `dasharray`, `dashes`, `linestyle`, `ls`, `stroke_dasharray` |
214
+ | `stroke_dashoffset` | — |
215
+ | `stroke_linecap` | `cap`, `linecap` |
216
+ | `stroke_linejoin` | `join`, `linejoin` |
217
+ | `opacity` | `alpha` |
218
+ | `shadow` | — |
219
+ | `background` | — |
220
+ | `image_opacity` | — |
221
+ | **Geometry** | |
222
+ | `radius` | `border_radius`, `corner_radius`, `rounding` |
223
+ | `padding` | — |
224
+ | **Type** | |
225
+ | `font_family` | `family`, `font`, `fontfamily`, `typeface` |
226
+ | `font_size` | `fontsize`, `size` |
227
+ | `font_weight` | `fontweight`, `weight` |
228
+ | `font_style` | `fontstyle` |
229
+ | `color` | `fg`, `foreground`, `text_color` |
230
+ | `line_height` | `leading`, `linespacing` |
231
+ | `letter_spacing` | `tracking` |
232
+ | `word_spacing` | Extra space between words, in figure units |
233
+ | `text_align` | `align`, `ha`, `halign` |
234
+ | `valign` | `va`, `vertical_align` |
235
+ | `text_transform` | `none`, `uppercase`, `lowercase`, or `capitalize` |
236
+ | `text_decoration` | — |
237
+ | **Math** | |
238
+ | `math_backend` | — |
239
+ | `math_color` | — |
240
+ | `math_scale` | — |
241
+ | **Connectors** | |
242
+ | `head` | `arrowhead` |
243
+ | `head_size` | `headsize` |
244
+ | `tail` | `arrowtail` |
245
+ | `tail_size` | `tailsize` |
246
+
247
+ `register_props("my_prop")` declares extras for a component of your own.
248
+
177
249
  **Geometry** — `Line(a, b)`, `Polyline(points)`, `Polygon(points)`,
178
250
  `Path("M0 0 L10 10 …")`, `Dot(center, r)`, `Marker(center, size, "diamond")`.
179
251
  `Line`/`Polyline`/`Polygon` accept **live anchors** as points, so they track
@@ -260,7 +332,28 @@ On connectors and other line-like elements (`Line`, `Polyline`, `Path`,
260
332
  `bend` deepens the bow **along the anchors' facing direction** — `curve(a.s,
261
333
  b.s, bend=0.5)` dips below both boxes. For plain points there is no normal to
262
334
  follow so it bows sideways. `bow=` always pushes sideways (positive = left of
263
- travel).
335
+ travel). Both are symmetric, and `bend` acts as a *floor* on the curve's
336
+ natural reach, so small values often change nothing.
337
+
338
+ For real control, place either end's Bezier handle yourself — the "whisker" a
339
+ vector editor lets you drag:
340
+
341
+ ```python
342
+ curve(a.e, b.w, start_handle=0.6) # leaves east on a long handle
343
+ curve(a.e, b.w, start_handle=0.8, end_handle=0.15) # asymmetric
344
+ curve(a.e, b.w, start_handle=(0.5, -40)) # turned 40 deg off the face
345
+ curve(a.e, b.w, start_handle=Handle(px=60)) # absolute reach
346
+ ```
347
+
348
+ The handle's **direction** is the asymptote — which way the curve insists on
349
+ leaving — and its **length** is how long it clings to it. Length is a fraction
350
+ of the straight-line distance between the endpoints, so the curve keeps its
351
+ shape when the things it connects move apart; `Handle(px=...)` opts out of
352
+ that. Angles are degrees off the face you attached to, positive clockwise on
353
+ screen. An end you give a handle ignores `bend` and `bow`.
354
+
355
+ Handles work with `waypoints=` too, and `tension=` (default 0.5) loosens the
356
+ spline that threads them.
264
357
 
265
358
  `self_loop(element, side="top", size=36, label="retry")` draws an arrow that
266
359
  leaves an element and returns to it — the staple of state machines.
@@ -446,6 +539,11 @@ PNG/PDF need a converter: `pip install "figkit[export]"` (cairosvg), or
446
539
  `rsvg-convert` / `resvg` / `inkscape` / headless chromium on `PATH`.
447
540
  PNG and PDF outline text automatically, so they always match the SVG.
448
541
 
542
+ On macOS, cairosvg often fails to import with `dlopen ... libcairo.2.dylib`
543
+ even with Homebrew's cairo installed, because the loader does not look in
544
+ `/opt/homebrew`. Either export `DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix
545
+ cairo)/lib` or use `rsvg-convert` instead. SVG export needs nothing.
546
+
449
547
  Install: `pip install figkit` · `figkit[latex]` (math) · `figkit[export]`
450
548
  (PNG/PDF) · `figkit[all]`.
451
549
 
@@ -522,6 +620,24 @@ fig.audit(min_contrast=4.5) # WCAG AA for body text
522
620
  SVG and HTML but not in a cairosvg-rendered PNG/PDF; figkit warns when
523
621
  that happens. Use `rsvg-convert`/`resvg`/chromium, or skip shadows for
524
622
  figures headed to PNG.
623
+ 13. **A misspelled property raises.** `Text("hi", sizee=8)` is an error, not a
624
+ silent default. `size` and `font_size` are the same thing, and match
625
+ `measure_text(..., size=)`.
626
+ 14. **`rotate=` turns the block about `(x, y)`,** so a rotated label lands in
627
+ the same place whatever its length. `rotate_about=` picks another pivot;
628
+ `el.rotate(deg)` after construction still turns about the centre.
629
+ 15. **A missing glyph warns.** If the font has no glyph for a character, it
630
+ renders as an empty box and its measured width is `.notdef`'s, so figkit
631
+ says so and names the codepoint. `register_font` makes text export as
632
+ outlines, which also removes the viewer's fallback chain — a character
633
+ the registered font lacks becomes a guaranteed empty box.
634
+ 16. **A tiny `Box` looks like a circle,** because the theme's `radius=6` is
635
+ clamped to half the shorter side. Pass `radius=0` for small swatches and
636
+ data cells.
637
+ 17. **Arrow heads shrink to fit short connectors.** Below roughly 15pt the
638
+ head would swallow the shaft, so figkit scales it down and warns when the
639
+ result is much smaller than you asked for. Give short links more room, or
640
+ set `head_size=`.
525
641
 
526
642
  ---
527
643
 
@@ -0,0 +1,181 @@
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.2.0] — 2026-08-24
8
+
9
+ ### Changed
10
+
11
+ - **A style property figkit does not read now raises `UnknownProperty`**
12
+ instead of being stored and ignored. `Text("hi", sizee=8)` used to render at
13
+ the theme default with no complaint; it now fails with a suggestion. This is
14
+ a breaking change for code passing properties that never did anything.
15
+ Components with properties of their own declare them with
16
+ `register_props(...)`, and `PROPS` is the full list.
17
+ - **`rotate=` on `Text` turns the block about `(x, y)`**, not about its own
18
+ centre. Pivoting on the centre displaced the text by half its own length, so
19
+ where a rotated label landed depended on how many characters it had — a
20
+ 50pt label drifted 25pt. `rotate_about=` takes an explicit pivot, and
21
+ `el.rotate(deg)` after construction is unchanged.
22
+ - Arrow heads shrink to fit connectors too short to hold them. A head larger
23
+ than the connector used to consume the whole shaft and overshoot its start,
24
+ drawing a stray triangle with no line.
25
+ - String options such as connector routes, marker shapes, grid order/alignment,
26
+ vector orientation, frame scales/sides and image fit now reject invalid
27
+ values with a suggestion instead of silently choosing another rendering.
28
+ - Raster export now rejects `.jpg`, `.jpeg` and `.webp`; these extensions were
29
+ accepted even though the bytes written were always PNG.
30
+
31
+ ### Added
32
+
33
+ - `size=` is an alias for `font_size=`, matching `measure_text(..., size=)`.
34
+ Measuring at one size and rendering at another was silent before.
35
+ - `fill=` set directly on a `Text` means its colour. Text has no fill of its
36
+ own, so the property was being dropped.
37
+ - A warning when the font has no glyph for a character: it renders as an empty
38
+ box and its measured width is `.notdef`'s, not the character's, so layout
39
+ computed from it is wrong. Names the codepoint, warns once per font and
40
+ character.
41
+ - `Dot` accepts `Dot(x, y, r=...)` as well as `Dot(center, r)`, matching
42
+ `Circle` and every other element's `x, y` front door.
43
+ - `word_spacing=` participates in both measurement and SVG/path rendering, and
44
+ `text_transform=` supports `none`, `uppercase`, `lowercase` and `capitalize`.
45
+ The advertised but unimplemented `font_variant` property is now rejected
46
+ instead of being accepted as a silent no-op.
47
+ - `rotate=` and `rotate_about=` are common element constructor options, as the
48
+ manual already claimed, rather than Text-only constructor options. The
49
+ rotation is applied on first measurement rather than in `Element.__init__`,
50
+ because a subclass has not sized itself yet at that point: pivoting there
51
+ used the placeholder box, so a label-sized element landed somewhere that
52
+ depended on how long its text was.
53
+ - `Figure.to_html(embed=False)` isolates the SVG in an image data URI;
54
+ `embed=True` keeps the SVG inline and styleable.
55
+
56
+ ### Documentation
57
+
58
+ - The manual led with "nothing is auto-laid-out", which read as an instruction
59
+ to place everything by hand. It now points at `hstack`/`vstack`/`grid`/`fit`
60
+ and the existing `Matrix`, `Table`, `Brace` and `Legend` components first.
61
+ - Every style property and its aliases are tabulated, so valid names no longer
62
+ have to be discovered by reading theme reprs.
63
+ - Gotchas for the rotation pivot, missing glyphs, small-`Box` corner radius,
64
+ short-connector heads, and the macOS `DYLD_FALLBACK_LIBRARY_PATH` fix for
65
+ cairosvg.
66
+
67
+ ### Fixed
68
+
69
+ - `figkit.__version__` reported `0.1.0` from the 0.1.1 release. The number
70
+ lived in both `pyproject.toml` and `figkit/__init__.py`, and a release only
71
+ bumped the first. `pyproject.toml` now reads the version from the package,
72
+ so there is one number to change and it cannot drift again.
73
+ - macOS `.ttc`/`.otc` system fonts failed to load because the collection
74
+ loader received a TTFont-only argument. Collections now select the closest
75
+ family/weight/style face, restoring accurate metrics and text outlines.
76
+ - `hstack(..., at=...)` and `vstack(..., at=...)` now place the north-west
77
+ corner of the completed result at `at`, including an optional panel.
78
+ - `max_w=` now constrains and reflows a shape's label, including long tokens,
79
+ instead of shrinking only the container and leaving its text outside.
80
+ - `self_loop()` keeps live anchors for its feet and apex, so it follows an
81
+ element after movement or resizing.
82
+ - `Frame(clip_data=True)` clips only data marks; axes, ticks and titles remain
83
+ visible outside the plot area.
84
+ - `Matrix.highlight()` keeps the highlighted cell's value label in front.
85
+ - Contrast audit uses the same resolved text colour as rendering, including
86
+ `Text(fill=...)` and per-Span colours.
87
+ - `ColorBar` segments stay within the declared strip bounds.
88
+ - Dict-valued theme fills such as gradients are no longer mistaken for roles.
89
+ - Math glyph left overhang is included in measurement and normalized in the
90
+ generated path.
91
+ - Rich `Text` content has a safe repr, and outlined missing glyphs now draw the
92
+ `.notdef` box described by their warning instead of leaving a blank gap.
93
+
94
+ ## [0.1.1] — 2026-08-21
95
+
96
+ ### Connectors
97
+
98
+ - `start_handle=` and `end_handle=` place either end's Bezier handle — the
99
+ "whisker" a vector editor lets you drag. The direction is the asymptote the
100
+ curve leaves along, the length is how long it clings to it, given as a
101
+ fraction of the endpoint separation so the shape survives the layout
102
+ moving. Takes a bare fraction, a tuple, or a `Handle` (which also offers
103
+ `px=` for an absolute reach). An end given a handle ignores `bend`/`bow`.
104
+ - Handles also give curves between bare points a direction to leave in, which
105
+ previously only anchors could supply.
106
+ - `tension=` (default 0.5) loosens the spline threaded through `waypoints=`.
107
+
108
+ ### Fixed
109
+
110
+ - A curve with waypoints threw its endpoint normals away, so `curve(a.e, b.w,
111
+ waypoints=[p])` left the box diagonally instead of along the face it was
112
+ attached to. The outer tangents are now pinned to the attachment normals.
113
+
114
+ ## [0.1.0] — 2026-08-21
115
+
116
+ First public release.
117
+
118
+ ### Layout and geometry
119
+
120
+ - Elements with live anchors (`box.e` re-resolves on read, so arrows follow
121
+ the things they connect), exact bounding boxes, and chainable relative
122
+ placement: `at`, `right_of`, `left_of`, `above_of`, `below_of`, `inside`,
123
+ `align_to`, `span_x`, `resize`, `rotate`, `scale_by`.
124
+ - Batch arrangement: `align`, `distribute_h/v`, `spread_h/v`, `hstack`,
125
+ `vstack`, `grid`, `fit`, `circular`, `same_size`, `brace_around`.
126
+ - `hstack(align="baseline")` sets mixed text and matrices like an equation.
127
+ - Groups that own their children and report live bounds, with `z`-ordering.
128
+
129
+ ### Text
130
+
131
+ - Text measured from real glyph advances via fontTools, with font resolution
132
+ through registered fonts, the usual system directories and `fc-match`, and a
133
+ fallback to the PostScript core-font metrics.
134
+ - Multi-line text, word wrapping, optical (cap-height) centring inside shapes.
135
+ - Inline `$math$` anywhere, typeset to vector outlines through matplotlib's
136
+ mathtext, or a real `latex` + `dvisvgm` toolchain when one is installed.
137
+ - `Span` for per-word colour, weight, style, size, family, strike-through and
138
+ underline. Decorations are drawn as geometry, so they survive rasterising
139
+ and outlining.
140
+
141
+ ### Drawing
142
+
143
+ - Shapes: `Box`, `Pill`, `Ellipse`, `Circle`, `Diamond`, `Triangle`,
144
+ `Hexagon`, `Parallelogram`, `Chevron`, `Star`, `Cylinder`, `Note`,
145
+ `Callout`; geometry primitives `Line`, `Polyline`, `Polygon`, `Path`,
146
+ `Dot`, `Marker`.
147
+ - Connectors: straight, orthogonal (`elbow`), curved, arcs, explicit
148
+ waypoints, nine arrow-head shapes, path labels, and `self_loop`.
149
+ - Composites: `Matrix`, `LabelledMatrix`, `Vector`, `ColorBar`, `Table`,
150
+ `Legend`, `Brace`, `Bracket`, `Panel`.
151
+ - `Component` for reusable units that publish named anchors.
152
+ - `Image` embeds rasters as data URIs and inlines SVGs as vectors, rewriting
153
+ internal ids so repeated copies never collide.
154
+
155
+ ### Style
156
+
157
+ - A cascading theme: base tokens, per-role overrides, CSS-style classes and a
158
+ colour palette, resolved kwargs → style → classes → inherited → theme →
159
+ default.
160
+ - Seven built-in themes: default, `PAPER`, `SLIDE`, `DARK`, `BLUEPRINT`,
161
+ `MINIMAL`, `SOFT`.
162
+ - Colour helpers: `mix`, `lighten`, `darken`, `alpha`, `saturate`,
163
+ `contrast_color`, `colormap`, `palette`.
164
+
165
+ ### Data
166
+
167
+ - `Frame` maps a data domain onto figure coordinates; `line`, `scatter`,
168
+ `bars`, `area_fill`, `region`, `axes`, `gridlines`, log scales and
169
+ `nice_ticks`. Every mark is an ordinary element you can anchor to.
170
+
171
+ ### Checking and output
172
+
173
+ - `fig.audit()` reports overlapping elements, labels escaping their shapes,
174
+ unreadable colour combinations, connectors crossing unrelated elements,
175
+ degenerate geometry and content outside a pinned canvas — and is built to
176
+ stay quiet about deliberate overlap, so a clean report means something.
177
+ - Export to SVG and HTML with no dependencies; PNG and PDF through cairosvg,
178
+ `rsvg-convert`, `resvg`, `inkscape` or headless Chromium. Raster and PDF
179
+ output outlines text so it cannot depend on the renderer's fonts.
180
+ - `AI_MANUAL.md`, a system-prompt-sized guide for driving figkit from an
181
+ agent.
@@ -37,7 +37,7 @@ skip rather than fail.
37
37
  does, it usually wants renaming instead.
38
38
  - Public functions get a docstring with a one-line summary and, where it
39
39
  helps, a short example.
40
- - Run `python -m pyflakes figkit` before opening a pull request.
40
+ - Run `python -m pyflakes figkit tools` before opening a pull request.
41
41
 
42
42
  ## Things worth knowing before changing the internals
43
43
 
@@ -47,3 +47,29 @@ skip rather than fail.
47
47
  own labels use `Element.place_local` for parent-space arithmetic instead.
48
48
  - `Group` takes ownership of its children. `bbox_of([...])` is the read-only
49
49
  way to ask about a set of elements.
50
+
51
+ ## Releasing
52
+
53
+ Releases are cut by the `publish` workflow, which uploads to PyPI through
54
+ Trusted Publishing — there is no API token to hold or rotate.
55
+
56
+ 1. Write the entry for the new version at the top of `CHANGELOG.md`, under a
57
+ `## [Unreleased]` heading (or the version's own heading). The workflow
58
+ refuses to release if the top section is some older version, because that
59
+ means nobody wrote down what changed.
60
+ 2. Run the workflow on `main` from the Actions tab (**publish → Run
61
+ workflow**), set **version** to the number being released, and untick
62
+ **dry_run**.
63
+
64
+ That is the whole procedure. The workflow sets the version in
65
+ `pyproject.toml`, dates the changelog section, runs the tests and the linter,
66
+ builds, and only then commits, tags `vX.Y.Z`, creates the GitHub Release with
67
+ the changelog section as its notes, and uploads to PyPI. Anything that fails
68
+ before the commit step leaves the repository untouched.
69
+
70
+ Leaving **dry_run** ticked builds and checks the current commit and changes
71
+ nothing — useful for confirming the packaging still passes.
72
+
73
+ Publishing a GitHub Release by hand also works, as long as the tag matches the
74
+ version already in `pyproject.toml`; the workflow verifies that and refuses
75
+ otherwise.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: figkit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Design figures with Python code — boxes, arrows, LaTeX and data, exported to SVG/PNG/PDF/HTML
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/philippspohn/figkit
@@ -263,7 +263,7 @@ Anchors on every element: `n s e w ne nw se sw center`, plus `at_angle(deg)`,
263
263
 
264
264
  ```bash
265
265
  pip install -e ".[dev]"
266
- pytest # 277 tests; every example must audit clean
266
+ pytest # full suite; every example must audit clean
267
267
  python -m pyflakes figkit
268
268
  python examples/01_pipeline.py
269
269
  ```
@@ -218,7 +218,7 @@ Anchors on every element: `n s e w ne nw se sw center`, plus `at_angle(deg)`,
218
218
 
219
219
  ```bash
220
220
  pip install -e ".[dev]"
221
- pytest # 277 tests; every example must audit clean
221
+ pytest # full suite; every example must audit clean
222
222
  python -m pyflakes figkit
223
223
  python examples/01_pipeline.py
224
224
  ```
@@ -42,13 +42,13 @@ with Figure(pad=26, background="#ffffff") as fig:
42
42
  Text([Span("Rich text", bold=True), " — one line, several styles"],
43
43
  font_size=16, align="left").at(0, 0)
44
44
  Text(["accuracy ", Span("76.1", strike=True, color="@muted"), " → ",
45
- Span("94.6", color="@good", bold=True), " after ",
45
+ Span("94.6", color="#4b965c", bold=True), " after ",
46
46
  Span("pretraining", italic=True), ", measured on ",
47
47
  Span("held-out data", underline=True)],
48
48
  font_size=14, align="left").at(0, 30)
49
49
 
50
50
  # ---- 2. a matrix expression, set like an equation -------------------
51
- lhs = Text("$\\Pi_{\\mathcal{NM}} \;=\;$", font_size=19)
51
+ lhs = Text(r"$\Pi_{\mathcal{NM}} \;=\;$", font_size=19)
52
52
  left = LabelledMatrix([[rng.random() for _ in range(4)] for _ in range(5)],
53
53
  cell=17, cmap="grays", stroke="#ffffff",
54
54
  row_label="seq len", col_label="$d$",
@@ -16,7 +16,7 @@ Everything is explicit: you place things, figkit measures them accurately
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.1.0"
19
+ __version__ = "0.2.0"
20
20
 
21
21
  # -- geometry ---------------------------------------------------------------
22
22
  from .geom import Affine, BBox, Point, to_point
@@ -46,8 +46,8 @@ from .components import (Bracket, Brace, Callout, ColorBar, Heatmap,
46
46
  Table, Vector)
47
47
 
48
48
  # -- connectors -------------------------------------------------------------
49
- from .connectors import (Connector, arrow, connect, curve, double_arrow,
50
- elbow, line, self_loop)
49
+ from .connectors import (Connector, Handle, arrow, connect, curve,
50
+ double_arrow, elbow, line, self_loop)
51
51
 
52
52
  # -- layout -----------------------------------------------------------------
53
53
  from .layout import (align, align_h, align_v, baseline_of, between, bbox_of,
@@ -94,8 +94,8 @@ __all__ = [
94
94
  "Bracket", "Legend", "Table", "Callout", "Spacer", "Component",
95
95
  "LabelledMatrix",
96
96
  # connectors
97
- "Connector", "arrow", "line", "elbow", "curve", "connect", "double_arrow",
98
- "self_loop",
97
+ "Connector", "Handle", "arrow", "line", "elbow", "curve", "connect",
98
+ "double_arrow", "self_loop",
99
99
  # layout
100
100
  "align", "align_h", "align_v", "distribute_h", "distribute_v", "spread_h",
101
101
  "spread_v", "hstack", "vstack", "grid", "fit", "frame_around", "between",
@@ -531,8 +531,19 @@ def _check_contrast(figure, min_contrast: float) -> list:
531
531
  continue
532
532
  if not getattr(el, "audit_enabled", True):
533
533
  continue
534
- colour = label.prop("color")
535
- if colour is None:
534
+ colours = [label.text_color()]
535
+ try:
536
+ colours.extend(label._resolve_value(run.color)
537
+ for line in label.layout.lines for run in line.runs
538
+ if run.color is not None)
539
+ except Exception:
540
+ pass
541
+ unique = []
542
+ for candidate in colours:
543
+ if candidate is not None and candidate not in unique:
544
+ unique.append(candidate)
545
+ colours = unique
546
+ if not colours:
536
547
  continue
537
548
  lb = label.bbox
538
549
  pos = index.get(id(el), 0)
@@ -544,18 +555,20 @@ def _check_contrast(figure, min_contrast: float) -> list:
544
555
  # box's own fill, which is the commonest backdrop of all.
545
556
  if _contains(other.bbox, lb, tol=-0.5):
546
557
  backdrop = _solid_fill(other)
547
- try:
548
- ratio = _contrast_ratio(colour, backdrop)
549
- except ValueError:
550
- continue
551
- if ratio < min_contrast - 1e-9:
552
- out.append(Finding(
553
- "contrast",
554
- f"{describe(el)}: text {colour} on {backdrop} has contrast "
555
- f"{_floor2(ratio)}:1 (want {min_contrast:.2f}:1)",
556
- severity="error" if ratio < 1.6 else "warning",
557
- where=lb.center, elements=(el,),
558
- detail={"ratio": ratio, "color": colour, "background": backdrop}))
558
+ for colour in colours:
559
+ try:
560
+ ratio = _contrast_ratio(colour, backdrop)
561
+ except (TypeError, ValueError):
562
+ continue
563
+ if ratio < min_contrast - 1e-9:
564
+ out.append(Finding(
565
+ "contrast",
566
+ f"{describe(el)}: text {colour} on {backdrop} has contrast "
567
+ f"{_floor2(ratio)}:1 (want {min_contrast:.2f}:1)",
568
+ severity="error" if ratio < 1.6 else "warning",
569
+ where=lb.center, elements=(el,),
570
+ detail={"ratio": ratio, "color": colour,
571
+ "background": backdrop}))
559
572
  return out
560
573
 
561
574
 
@@ -7,7 +7,7 @@ import math
7
7
  from .colors import colormap, contrast_color, to_hex
8
8
  from .component import Component
9
9
  from .core import Element, Group
10
- from .style import Style
10
+ from .style import Style, enum_value
11
11
  from .geom import BBox, Point, _expand_spec, to_point
12
12
  from .paint import paint_attrs
13
13
  from .shapes import Box, Marker
@@ -171,6 +171,7 @@ class Matrix(Group):
171
171
  self.cmap = cmap
172
172
  self._cells: list = []
173
173
  self._value_labels: list = []
174
+ self._value_label_map: dict = {}
174
175
  # Paint properties passed to Matrix(...) style the *cells*, which is
175
176
  # what people mean by `Matrix(vals, stroke="#333")`.
176
177
  group_kw = {k: v for k, v in kw.items() if k in _GROUP_KEYS}
@@ -211,6 +212,7 @@ class Matrix(Group):
211
212
  self.add(lbl)
212
213
  lbl.center_at(cell_el.bbox.cx, cell_el.bbox.cy)
213
214
  self._value_labels.append(lbl)
215
+ self._value_label_map[(i, j)] = lbl
214
216
  self._cells.append(row_cells)
215
217
 
216
218
  if border:
@@ -263,6 +265,9 @@ class Matrix(Group):
263
265
  c = self.cell(i, j)
264
266
  c.restyle(**style)
265
267
  c.to_front()
268
+ label = self._value_label_map.get((i % self.n_rows, j % self.n_cols))
269
+ if label is not None:
270
+ label.to_front()
266
271
  return c
267
272
 
268
273
 
@@ -271,13 +276,16 @@ def Vector(values=None, *, orient: str = "v", **kw) -> Matrix:
271
276
  if values is None:
272
277
  values = []
273
278
  flat = list(values)
274
- if str(orient).lower().startswith("v"):
279
+ orient = enum_value(orient, "orient", {
280
+ "v": "v", "vertical": "v", "h": "h", "horizontal": "h",
281
+ })
282
+ if orient == "v":
275
283
  grid = [[v] for v in flat]
276
284
  else:
277
285
  grid = [flat]
278
286
  colors = kw.pop("colors", None)
279
287
  if colors is not None:
280
- colors = [[c] for c in colors] if str(orient).lower().startswith("v") \
288
+ colors = [[c] for c in colors] if orient == "v" \
281
289
  else [list(colors)]
282
290
  return Matrix(None, colors=colors, **kw)
283
291
  return Matrix(grid, **kw)
@@ -296,16 +304,21 @@ class ColorBar(Group):
296
304
  labels=True, label_fmt="{:.2g}", x: float = 0.0,
297
305
  y: float = 0.0, **kw):
298
306
  super().__init__(**kw)
299
- vertical = str(orient).lower().startswith("v")
307
+ orient = enum_value(orient, "orient", {
308
+ "v": "v", "vertical": "v", "h": "h", "horizontal": "h",
309
+ })
310
+ vertical = orient == "v"
300
311
  n = max(2, int(steps))
301
312
  for i in range(n):
302
313
  t = i / (n - 1)
314
+ pos = i / n
315
+ span = (1.0 - pos) if i == n - 1 else (1.0 / n + 0.001)
303
316
  col = colormap(cmap, 1.0 - t if vertical else t)
304
317
  if vertical:
305
- seg = Box(None, x, y + t * h, w, h / n + 0.6, fill=col,
318
+ seg = Box(None, x, y + pos * h, w, span * h, fill=col,
306
319
  stroke="none", padding=0, radius=0, add=False)
307
320
  else:
308
- seg = Box(None, x + t * w, y, w / n + 0.6, h, fill=col,
321
+ seg = Box(None, x + pos * w, y, span * w, h, fill=col,
309
322
  stroke="none", padding=0, radius=0, add=False)
310
323
  self.add(seg)
311
324
  frame = Box(None, x, y, w, h, fill="none", stroke="#6b7280",