figkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- figkit/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
figkit/shapes.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"""Drawable shapes: boxes, ellipses, polygons, paths and friends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from .core import Element
|
|
8
|
+
from .geom import Affine, BBox, Point, _expand_spec, to_point
|
|
9
|
+
from .paint import paint_attrs
|
|
10
|
+
from .svgdoc import Node, RenderContext
|
|
11
|
+
from .text import Text
|
|
12
|
+
from .svgpath import (fmt, path_bbox, path_from_points, rounded_polyline,
|
|
13
|
+
transform_path_data)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Shape", "Box", "Rect", "Pill", "Ellipse", "Circle", "Diamond", "Triangle",
|
|
17
|
+
"Hexagon", "Parallelogram", "Cylinder", "Note", "Chevron", "Stadium",
|
|
18
|
+
"Path", "Polygon", "Polyline", "Line", "Dot", "Marker", "Star",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ==========================================================================
|
|
23
|
+
# Base class for label-carrying shapes
|
|
24
|
+
# ==========================================================================
|
|
25
|
+
|
|
26
|
+
class Shape(Element):
|
|
27
|
+
"""A filled/stroked shape that can auto-size around a text label.
|
|
28
|
+
|
|
29
|
+
Subclasses override :meth:`shape_nodes` to draw themselves.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
role = "box"
|
|
33
|
+
default_padding = (8, 12)
|
|
34
|
+
|
|
35
|
+
def __init__(self, text=None, x: float = 0.0, y: float = 0.0,
|
|
36
|
+
w: float = None, h: float = None, *, padding=None,
|
|
37
|
+
min_w: float = 0.0, min_h: float = 0.0,
|
|
38
|
+
max_w: float = None, wrap=None, content: Element = None,
|
|
39
|
+
label_style=None, markup: bool = False, aspect: float = None,
|
|
40
|
+
**kw):
|
|
41
|
+
self._label: Text | None = None
|
|
42
|
+
self._content: Element | None = None
|
|
43
|
+
self.min_w = float(min_w or 0)
|
|
44
|
+
self.min_h = float(min_h or 0)
|
|
45
|
+
self.max_w = max_w
|
|
46
|
+
self.aspect = aspect
|
|
47
|
+
self._wrap = wrap
|
|
48
|
+
self._label_style = label_style
|
|
49
|
+
self._markup = markup
|
|
50
|
+
if padding is not None:
|
|
51
|
+
kw["padding"] = padding
|
|
52
|
+
super().__init__(x, y, w, h, **kw)
|
|
53
|
+
if text is not None and text != "":
|
|
54
|
+
self._label = Text(text, markup=markup, add=False,
|
|
55
|
+
style=label_style)
|
|
56
|
+
self._label.parent = self
|
|
57
|
+
self._label.role = "text"
|
|
58
|
+
if content is not None:
|
|
59
|
+
self.set_content(content)
|
|
60
|
+
self.invalidate()
|
|
61
|
+
|
|
62
|
+
# -- content ---------------------------------------------------------
|
|
63
|
+
@property
|
|
64
|
+
def label(self) -> Text | None:
|
|
65
|
+
"""The inner :class:`~figkit.text.Text` element (or ``None``)."""
|
|
66
|
+
self._ensure()
|
|
67
|
+
return self._label
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def text(self) -> str:
|
|
71
|
+
return self._label.text if self._label else ""
|
|
72
|
+
|
|
73
|
+
@text.setter
|
|
74
|
+
def text(self, value) -> None:
|
|
75
|
+
if self._label is None:
|
|
76
|
+
self._label = Text(value, markup=self._markup, add=False,
|
|
77
|
+
style=self._label_style)
|
|
78
|
+
self._label.parent = self
|
|
79
|
+
else:
|
|
80
|
+
self._label.text = value
|
|
81
|
+
self.invalidate()
|
|
82
|
+
|
|
83
|
+
def set_text(self, value, **style) -> "Shape":
|
|
84
|
+
self.text = value
|
|
85
|
+
if style and self._label is not None:
|
|
86
|
+
self._label.restyle(**style)
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def set_content(self, element: Element) -> "Shape":
|
|
90
|
+
"""Put an arbitrary element inside; the box sizes around it."""
|
|
91
|
+
if element.parent is not None:
|
|
92
|
+
element.parent.remove_child(element)
|
|
93
|
+
self._content = element
|
|
94
|
+
element.parent = self
|
|
95
|
+
return self.invalidate()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def content(self) -> Element | None:
|
|
99
|
+
return self._content
|
|
100
|
+
|
|
101
|
+
def padding(self) -> tuple:
|
|
102
|
+
pad = self.prop("padding", None)
|
|
103
|
+
if pad is None:
|
|
104
|
+
pad = self.default_padding
|
|
105
|
+
return _expand_spec(pad)
|
|
106
|
+
|
|
107
|
+
# -- measurement -----------------------------------------------------
|
|
108
|
+
def _inner_size(self) -> tuple:
|
|
109
|
+
top, right, bottom, left = self.padding()
|
|
110
|
+
# wrap: None -> wrap to the inner width when the width is pinned;
|
|
111
|
+
# True -> same; False -> never wrap; a number -> wrap at it.
|
|
112
|
+
wrap = self._wrap
|
|
113
|
+
inner_w = max(1.0, float(self._w or 0) - left - right)
|
|
114
|
+
# Padding can exceed a small shape (a Circle with generous padding, say).
|
|
115
|
+
# Wrapping to a sliver would collapse the label to zero width, so only
|
|
116
|
+
# wrap when there is a usable amount of room.
|
|
117
|
+
usable = inner_w if inner_w > 8 else None
|
|
118
|
+
if wrap is False:
|
|
119
|
+
wrap = None
|
|
120
|
+
elif wrap is True or wrap is None:
|
|
121
|
+
wrap = usable if self._explicit_w else None
|
|
122
|
+
if self._label is not None:
|
|
123
|
+
self._label._wrap = wrap if wrap else None
|
|
124
|
+
self._label.invalidate()
|
|
125
|
+
lb = self._label.local_bbox
|
|
126
|
+
cw, ch = lb.w, lb.h
|
|
127
|
+
else:
|
|
128
|
+
cw = ch = 0.0
|
|
129
|
+
if self._content is not None:
|
|
130
|
+
cb = self._content.bbox
|
|
131
|
+
cw = max(cw, cb.w)
|
|
132
|
+
ch = max(ch, cb.h) if self._label is None else ch + cb.h
|
|
133
|
+
return cw, ch
|
|
134
|
+
|
|
135
|
+
def _measure(self) -> None:
|
|
136
|
+
top, right, bottom, left = self.padding()
|
|
137
|
+
cw, ch = self._inner_size()
|
|
138
|
+
if not self._explicit_w:
|
|
139
|
+
self._w = max(self.min_w, cw + left + right)
|
|
140
|
+
if self.max_w:
|
|
141
|
+
self._w = min(self._w, float(self.max_w))
|
|
142
|
+
if not self._explicit_h:
|
|
143
|
+
self._h = max(self.min_h, ch + top + bottom)
|
|
144
|
+
if self.aspect:
|
|
145
|
+
if self._explicit_w and not self._explicit_h:
|
|
146
|
+
self._h = self._w / self.aspect
|
|
147
|
+
elif self._explicit_h and not self._explicit_w:
|
|
148
|
+
self._w = self._h * self.aspect
|
|
149
|
+
elif not self._explicit_w and not self._explicit_h:
|
|
150
|
+
if self._w / max(self._h, 1e-6) < self.aspect:
|
|
151
|
+
self._w = self._h * self.aspect
|
|
152
|
+
else:
|
|
153
|
+
self._h = self._w / self.aspect
|
|
154
|
+
self._place_content()
|
|
155
|
+
|
|
156
|
+
def _inner_box(self) -> BBox:
|
|
157
|
+
"""The content area. Falls back to the whole shape when padding
|
|
158
|
+
would leave no room — otherwise content drifts toward the padding
|
|
159
|
+
instead of staying centred on a small shape."""
|
|
160
|
+
top, right, bottom, left = self.padding()
|
|
161
|
+
w = (self._w or 0) - left - right
|
|
162
|
+
h = (self._h or 0) - top - bottom
|
|
163
|
+
x, y = self._x + left, self._y + top
|
|
164
|
+
if w <= 0:
|
|
165
|
+
x, w = self._x, (self._w or 0)
|
|
166
|
+
if h <= 0:
|
|
167
|
+
y, h = self._y, (self._h or 0)
|
|
168
|
+
return BBox(x, y, w, h)
|
|
169
|
+
|
|
170
|
+
def _place_content(self) -> None:
|
|
171
|
+
inner = self._inner_box()
|
|
172
|
+
halign = str(self.prop("text_align", "center")).lower()
|
|
173
|
+
valign = str(self.prop("valign", "center")).lower()
|
|
174
|
+
items = [i for i in (self._content, self._label) if i is not None]
|
|
175
|
+
if not items:
|
|
176
|
+
return
|
|
177
|
+
# Stack content above label when both are present.
|
|
178
|
+
total_h = sum(i.local_bbox.h for i in items)
|
|
179
|
+
y = _valign_start(inner, total_h, valign)
|
|
180
|
+
for item in items:
|
|
181
|
+
ib = item.local_bbox
|
|
182
|
+
x = _halign_start(inner, ib.w, halign)
|
|
183
|
+
if item is self._label:
|
|
184
|
+
item._x = x if not self._label._wrap else inner.x
|
|
185
|
+
if self._label._wrap and inner.w > 0:
|
|
186
|
+
item._w = inner.w
|
|
187
|
+
item._y = y
|
|
188
|
+
if valign in ("center", "middle", "c"):
|
|
189
|
+
lay = self._label.layout
|
|
190
|
+
band = max(0.0, lay.baseline_last - lay.cap_top)
|
|
191
|
+
item._y = inner.cy - band / 2.0 - lay.cap_top
|
|
192
|
+
if len(items) > 1:
|
|
193
|
+
item._y = y
|
|
194
|
+
else:
|
|
195
|
+
item.place_local(x, y, anchor="nw")
|
|
196
|
+
y += ib.h
|
|
197
|
+
|
|
198
|
+
# -- drawing ---------------------------------------------------------
|
|
199
|
+
def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
|
|
200
|
+
raise NotImplementedError
|
|
201
|
+
|
|
202
|
+
def path_data(self, bb: BBox = None) -> str:
|
|
203
|
+
"""Outline of the shape as SVG path data (used by some connectors)."""
|
|
204
|
+
bb = bb or self.local_bbox
|
|
205
|
+
return path_from_points(bb.corners, close=True)
|
|
206
|
+
|
|
207
|
+
def _render_content(self, ctx: RenderContext):
|
|
208
|
+
self._ensure()
|
|
209
|
+
bb = self.local_bbox
|
|
210
|
+
nodes = list(self.shape_nodes(ctx, bb) or [])
|
|
211
|
+
if self._content is not None:
|
|
212
|
+
n = self._content.render(ctx)
|
|
213
|
+
if n is not None:
|
|
214
|
+
nodes.append(n)
|
|
215
|
+
if self._label is not None:
|
|
216
|
+
n = self._label.render(ctx)
|
|
217
|
+
if n is not None:
|
|
218
|
+
nodes.append(n)
|
|
219
|
+
return nodes or None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _halign_start(inner: BBox, w: float, align: str) -> float:
|
|
223
|
+
if align in ("left", "start", "w"):
|
|
224
|
+
return inner.x
|
|
225
|
+
if align in ("right", "end", "e"):
|
|
226
|
+
return inner.x1 - w
|
|
227
|
+
return inner.x + (inner.w - w) / 2.0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _valign_start(inner: BBox, h: float, align: str) -> float:
|
|
231
|
+
if align in ("top", "start", "n"):
|
|
232
|
+
return inner.y
|
|
233
|
+
if align in ("bottom", "end", "s"):
|
|
234
|
+
return inner.y1 - h
|
|
235
|
+
return inner.y + (inner.h - h) / 2.0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ==========================================================================
|
|
239
|
+
# Rectangles
|
|
240
|
+
# ==========================================================================
|
|
241
|
+
|
|
242
|
+
class Box(Shape):
|
|
243
|
+
"""A rectangle with an optional label. The workhorse of most figures.
|
|
244
|
+
|
|
245
|
+
>>> Box("Feature\\nExtractor", w=120, style="block")
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
role = "box"
|
|
249
|
+
|
|
250
|
+
def _radius(self, bb: BBox) -> float:
|
|
251
|
+
r = self.prop("radius", 0) or 0
|
|
252
|
+
if isinstance(r, str):
|
|
253
|
+
if r.lower() in ("pill", "full", "round"):
|
|
254
|
+
return min(bb.w, bb.h) / 2.0
|
|
255
|
+
r = float(r)
|
|
256
|
+
return max(0.0, min(float(r), bb.w / 2.0, bb.h / 2.0))
|
|
257
|
+
|
|
258
|
+
def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
|
|
259
|
+
attrs = paint_attrs(self, ctx, bbox=bb)
|
|
260
|
+
r = self._radius(bb)
|
|
261
|
+
node = Node("rect", x=bb.x, y=bb.y, width=bb.w, height=bb.h, **attrs)
|
|
262
|
+
if r:
|
|
263
|
+
node.set(rx=r, ry=r)
|
|
264
|
+
return [node]
|
|
265
|
+
|
|
266
|
+
def path_data(self, bb: BBox = None) -> str:
|
|
267
|
+
bb = bb or self.local_bbox
|
|
268
|
+
r = self._radius(bb)
|
|
269
|
+
if not r:
|
|
270
|
+
return path_from_points(bb.corners, close=True)
|
|
271
|
+
return rounded_polyline(list(bb.corners), radius=r, close=True)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
Rect = Box
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class Pill(Box):
|
|
278
|
+
"""A fully rounded box (radius = half the height)."""
|
|
279
|
+
|
|
280
|
+
def _radius(self, bb: BBox) -> float:
|
|
281
|
+
r = self.prop("radius", None)
|
|
282
|
+
if r is None or (isinstance(r, str) and r.lower() in ("pill", "full")):
|
|
283
|
+
return min(bb.w, bb.h) / 2.0
|
|
284
|
+
return super()._radius(bb)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
Stadium = Pill
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ==========================================================================
|
|
291
|
+
# Ellipses
|
|
292
|
+
# ==========================================================================
|
|
293
|
+
|
|
294
|
+
class Ellipse(Shape):
|
|
295
|
+
"""An ellipse that grows to fit its label."""
|
|
296
|
+
|
|
297
|
+
role = "ellipse"
|
|
298
|
+
default_padding = (10, 18)
|
|
299
|
+
|
|
300
|
+
def _measure(self) -> None:
|
|
301
|
+
super()._measure()
|
|
302
|
+
if self._label is not None and not (self._explicit_w and self._explicit_h):
|
|
303
|
+
# a label needs sqrt(2) more room inside an ellipse than a box
|
|
304
|
+
top, right, bottom, left = self.padding()
|
|
305
|
+
cw, ch = self._inner_size()
|
|
306
|
+
if not self._explicit_w:
|
|
307
|
+
self._w = max(self.min_w, cw * 1.42 + left + right)
|
|
308
|
+
if not self._explicit_h:
|
|
309
|
+
self._h = max(self.min_h, ch * 1.42 + top + bottom)
|
|
310
|
+
self._place_content()
|
|
311
|
+
|
|
312
|
+
def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
|
|
313
|
+
attrs = paint_attrs(self, ctx, bbox=bb)
|
|
314
|
+
return [Node("ellipse", cx=bb.cx, cy=bb.cy, rx=bb.w / 2.0,
|
|
315
|
+
ry=bb.h / 2.0, **attrs)]
|
|
316
|
+
|
|
317
|
+
def anchor_point(self, name: str) -> Point:
|
|
318
|
+
return self.bbox.at_angle({"e": 0, "se": 45, "s": 90, "sw": 135,
|
|
319
|
+
"w": 180, "nw": 225, "n": 270,
|
|
320
|
+
"ne": 315}.get(name, 0))
|
|
321
|
+
|
|
322
|
+
def path_data(self, bb: BBox = None) -> str:
|
|
323
|
+
bb = bb or self.local_bbox
|
|
324
|
+
rx, ry = bb.w / 2.0, bb.h / 2.0
|
|
325
|
+
return (f"M{fmt(bb.x)} {fmt(bb.cy)}"
|
|
326
|
+
f"A{fmt(rx)} {fmt(ry)} 0 1 0 {fmt(bb.x1)} {fmt(bb.cy)}"
|
|
327
|
+
f"A{fmt(rx)} {fmt(ry)} 0 1 0 {fmt(bb.x)} {fmt(bb.cy)}Z")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class Circle(Ellipse):
|
|
331
|
+
"""An ellipse constrained to a 1:1 aspect ratio."""
|
|
332
|
+
|
|
333
|
+
def __init__(self, text=None, x=0.0, y=0.0, r: float = None, **kw):
|
|
334
|
+
if r is not None:
|
|
335
|
+
kw.setdefault("w", r * 2)
|
|
336
|
+
kw.setdefault("h", r * 2)
|
|
337
|
+
super().__init__(text, x, y, **kw)
|
|
338
|
+
self.aspect = 1.0
|
|
339
|
+
|
|
340
|
+
def _measure(self) -> None:
|
|
341
|
+
super()._measure()
|
|
342
|
+
if not (self._explicit_w and self._explicit_h):
|
|
343
|
+
d = max(self._w or 0, self._h or 0)
|
|
344
|
+
self._w = self._h = d
|
|
345
|
+
self._place_content()
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def radius(self) -> float:
|
|
349
|
+
return self.bbox.w / 2.0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ==========================================================================
|
|
353
|
+
# Polygonal shapes
|
|
354
|
+
# ==========================================================================
|
|
355
|
+
|
|
356
|
+
class _PolyShape(Shape):
|
|
357
|
+
"""Shape defined by fractional ``(u, v)`` vertices inside its bbox."""
|
|
358
|
+
|
|
359
|
+
role = "box"
|
|
360
|
+
points_uv: tuple = ()
|
|
361
|
+
inset = (0.0, 0.0) # extra padding fraction (x, y) for the label
|
|
362
|
+
|
|
363
|
+
def _measure(self) -> None:
|
|
364
|
+
super()._measure()
|
|
365
|
+
fx, fy = self.inset
|
|
366
|
+
if self._label is not None and (fx or fy):
|
|
367
|
+
if not self._explicit_w:
|
|
368
|
+
self._w = self._w * (1 + fx)
|
|
369
|
+
if not self._explicit_h:
|
|
370
|
+
self._h = self._h * (1 + fy)
|
|
371
|
+
self._place_content()
|
|
372
|
+
|
|
373
|
+
def vertices(self, bb: BBox) -> list:
|
|
374
|
+
return [bb.uv(u, v) for u, v in self.points_uv]
|
|
375
|
+
|
|
376
|
+
def path_data(self, bb: BBox = None) -> str:
|
|
377
|
+
bb = bb or self.local_bbox
|
|
378
|
+
r = self.prop("radius", 0) or 0
|
|
379
|
+
pts = self.vertices(bb)
|
|
380
|
+
if r:
|
|
381
|
+
return rounded_polyline(pts, radius=float(r), close=True)
|
|
382
|
+
return path_from_points(pts, close=True)
|
|
383
|
+
|
|
384
|
+
def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
|
|
385
|
+
attrs = paint_attrs(self, ctx, bbox=bb)
|
|
386
|
+
return [Node("path", d=self.path_data(bb), **attrs)]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class Diamond(_PolyShape):
|
|
390
|
+
"""A rhombus — the decision node of a flow chart."""
|
|
391
|
+
|
|
392
|
+
points_uv = ((0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5))
|
|
393
|
+
inset = (0.7, 0.7)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class Triangle(_PolyShape):
|
|
397
|
+
"""An upward-pointing triangle."""
|
|
398
|
+
|
|
399
|
+
points_uv = ((0.5, 0.0), (1.0, 1.0), (0.0, 1.0))
|
|
400
|
+
inset = (0.5, 0.25)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class Hexagon(_PolyShape):
|
|
404
|
+
"""A flat-topped hexagon, often used for a preparation or gate step."""
|
|
405
|
+
|
|
406
|
+
points_uv = ((0.25, 0.0), (0.75, 0.0), (1.0, 0.5),
|
|
407
|
+
(0.75, 1.0), (0.25, 1.0), (0.0, 0.5))
|
|
408
|
+
inset = (0.3, 0.0)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class Parallelogram(_PolyShape):
|
|
412
|
+
"""A slanted box — conventionally input or output."""
|
|
413
|
+
|
|
414
|
+
points_uv = ((0.18, 0.0), (1.0, 0.0), (0.82, 1.0), (0.0, 1.0))
|
|
415
|
+
inset = (0.25, 0.0)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class Chevron(_PolyShape):
|
|
419
|
+
"""An arrow-shaped block, for stages that chain left to right."""
|
|
420
|
+
|
|
421
|
+
points_uv = ((0.0, 0.0), (0.82, 0.0), (1.0, 0.5), (0.82, 1.0),
|
|
422
|
+
(0.0, 1.0), (0.18, 0.5))
|
|
423
|
+
inset = (0.3, 0.0)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class Star(_PolyShape):
|
|
427
|
+
"""An n-pointed star. ``inner`` sets how deep the notches cut."""
|
|
428
|
+
|
|
429
|
+
def __init__(self, text=None, x=0.0, y=0.0, points: int = 5,
|
|
430
|
+
inner: float = 0.42, **kw):
|
|
431
|
+
self.n_points = int(points)
|
|
432
|
+
self.inner_ratio = float(inner)
|
|
433
|
+
super().__init__(text, x, y, **kw)
|
|
434
|
+
if not self._explicit_w:
|
|
435
|
+
self._w = self._w or 40
|
|
436
|
+
if not self._explicit_h:
|
|
437
|
+
self._h = self._h or 40
|
|
438
|
+
|
|
439
|
+
def vertices(self, bb: BBox) -> list:
|
|
440
|
+
pts = []
|
|
441
|
+
n = self.n_points
|
|
442
|
+
for i in range(2 * n):
|
|
443
|
+
r = 1.0 if i % 2 == 0 else self.inner_ratio
|
|
444
|
+
a = -math.pi / 2 + i * math.pi / n
|
|
445
|
+
pts.append(Point(bb.cx + math.cos(a) * r * bb.w / 2,
|
|
446
|
+
bb.cy + math.sin(a) * r * bb.h / 2))
|
|
447
|
+
return pts
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class Cylinder(Shape):
|
|
451
|
+
"""Database / storage symbol."""
|
|
452
|
+
|
|
453
|
+
role = "box"
|
|
454
|
+
default_padding = (16, 14)
|
|
455
|
+
|
|
456
|
+
def __init__(self, text=None, x=0.0, y=0.0, ellipse_h: float = None, **kw):
|
|
457
|
+
self.ellipse_h = ellipse_h
|
|
458
|
+
super().__init__(text, x, y, **kw)
|
|
459
|
+
|
|
460
|
+
def _eh(self, bb: BBox) -> float:
|
|
461
|
+
return (self.ellipse_h if self.ellipse_h is not None
|
|
462
|
+
else min(bb.h * 0.22, bb.w * 0.3))
|
|
463
|
+
|
|
464
|
+
def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
|
|
465
|
+
attrs = paint_attrs(self, ctx, bbox=bb)
|
|
466
|
+
eh = self._eh(bb)
|
|
467
|
+
rx, ry = bb.w / 2.0, eh / 2.0
|
|
468
|
+
body = (f"M{fmt(bb.x)} {fmt(bb.y + ry)}"
|
|
469
|
+
f"A{fmt(rx)} {fmt(ry)} 0 0 1 {fmt(bb.x1)} {fmt(bb.y + ry)}"
|
|
470
|
+
f"L{fmt(bb.x1)} {fmt(bb.y1 - ry)}"
|
|
471
|
+
f"A{fmt(rx)} {fmt(ry)} 0 0 1 {fmt(bb.x)} {fmt(bb.y1 - ry)}Z")
|
|
472
|
+
top = (f"M{fmt(bb.x)} {fmt(bb.y + ry)}"
|
|
473
|
+
f"A{fmt(rx)} {fmt(ry)} 0 0 0 {fmt(bb.x1)} {fmt(bb.y + ry)}")
|
|
474
|
+
line_attrs = dict(attrs)
|
|
475
|
+
line_attrs["fill"] = "none"
|
|
476
|
+
return [Node("path", d=body, **attrs), Node("path", d=top, **line_attrs)]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class Note(Shape):
|
|
480
|
+
"""Document/note shape with a folded corner."""
|
|
481
|
+
|
|
482
|
+
role = "box"
|
|
483
|
+
|
|
484
|
+
def __init__(self, text=None, x=0.0, y=0.0, fold: float = 14.0, **kw):
|
|
485
|
+
self.fold = float(fold)
|
|
486
|
+
super().__init__(text, x, y, **kw)
|
|
487
|
+
|
|
488
|
+
def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
|
|
489
|
+
attrs = paint_attrs(self, ctx, bbox=bb)
|
|
490
|
+
f = min(self.fold, bb.w / 2, bb.h / 2)
|
|
491
|
+
body = (f"M{fmt(bb.x)} {fmt(bb.y)}H{fmt(bb.x1 - f)}L{fmt(bb.x1)} "
|
|
492
|
+
f"{fmt(bb.y + f)}V{fmt(bb.y1)}H{fmt(bb.x)}Z")
|
|
493
|
+
fold = (f"M{fmt(bb.x1 - f)} {fmt(bb.y)}V{fmt(bb.y + f)}H{fmt(bb.x1)}")
|
|
494
|
+
fold_attrs = dict(attrs)
|
|
495
|
+
fold_attrs["fill"] = "none"
|
|
496
|
+
return [Node("path", d=body, **attrs), Node("path", d=fold, **fold_attrs)]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ==========================================================================
|
|
500
|
+
# Pure geometry
|
|
501
|
+
# ==========================================================================
|
|
502
|
+
|
|
503
|
+
class Path(Element):
|
|
504
|
+
"""A raw SVG path. Bounds are computed exactly from the path data."""
|
|
505
|
+
|
|
506
|
+
role = "path"
|
|
507
|
+
STROKE_WIDTH_ALIAS = True
|
|
508
|
+
|
|
509
|
+
def __init__(self, d: str, x: float = None, y: float = None, **kw):
|
|
510
|
+
self._d = d or ""
|
|
511
|
+
x0, y0, x1, y1 = path_bbox(self._d)
|
|
512
|
+
self._src = BBox.from_corners(x0, y0, x1, y1)
|
|
513
|
+
self._scale = (1.0, 1.0)
|
|
514
|
+
super().__init__(self._src.x if x is None else x,
|
|
515
|
+
self._src.y if y is None else y,
|
|
516
|
+
self._src.w, self._src.h, **kw)
|
|
517
|
+
self._explicit_w = self._explicit_h = False
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def d(self) -> str:
|
|
521
|
+
return self._d
|
|
522
|
+
|
|
523
|
+
@d.setter
|
|
524
|
+
def d(self, value: str) -> None:
|
|
525
|
+
self._d = value or ""
|
|
526
|
+
x0, y0, x1, y1 = path_bbox(self._d)
|
|
527
|
+
self._src = BBox.from_corners(x0, y0, x1, y1)
|
|
528
|
+
self._w, self._h = self._src.w, self._src.h
|
|
529
|
+
self.invalidate()
|
|
530
|
+
|
|
531
|
+
def _measure(self) -> None:
|
|
532
|
+
self._w = self._src.w * self._scale[0]
|
|
533
|
+
self._h = self._src.h * self._scale[1]
|
|
534
|
+
|
|
535
|
+
def resize(self, w: float = None, h: float = None, anchor: str = "nw"):
|
|
536
|
+
keep = self.bbox.anchor(anchor)
|
|
537
|
+
sx = (w / self._src.w) if (w and self._src.w) else self._scale[0]
|
|
538
|
+
sy = (h / self._src.h) if (h and self._src.h) else self._scale[1]
|
|
539
|
+
if w and not h:
|
|
540
|
+
sy = sx
|
|
541
|
+
if h and not w:
|
|
542
|
+
sx = sy
|
|
543
|
+
self._scale = (sx, sy)
|
|
544
|
+
self.invalidate()
|
|
545
|
+
self._ensure()
|
|
546
|
+
now = self.bbox.anchor(anchor)
|
|
547
|
+
return self.move(keep.x - now.x, keep.y - now.y)
|
|
548
|
+
|
|
549
|
+
def transformed_d(self) -> str:
|
|
550
|
+
sx, sy = self._scale
|
|
551
|
+
m = (Affine.translate(self._x, self._y) @ Affine.scale(sx, sy)
|
|
552
|
+
@ Affine.translate(-self._src.x, -self._src.y))
|
|
553
|
+
return self._d if m.is_identity else transform_path_data(self._d, m)
|
|
554
|
+
|
|
555
|
+
def _render_content(self, ctx: RenderContext):
|
|
556
|
+
self._ensure()
|
|
557
|
+
attrs = paint_attrs(self, ctx, bbox=self.local_bbox)
|
|
558
|
+
return Node("path", d=self.transformed_d(), **attrs)
|
|
559
|
+
|
|
560
|
+
def path_data(self, bb: BBox = None) -> str:
|
|
561
|
+
return self.transformed_d()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class Polyline(Element):
|
|
565
|
+
"""An open polyline through a list of points (optionally rounded).
|
|
566
|
+
|
|
567
|
+
Points may be raw coordinates *or* live references (anchors/elements).
|
|
568
|
+
With live references the polyline re-resolves them on every read, so it
|
|
569
|
+
follows whatever it was attached to.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
role = "path"
|
|
573
|
+
closed = False
|
|
574
|
+
STROKE_WIDTH_ALIAS = True
|
|
575
|
+
|
|
576
|
+
def __init__(self, points, close: bool = None, **kw):
|
|
577
|
+
self._refs = list(points)
|
|
578
|
+
self._live = any(_is_live(p) for p in self._refs)
|
|
579
|
+
self._points = [to_point(p) for p in self._refs]
|
|
580
|
+
if close is not None:
|
|
581
|
+
self.closed = bool(close)
|
|
582
|
+
bb = BBox.from_points(self._points)
|
|
583
|
+
super().__init__(bb.x, bb.y, bb.w, bb.h, **kw)
|
|
584
|
+
self._src = bb
|
|
585
|
+
self._explicit_w = self._explicit_h = False
|
|
586
|
+
|
|
587
|
+
@property
|
|
588
|
+
def live(self) -> bool:
|
|
589
|
+
return self._live
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def points(self) -> list:
|
|
593
|
+
if self._live:
|
|
594
|
+
return [to_point(r) for r in self._refs]
|
|
595
|
+
off = Point(self._x - self._src.x, self._y - self._src.y)
|
|
596
|
+
return [p + off for p in self._points]
|
|
597
|
+
|
|
598
|
+
@points.setter
|
|
599
|
+
def points(self, value) -> None:
|
|
600
|
+
self._refs = list(value)
|
|
601
|
+
self._live = any(_is_live(p) for p in self._refs)
|
|
602
|
+
self._points = [to_point(p) for p in self._refs]
|
|
603
|
+
self._src = BBox.from_points(self._points)
|
|
604
|
+
self._x, self._y = self._src.x, self._src.y
|
|
605
|
+
self.invalidate()
|
|
606
|
+
|
|
607
|
+
def _measure(self) -> None:
|
|
608
|
+
if self._live:
|
|
609
|
+
bb = BBox.from_points(self.points)
|
|
610
|
+
self._x, self._y, self._w, self._h = bb.x, bb.y, bb.w, bb.h
|
|
611
|
+
else:
|
|
612
|
+
self._w, self._h = self._src.w, self._src.h
|
|
613
|
+
|
|
614
|
+
@property
|
|
615
|
+
def local_bbox(self) -> BBox:
|
|
616
|
+
if self._live:
|
|
617
|
+
self._dirty = True # endpoints may have moved
|
|
618
|
+
self._ensure()
|
|
619
|
+
return BBox(self._x, self._y, self._w or 0.0, self._h or 0.0)
|
|
620
|
+
|
|
621
|
+
def resize(self, w: float = None, h: float = None, anchor: str = "nw"):
|
|
622
|
+
"""Scale the point set (only meaningful for static polylines)."""
|
|
623
|
+
if self._live or not self._src.w or not self._src.h:
|
|
624
|
+
return self
|
|
625
|
+
keep = self.bbox.anchor(anchor)
|
|
626
|
+
sx = (w / self._src.w) if w else 1.0
|
|
627
|
+
sy = (h / self._src.h) if h else 1.0
|
|
628
|
+
if w and not h:
|
|
629
|
+
sy = sx
|
|
630
|
+
if h and not w:
|
|
631
|
+
sx = sy
|
|
632
|
+
ox, oy = self._src.x, self._src.y
|
|
633
|
+
self._points = [Point(ox + (p.x - ox) * sx, oy + (p.y - oy) * sy)
|
|
634
|
+
for p in self._points]
|
|
635
|
+
self._src = BBox.from_points(self._points)
|
|
636
|
+
self.invalidate()
|
|
637
|
+
self._ensure()
|
|
638
|
+
now = self.bbox.anchor(anchor)
|
|
639
|
+
return self.move(keep.x - now.x, keep.y - now.y)
|
|
640
|
+
|
|
641
|
+
def path_data(self, bb: BBox = None) -> str:
|
|
642
|
+
r = self.prop("radius", 0) or 0
|
|
643
|
+
pts = self.points
|
|
644
|
+
if r:
|
|
645
|
+
return rounded_polyline(pts, float(r), close=self.closed)
|
|
646
|
+
return path_from_points(pts, close=self.closed)
|
|
647
|
+
|
|
648
|
+
def _render_content(self, ctx: RenderContext):
|
|
649
|
+
self._ensure()
|
|
650
|
+
attrs = paint_attrs(self, ctx, bbox=self.local_bbox)
|
|
651
|
+
if not self.closed:
|
|
652
|
+
attrs["fill"] = attrs.get("fill", "none")
|
|
653
|
+
if self.prop("fill", None) is None:
|
|
654
|
+
attrs["fill"] = "none"
|
|
655
|
+
return Node("path", d=self.path_data(), **attrs)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
class Polygon(Polyline):
|
|
659
|
+
"""A closed polyline."""
|
|
660
|
+
|
|
661
|
+
role = "box"
|
|
662
|
+
closed = True
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class Line(Polyline):
|
|
666
|
+
"""A straight segment between two points, anchors or elements."""
|
|
667
|
+
|
|
668
|
+
role = "line"
|
|
669
|
+
|
|
670
|
+
def __init__(self, start, end, **kw):
|
|
671
|
+
super().__init__([start, end], **kw)
|
|
672
|
+
|
|
673
|
+
@property
|
|
674
|
+
def start(self) -> Point:
|
|
675
|
+
return self.points[0]
|
|
676
|
+
|
|
677
|
+
@property
|
|
678
|
+
def end(self) -> Point:
|
|
679
|
+
return self.points[1]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _is_live(obj) -> bool:
|
|
683
|
+
"""True if ``obj`` resolves to a different point when things move."""
|
|
684
|
+
from .core import Anchor
|
|
685
|
+
return isinstance(obj, (Anchor, Element))
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
class Dot(Element):
|
|
689
|
+
"""A small filled circle — plot markers, junction points, bullets."""
|
|
690
|
+
|
|
691
|
+
role = "marker"
|
|
692
|
+
|
|
693
|
+
def __init__(self, center=(0, 0), r: float = 3.0, **kw):
|
|
694
|
+
p = to_point(center)
|
|
695
|
+
self._r = float(r)
|
|
696
|
+
super().__init__(p.x - r, p.y - r, r * 2, r * 2, **kw)
|
|
697
|
+
|
|
698
|
+
@property
|
|
699
|
+
def r(self) -> float:
|
|
700
|
+
return self.bbox.w / 2.0
|
|
701
|
+
|
|
702
|
+
def _measure(self) -> None:
|
|
703
|
+
if self._w is None:
|
|
704
|
+
self._w = self._r * 2
|
|
705
|
+
if self._h is None:
|
|
706
|
+
self._h = self._r * 2
|
|
707
|
+
|
|
708
|
+
def _render_content(self, ctx: RenderContext):
|
|
709
|
+
self._ensure()
|
|
710
|
+
bb = self.local_bbox
|
|
711
|
+
attrs = paint_attrs(self, ctx, bbox=bb)
|
|
712
|
+
return Node("ellipse", cx=bb.cx, cy=bb.cy, rx=bb.w / 2,
|
|
713
|
+
ry=bb.h / 2, **attrs)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class Marker(Element):
|
|
717
|
+
"""A plot marker: circle, square, diamond, triangle, cross, plus, x."""
|
|
718
|
+
|
|
719
|
+
role = "marker"
|
|
720
|
+
SHAPES = ("circle", "square", "diamond", "triangle", "cross", "plus",
|
|
721
|
+
"x", "star")
|
|
722
|
+
|
|
723
|
+
def __init__(self, center=(0, 0), size: float = 7.0, shape: str = "circle",
|
|
724
|
+
**kw):
|
|
725
|
+
p = to_point(center)
|
|
726
|
+
self.shape = str(shape).lower()
|
|
727
|
+
super().__init__(p.x - size / 2, p.y - size / 2, size, size, **kw)
|
|
728
|
+
|
|
729
|
+
def _render_content(self, ctx: RenderContext):
|
|
730
|
+
self._ensure()
|
|
731
|
+
bb = self.local_bbox
|
|
732
|
+
attrs = paint_attrs(self, ctx, bbox=bb)
|
|
733
|
+
s = self.shape
|
|
734
|
+
if s == "circle":
|
|
735
|
+
return Node("ellipse", cx=bb.cx, cy=bb.cy, rx=bb.w / 2,
|
|
736
|
+
ry=bb.h / 2, **attrs)
|
|
737
|
+
if s == "square":
|
|
738
|
+
return Node("rect", x=bb.x, y=bb.y, width=bb.w, height=bb.h, **attrs)
|
|
739
|
+
if s in ("cross", "plus", "x"):
|
|
740
|
+
line_attrs = dict(attrs)
|
|
741
|
+
line_attrs["fill"] = "none"
|
|
742
|
+
if line_attrs.get("stroke", "none") == "none":
|
|
743
|
+
line_attrs["stroke"] = self.prop("fill", "#000")
|
|
744
|
+
line_attrs.setdefault("stroke-width",
|
|
745
|
+
self.prop("stroke_width", 1.5) or 1.5)
|
|
746
|
+
if s == "plus":
|
|
747
|
+
d = (f"M{fmt(bb.x)} {fmt(bb.cy)}H{fmt(bb.x1)}"
|
|
748
|
+
f"M{fmt(bb.cx)} {fmt(bb.y)}V{fmt(bb.y1)}")
|
|
749
|
+
else:
|
|
750
|
+
d = (f"M{fmt(bb.x)} {fmt(bb.y)}L{fmt(bb.x1)} {fmt(bb.y1)}"
|
|
751
|
+
f"M{fmt(bb.x1)} {fmt(bb.y)}L{fmt(bb.x)} {fmt(bb.y1)}")
|
|
752
|
+
return Node("path", d=d, **line_attrs)
|
|
753
|
+
uv = {"diamond": ((0.5, 0), (1, 0.5), (0.5, 1), (0, 0.5)),
|
|
754
|
+
"triangle": ((0.5, 0), (1, 1), (0, 1))}.get(s)
|
|
755
|
+
if uv:
|
|
756
|
+
return Node("path", d=path_from_points([bb.uv(*p) for p in uv],
|
|
757
|
+
close=True), **attrs)
|
|
758
|
+
pts = []
|
|
759
|
+
for i in range(10):
|
|
760
|
+
r = 1.0 if i % 2 == 0 else 0.45
|
|
761
|
+
a = -math.pi / 2 + i * math.pi / 5
|
|
762
|
+
pts.append(Point(bb.cx + math.cos(a) * r * bb.w / 2,
|
|
763
|
+
bb.cy + math.sin(a) * r * bb.h / 2))
|
|
764
|
+
return Node("path", d=path_from_points(pts, close=True), **attrs)
|