figkit 0.1.1__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.
- {figkit-0.1.1 → figkit-0.2.0}/AI_MANUAL.md +97 -3
- figkit-0.2.0/CHANGELOG.md +181 -0
- {figkit-0.1.1 → figkit-0.2.0}/PKG-INFO +2 -2
- {figkit-0.1.1 → figkit-0.2.0}/README.md +1 -1
- {figkit-0.1.1 → figkit-0.2.0}/examples/05_rich_text_and_components.py +2 -2
- {figkit-0.1.1 → figkit-0.2.0}/figkit/__init__.py +1 -1
- {figkit-0.1.1 → figkit-0.2.0}/figkit/audit.py +27 -14
- {figkit-0.1.1 → figkit-0.2.0}/figkit/components.py +19 -6
- {figkit-0.1.1 → figkit-0.2.0}/figkit/connectors.py +42 -11
- {figkit-0.1.1 → figkit-0.2.0}/figkit/core.py +20 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/export.py +7 -1
- {figkit-0.1.1 → figkit-0.2.0}/figkit/figure.py +15 -4
- {figkit-0.1.1 → figkit-0.2.0}/figkit/fonts.py +79 -10
- {figkit-0.1.1 → figkit-0.2.0}/figkit/frame.py +43 -8
- {figkit-0.1.1 → figkit-0.2.0}/figkit/image.py +5 -1
- {figkit-0.1.1 → figkit-0.2.0}/figkit/layout.py +28 -14
- {figkit-0.1.1 → figkit-0.2.0}/figkit/mathtext.py +3 -3
- {figkit-0.1.1 → figkit-0.2.0}/figkit/shapes.py +32 -5
- {figkit-0.1.1 → figkit-0.2.0}/figkit/style.py +73 -5
- {figkit-0.1.1 → figkit-0.2.0}/figkit/text.py +90 -15
- {figkit-0.1.1 → figkit-0.2.0}/figkit.egg-info/PKG-INFO +2 -2
- {figkit-0.1.1 → figkit-0.2.0}/figkit.egg-info/SOURCES.txt +1 -0
- {figkit-0.1.1 → figkit-0.2.0}/pyproject.toml +6 -1
- figkit-0.2.0/tests/test_audit_fixes.py +220 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_connectors.py +21 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_release_prep.py +27 -5
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_style.py +55 -1
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_text_and_shapes.py +76 -2
- figkit-0.1.1/CHANGELOG.md +0 -94
- {figkit-0.1.1 → figkit-0.2.0}/CONTRIBUTING.md +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/LICENSE +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/MANIFEST.in +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/examples/00_quickstart.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/examples/01_pipeline.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/examples/02_attribution.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/examples/03_data_and_plots.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/examples/04_themes.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/colors.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/component.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/geom.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/paint.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/py.typed +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/svgdoc.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/svgpath.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit/themes.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit.egg-info/dependency_links.txt +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit.egg-info/requires.txt +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/figkit.egg-info/top_level.txt +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/setup.cfg +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_audit.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_colors.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_composition.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_core.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_examples.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_export.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_figure_and_frame.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_geom.py +0 -0
- {figkit-0.1.1 → figkit-0.2.0}/tests/test_layout.py +0 -0
- {figkit-0.1.1 → 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
|
-
* **
|
|
38
|
-
|
|
39
|
-
|
|
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 |
|
|
@@ -175,6 +197,55 @@ Common kwargs: `w, h, min_w, min_h, max_w, padding, wrap, align, valign,
|
|
|
175
197
|
radius, fill, stroke, stroke_width, stroke_dash, opacity, shadow, rotate, z,
|
|
176
198
|
name, style, theme`.
|
|
177
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
|
+
|
|
178
249
|
**Geometry** — `Line(a, b)`, `Polyline(points)`, `Polygon(points)`,
|
|
179
250
|
`Path("M0 0 L10 10 …")`, `Dot(center, r)`, `Marker(center, size, "diamond")`.
|
|
180
251
|
`Line`/`Polyline`/`Polygon` accept **live anchors** as points, so they track
|
|
@@ -468,6 +539,11 @@ PNG/PDF need a converter: `pip install "figkit[export]"` (cairosvg), or
|
|
|
468
539
|
`rsvg-convert` / `resvg` / `inkscape` / headless chromium on `PATH`.
|
|
469
540
|
PNG and PDF outline text automatically, so they always match the SVG.
|
|
470
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
|
+
|
|
471
547
|
Install: `pip install figkit` · `figkit[latex]` (math) · `figkit[export]`
|
|
472
548
|
(PNG/PDF) · `figkit[all]`.
|
|
473
549
|
|
|
@@ -544,6 +620,24 @@ fig.audit(min_contrast=4.5) # WCAG AA for body text
|
|
|
544
620
|
SVG and HTML but not in a cairosvg-rendered PNG/PDF; figkit warns when
|
|
545
621
|
that happens. Use `rsvg-convert`/`resvg`/chromium, or skip shadows for
|
|
546
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=`.
|
|
547
641
|
|
|
548
642
|
---
|
|
549
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: figkit
|
|
3
|
-
Version: 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 #
|
|
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 #
|
|
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="
|
|
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("
|
|
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.
|
|
19
|
+
__version__ = "0.2.0"
|
|
20
20
|
|
|
21
21
|
# -- geometry ---------------------------------------------------------------
|
|
22
22
|
from .geom import Affine, BBox, Point, to_point
|
|
@@ -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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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 +
|
|
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",
|
|
@@ -12,9 +12,10 @@ from dataclasses import dataclass
|
|
|
12
12
|
from .core import Anchor, Element
|
|
13
13
|
from .geom import BBox, Point, polyline_length, to_point
|
|
14
14
|
from .paint import paint_attrs
|
|
15
|
+
from .style import enum_value
|
|
15
16
|
from .svgdoc import Node, RenderContext
|
|
16
17
|
from .svgpath import (fmt, flatten_path, path_bbox, path_from_points,
|
|
17
|
-
point_at, rounded_polyline)
|
|
18
|
+
path_length, point_at, rounded_polyline)
|
|
18
19
|
from .text import Text
|
|
19
20
|
|
|
20
21
|
__all__ = [
|
|
@@ -25,6 +26,13 @@ __all__ = [
|
|
|
25
26
|
HEADS = ("triangle", "stealth", "open", "vee", "circle", "dot", "diamond",
|
|
26
27
|
"square", "bar", "tee", "cross", "none")
|
|
27
28
|
|
|
29
|
+
_ROUTES = {
|
|
30
|
+
"straight": "straight", "line": "straight",
|
|
31
|
+
"elbow": "elbow", "orth": "orth", "orthogonal": "orthogonal",
|
|
32
|
+
"hv": "hv", "vh": "vh", "manhattan": "manhattan",
|
|
33
|
+
"curve": "curve", "bezier": "bezier", "spline": "spline", "arc": "arc",
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
_SIDE_NORMAL = {"n": Point(0, -1), "s": Point(0, 1),
|
|
29
37
|
"e": Point(1, 0), "w": Point(-1, 0)}
|
|
30
38
|
|
|
@@ -202,7 +210,7 @@ class Connector(Element):
|
|
|
202
210
|
label_rotate: bool = False, **kw):
|
|
203
211
|
self.start_ref = start
|
|
204
212
|
self.end_ref = end
|
|
205
|
-
self.route =
|
|
213
|
+
self.route = enum_value(route, "route", _ROUTES)
|
|
206
214
|
self.waypoints = list(waypoints or [])
|
|
207
215
|
self.stub = float(stub)
|
|
208
216
|
self.bend = float(bend)
|
|
@@ -416,6 +424,19 @@ class Connector(Element):
|
|
|
416
424
|
# How far back the stroke has to stop for each head to cover its end.
|
|
417
425
|
trim_end = _head_inset(head_kind, head_size) if has_head else 0.0
|
|
418
426
|
trim_start = _head_inset(tail_kind, tail_size) if has_tail else 0.0
|
|
427
|
+
shrink = _head_budget(trim_start + trim_end, path_length(d))
|
|
428
|
+
if shrink < 1.0:
|
|
429
|
+
# The heads wanted more room than the connector has. Left alone
|
|
430
|
+
# they eat the whole shaft and overshoot its ends, which reads as
|
|
431
|
+
# a stray triangle rather than an arrow.
|
|
432
|
+
if shrink < 0.5:
|
|
433
|
+
ctx.warn(f"connector is {path_length(d):.0f}pt long but its "
|
|
434
|
+
f"heads need {trim_start + trim_end:.0f}pt; shrinking "
|
|
435
|
+
f"them to fit (set head_size= to choose)")
|
|
436
|
+
head_size *= shrink
|
|
437
|
+
tail_size *= shrink
|
|
438
|
+
trim_end = _head_inset(head_kind, head_size) if has_head else 0.0
|
|
439
|
+
trim_start = _head_inset(tail_kind, tail_size) if has_tail else 0.0
|
|
419
440
|
if trim_start or trim_end:
|
|
420
441
|
d = _trim_path(d, trim_start, trim_end)
|
|
421
442
|
|
|
@@ -449,6 +470,17 @@ class Connector(Element):
|
|
|
449
470
|
return nodes
|
|
450
471
|
|
|
451
472
|
|
|
473
|
+
#: Most of a short connector should still be line rather than arrow head.
|
|
474
|
+
_MAX_HEAD_SHARE = 0.6
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _head_budget(inset: float, length: float) -> float:
|
|
478
|
+
"""How much the heads must shrink by to leave a visible shaft."""
|
|
479
|
+
if inset <= 0 or length <= 0:
|
|
480
|
+
return 1.0
|
|
481
|
+
return min(1.0, _MAX_HEAD_SHARE * length / inset)
|
|
482
|
+
|
|
483
|
+
|
|
452
484
|
def _head_inset(kind: str, size: float) -> float:
|
|
453
485
|
"""How far short of the tip the stroke must stop for this head shape."""
|
|
454
486
|
_d, _meta, inset = _head_geometry(kind, Point(0, 0), Point(1, 0), size)
|
|
@@ -774,21 +806,20 @@ def self_loop(element, side: str = "top", size: float = 36.0,
|
|
|
774
806
|
|
|
775
807
|
>>> self_loop(state, side="top", label="retry")
|
|
776
808
|
"""
|
|
777
|
-
box = element.bbox
|
|
778
809
|
half = max(0.02, min(0.9, float(spread))) / 2.0
|
|
779
810
|
s = str(side).lower()
|
|
780
811
|
if s in ("top", "n", "up"):
|
|
781
|
-
start, end =
|
|
782
|
-
apex =
|
|
812
|
+
start, end = element.uv(0.5 - half, 0.0), element.uv(0.5 + half, 0.0)
|
|
813
|
+
apex = element.n + (0, -size)
|
|
783
814
|
elif s in ("bottom", "s", "down"):
|
|
784
|
-
start, end =
|
|
785
|
-
apex =
|
|
815
|
+
start, end = element.uv(0.5 + half, 1.0), element.uv(0.5 - half, 1.0)
|
|
816
|
+
apex = element.s + (0, size)
|
|
786
817
|
elif s in ("left", "w"):
|
|
787
|
-
start, end =
|
|
788
|
-
apex =
|
|
818
|
+
start, end = element.uv(0.0, 0.5 + half), element.uv(0.0, 0.5 - half)
|
|
819
|
+
apex = element.w + (-size, 0)
|
|
789
820
|
elif s in ("right", "e"):
|
|
790
|
-
start, end =
|
|
791
|
-
apex =
|
|
821
|
+
start, end = element.uv(1.0, 0.5 - half), element.uv(1.0, 0.5 + half)
|
|
822
|
+
apex = element.e + (size, 0)
|
|
792
823
|
else:
|
|
793
824
|
raise ValueError(f"side={side!r}; use top/bottom/left/right")
|
|
794
825
|
kw.setdefault("route", "curve")
|