svgblock 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.
svgblock/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .animations import Animation
2
+ from .block import Block
3
+ from .themes import THEMES
4
+ from .composition import Column
5
+
6
+ __all__ = ["Animation", "Block", "THEMES", "Column"]
svgblock/animations.py ADDED
@@ -0,0 +1,69 @@
1
+ from enum import Enum
2
+
3
+ ANIMATION_DURATION_SECONDS = 0.8
4
+ SLIDE_DISTANCE_PX = 24
5
+ POP_START_SCALE = 0.92
6
+
7
+
8
+ class Animation(str, Enum):
9
+ FADE = "fade"
10
+ SLIDE_UP = "slide-up"
11
+ SLIDE_DOWN = "slide-down"
12
+ SLIDE_LEFT = "slide-left"
13
+ SLIDE_RIGHT = "slide-right"
14
+ POP = "pop"
15
+
16
+
17
+ ENTRY_CLASS_NAME = "entry"
18
+
19
+ _START_STATES: dict[Animation, str] = {
20
+ Animation.FADE: "opacity: 0;",
21
+ Animation.SLIDE_UP: f"opacity: 0; transform: translateY({SLIDE_DISTANCE_PX}px);",
22
+ Animation.SLIDE_DOWN: f"opacity: 0; transform: translateY(-{SLIDE_DISTANCE_PX}px);",
23
+ Animation.SLIDE_LEFT: f"opacity: 0; transform: translateX({SLIDE_DISTANCE_PX}px);",
24
+ Animation.SLIDE_RIGHT: f"opacity: 0; transform: translateX(-{SLIDE_DISTANCE_PX}px);",
25
+ Animation.POP: f"opacity: 0; transform: scale({POP_START_SCALE});",
26
+ }
27
+
28
+ _END_STATE = "opacity: 1; transform: none;"
29
+
30
+
31
+ def validate_animation_loop(animation_loop: float | None) -> None:
32
+ if animation_loop is None:
33
+ return
34
+ if animation_loop <= ANIMATION_DURATION_SECONDS:
35
+ raise ValueError(
36
+ f"animation_loop must be greater than {ANIMATION_DURATION_SECONDS} "
37
+ f"seconds (the duration of the animation), got {animation_loop!r}."
38
+ )
39
+
40
+
41
+ def build_animation_css(animation: Animation, loop: float | None = None) -> str:
42
+ if loop is None:
43
+ keyframes = (
44
+ f"from {{ {_START_STATES[animation]} }} "
45
+ f"to {{ {_END_STATE} }}"
46
+ )
47
+ rule = f"animation: entry {ANIMATION_DURATION_SECONDS}s ease-out both;"
48
+ else:
49
+ percent = ANIMATION_DURATION_SECONDS / loop * 100
50
+ keyframes = (
51
+ f"0% {{ {_START_STATES[animation]} }} "
52
+ f"{percent:.3f}% {{ {_END_STATE} }} "
53
+ f"100% {{ {_END_STATE} }}"
54
+ )
55
+ rule = f"animation: entry {loop:g}s ease-out infinite;"
56
+
57
+ return f"""
58
+ @keyframes entry {{
59
+ {keyframes}
60
+ }}
61
+ .{ENTRY_CLASS_NAME} {{
62
+ {rule}
63
+ transform-box: fill-box;
64
+ transform-origin: center;
65
+ }}
66
+ @media (prefers-reduced-motion: reduce) {{
67
+ .{ENTRY_CLASS_NAME} {{ animation: none; }}
68
+ }}
69
+ """
svgblock/block.py ADDED
@@ -0,0 +1,94 @@
1
+ from pathlib import Path
2
+
3
+ from .animations import (
4
+ ENTRY_CLASS_NAME,
5
+ Animation,
6
+ build_animation_css,
7
+ validate_animation_loop,
8
+ )
9
+ from .composition import Composer
10
+ from .frame import build_frame
11
+ from .themes import THEMES, Theme
12
+
13
+ DEFAULT_THEME_NAME = "red"
14
+ DEFAULT_WIDTH = 800
15
+ DEFAULT_ANIMATION = None
16
+
17
+ BLOCK_PADDING = 24
18
+
19
+ FONT_FAMILY = "Arial, Helvetica, sans-serif"
20
+
21
+
22
+ class Block(Composer):
23
+
24
+ def __init__(
25
+ self,
26
+ theme: str = DEFAULT_THEME_NAME,
27
+ width: float = DEFAULT_WIDTH,
28
+ animation: Animation | str | None = DEFAULT_ANIMATION,
29
+ frame: bool = True,
30
+ animation_loop: float | None = None,
31
+ ) -> None:
32
+ super().__init__()
33
+ self._theme = _find_theme(theme)
34
+ self._width = width
35
+ self._animation = _parse_animation(animation)
36
+ if animation_loop is not None and self._animation is None:
37
+ raise ValueError(
38
+ "animation_loop needs an animation. Pass animation=... too.")
39
+ validate_animation_loop(animation_loop)
40
+ self._animation_loop = animation_loop
41
+ self._frame = frame
42
+
43
+ def render(self) -> str:
44
+ padding = BLOCK_PADDING if self._frame else 0
45
+ content_width = self._width - 2 * padding
46
+ rendered = self._render_components(
47
+ self._theme, padding, padding, content_width
48
+ )
49
+ height = rendered.height + 2 * padding
50
+ frame_markup = (
51
+ build_frame(self._theme, self._width,
52
+ height) if self._frame else ""
53
+ )
54
+ if self._animation is None:
55
+ style = ""
56
+ open_group = "<g>"
57
+ else:
58
+ css = build_animation_css(self._animation, self._animation_loop)
59
+ style = f"<style>{css}</style>"
60
+ open_group = f'<g class="{ENTRY_CLASS_NAME}">'
61
+
62
+ return (
63
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{self._width:g}" '
64
+ f'height="{height:g}" viewBox="0 0 {self._width:g} {height:g}">'
65
+ f"{style}"
66
+ f"{open_group}"
67
+ f"{frame_markup}"
68
+ f'<g font-family="{FONT_FAMILY}">{rendered.svg}</g>'
69
+ "</g></svg>"
70
+ )
71
+
72
+ def save(self, path: str | Path) -> None:
73
+ file_path = Path(path)
74
+ file_path.parent.mkdir(parents=True, exist_ok=True)
75
+ file_path.write_text(self.render(), encoding="utf-8")
76
+
77
+
78
+ def _find_theme(name: str) -> Theme:
79
+ try:
80
+ return THEMES[name.lower().replace(" ", "").replace("-", "").replace("_", "")]
81
+ except KeyError:
82
+ raise ValueError(
83
+ f"Unknown theme {name!r}. Available: {', '.join(THEMES)}") from None
84
+
85
+
86
+ def _parse_animation(animation: Animation | str | None) -> Animation | None:
87
+ if animation is None:
88
+ return None
89
+ try:
90
+ return Animation(animation)
91
+ except ValueError:
92
+ options = ", ".join(option.value for option in Animation)
93
+ raise ValueError(
94
+ f"Unknown animation {animation!r}. Available: {options}") from None
svgblock/components.py ADDED
@@ -0,0 +1,451 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, Protocol
3
+ from xml.sax.saxutils import escape
4
+
5
+ from .image_reader import ImageData
6
+ from .line_parser import wrap_into_lines
7
+ from .markup import Style, TextSegment, parse_markup
8
+ from .themes import Theme
9
+
10
+ LINE_HEIGHT_RATIO = 1.5
11
+ BASELINE_RATIO = 1.1
12
+ BOLD_WEIGHT = 700
13
+
14
+ TITLE_BAR_WIDTH = 4
15
+ TITLE_BAR_HEIGHT_RATIO = 1.2
16
+ TITLE_BAR_GAP = 11
17
+
18
+ SUBSECTION_MARKER_RADIUS_RATIO = 0.18
19
+ SUBSECTION_MARKER_GAP = 8
20
+
21
+ Alignment = Literal["left", "center", "right"]
22
+ CellValue = str | int | float | bool | None
23
+
24
+ TABLE_FONT_SIZE = 14
25
+ TABLE_CELL_PADDING = 8
26
+ TABLE_BORDER_RADIUS = 8
27
+ TABLE_LINE_WIDTH = 1
28
+
29
+ TRUE_SYMBOL = "✓"
30
+ FALSE_SYMBOL = "✗"
31
+
32
+ _TEXT_ANCHORS: dict[Alignment, str] = {
33
+ "left": "start",
34
+ "center": "middle",
35
+ "right": "end",
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class RenderedComponent:
41
+ svg: str
42
+ height: float
43
+
44
+
45
+ class Component(Protocol):
46
+ def render(
47
+ self, theme: Theme, left: float, top: float, width: float
48
+ ) -> RenderedComponent: ...
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Title:
53
+ text: str
54
+ font_size: float
55
+
56
+ def render(self, theme: Theme, left: float, top: float, width: float) -> RenderedComponent:
57
+ line_height = _line_height(self.font_size)
58
+ text_offset = TITLE_BAR_WIDTH + TITLE_BAR_GAP
59
+ lines = _wrap_bold_text(self.text, self.font_size, width - text_offset)
60
+
61
+ single_bar_height = self.font_size * TITLE_BAR_HEIGHT_RATIO
62
+ bar_top = top + (line_height - single_bar_height) / 2
63
+ bar_height = single_bar_height + (len(lines) - 1) * line_height
64
+ bar = (
65
+ f'<rect x="{left:.1f}" y="{bar_top:.1f}" width="{TITLE_BAR_WIDTH}" '
66
+ f'height="{bar_height:.1f}" rx="{TITLE_BAR_WIDTH / 2:g}" fill="{theme["title_bar"]}"/>'
67
+ )
68
+ text = _bold_lines_markup(
69
+ lines,
70
+ x=left + text_offset,
71
+ top=top,
72
+ font_size=self.font_size,
73
+ fill=theme["title_text"],
74
+ )
75
+ return RenderedComponent(svg=bar + text, height=len(lines) * line_height)
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Subsection:
80
+ text: str
81
+ font_size: float
82
+
83
+ def render(self, theme: Theme, left: float, top: float, width: float) -> RenderedComponent:
84
+ line_height = _line_height(self.font_size)
85
+ radius = self.font_size * SUBSECTION_MARKER_RADIUS_RATIO
86
+ text_offset = 2 * radius + SUBSECTION_MARKER_GAP
87
+ lines = _wrap_bold_text(self.text, self.font_size, width - text_offset)
88
+
89
+ marker = (
90
+ f'<circle cx="{left + radius:.1f}" cy="{top + line_height / 2:.1f}" '
91
+ f'r="{radius:.1f}" fill="{theme["subsection_marker"]}"/>'
92
+ )
93
+ text = _bold_lines_markup(
94
+ lines,
95
+ x=left + text_offset,
96
+ top=top,
97
+ font_size=self.font_size,
98
+ fill=theme["subsection_text"],
99
+ )
100
+ return RenderedComponent(svg=marker + text, height=len(lines) * line_height)
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Paragraph:
105
+ text: str
106
+ font_size: float
107
+
108
+ def render(self, theme: Theme, left: float, top: float, width: float) -> RenderedComponent:
109
+ segments = parse_markup(self.text)
110
+ lines = wrap_into_lines(segments, self.font_size, width)
111
+ line_height = _line_height(self.font_size)
112
+ svg = "".join(
113
+ _text_element(
114
+ x=left,
115
+ baseline=_baseline(top + index * line_height, self.font_size),
116
+ font_size=self.font_size,
117
+ fill=theme["text"],
118
+ content=_segments_markup(line, theme),
119
+ )
120
+ for index, line in enumerate(lines)
121
+ )
122
+ return RenderedComponent(svg=svg, height=len(lines) * line_height)
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Image:
127
+ image: ImageData
128
+ requested_width: float | None = None
129
+
130
+ def render(self, theme: Theme, left: float, top: float, width: float) -> RenderedComponent:
131
+ target_width = (
132
+ self.requested_width
133
+ if self.requested_width is not None
134
+ else self.image.width
135
+ )
136
+ final_width = min(target_width, width)
137
+ if final_width <= 0:
138
+ return RenderedComponent(svg="", height=0)
139
+
140
+ final_height = final_width * self.image.height / self.image.width
141
+ svg = (
142
+ f'<image x="{left:.1f}" y="{top:.1f}" width="{final_width:.1f}" '
143
+ f'height="{final_height:.1f}" href="{self.image.data_uri}"/>'
144
+ )
145
+ return RenderedComponent(svg=svg, height=final_height)
146
+
147
+
148
+ def _line_height(font_size: float) -> float:
149
+ return font_size * LINE_HEIGHT_RATIO
150
+
151
+
152
+ def _baseline(top: float, font_size: float) -> float:
153
+ return top + font_size * BASELINE_RATIO
154
+
155
+
156
+ def _wrap_bold_text(text: str, font_size: float, max_width: float) -> list[str]:
157
+ segments = [TextSegment(text=text)]
158
+ lines = wrap_into_lines(segments, font_size, max_width, bold=True)
159
+ return ["".join(segment.text for segment in line) for line in lines] or [""]
160
+
161
+
162
+ def _bold_lines_markup(
163
+ lines: list[str], x: float, top: float, font_size: float, fill: str
164
+ ) -> str:
165
+ line_height = _line_height(font_size)
166
+ return "".join(
167
+ _text_element(
168
+ x=x,
169
+ baseline=_baseline(top + index * line_height, font_size),
170
+ font_size=font_size,
171
+ fill=fill,
172
+ content=escape(line),
173
+ bold=True,
174
+ )
175
+ for index, line in enumerate(lines)
176
+ if line
177
+ )
178
+
179
+
180
+ def _text_element(
181
+ x: float,
182
+ baseline: float,
183
+ font_size: float,
184
+ fill: str,
185
+ content: str,
186
+ bold: bool = False,
187
+ anchor: str = "start",
188
+ ) -> str:
189
+ weight = f' font-weight="{BOLD_WEIGHT}"' if bold else ""
190
+ anchor_attribute = f' text-anchor="{anchor}"' if anchor != "start" else ""
191
+ return (
192
+ f'<text x="{x:.1f}" y="{baseline:.1f}" font-size="{font_size:g}" '
193
+ f'fill="{fill}"{weight}{anchor_attribute}>{content}</text>'
194
+ )
195
+
196
+
197
+ def _segments_markup(segments: list[TextSegment], theme: Theme) -> str:
198
+ return "".join(_segment_markup(segment, theme) for segment in segments)
199
+
200
+
201
+ def _segment_markup(segment: TextSegment, theme: Theme) -> str:
202
+ content = escape(segment.text)
203
+ if not segment.styles:
204
+ return content
205
+
206
+ innermost_style = segment.styles[-1]
207
+ attributes = [f'fill="{_style_color(theme, innermost_style)}"']
208
+ if segment.is_bold:
209
+ attributes.append(f'font-weight="{BOLD_WEIGHT}"')
210
+ if Style.ITALIC in segment.styles:
211
+ attributes.append('font-style="italic"')
212
+ decorations = _text_decorations(segment.styles)
213
+ if decorations:
214
+ attributes.append(f'text-decoration="{decorations}"')
215
+ return f'<tspan {" ".join(attributes)}>{content}</tspan>'
216
+
217
+
218
+ def _style_color(theme: Theme, style: Style) -> str:
219
+ colors: dict[Style, str] = {
220
+ Style.HIGHLIGHT: theme["highlight"],
221
+ Style.UNDERLINE: theme["underline"],
222
+ Style.STRIKETHROUGH: theme["strikethrough"],
223
+ Style.ITALIC: theme["italic"],
224
+ }
225
+ return colors[style]
226
+
227
+
228
+ def _text_decorations(styles: tuple[Style, ...]) -> str:
229
+ decorations: list[str] = []
230
+ if Style.UNDERLINE in styles:
231
+ decorations.append("underline")
232
+ if Style.STRIKETHROUGH in styles:
233
+ decorations.append("line-through")
234
+ return " ".join(decorations)
235
+
236
+
237
+ def format_cell_value(value: CellValue) -> str:
238
+ if value is None:
239
+ return ""
240
+ if isinstance(value, bool):
241
+ return TRUE_SYMBOL if value else FALSE_SYMBOL
242
+ return str(value)
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class Table:
247
+ columns: tuple[str, ...]
248
+ rows: tuple[tuple[str, ...], ...]
249
+ font_size: float
250
+ cell_padding: float
251
+ border_radius: float
252
+ alignments: tuple[Alignment, ...]
253
+ weights: tuple[float, ...]
254
+ striped: bool
255
+ row_dividers: bool
256
+ column_dividers: bool
257
+ highlighted_words: tuple[str, ...] = ()
258
+
259
+ def render(self, theme: Theme, left: float, top: float, width: float) -> RenderedComponent:
260
+ total_weight = sum(self.weights)
261
+ column_widths = [width * weight /
262
+ total_weight for weight in self.weights]
263
+ column_lefts = _running_positions(left, column_widths)
264
+
265
+ header = [
266
+ [
267
+ escape(line)
268
+ for line in _wrap_bold_text(
269
+ name, self.font_size, column_width - 2 * self.cell_padding
270
+ )
271
+ ]
272
+ for name, column_width in zip(self.columns, column_widths)
273
+ ]
274
+ body = [
275
+ [
276
+ self._body_cell(text, theme, column_width)
277
+ for text, column_width in zip(row, column_widths)
278
+ ]
279
+ for row in self.rows
280
+ ]
281
+ table_rows = [header, *body]
282
+
283
+ row_heights = [self._row_height(row) for row in table_rows]
284
+ row_tops = _running_positions(top, row_heights)
285
+ height = sum(row_heights)
286
+
287
+ svg = (
288
+ self._background_markup(
289
+ theme, left, top, width, height, row_tops, row_heights)
290
+ + self._dividers_markup(theme, left, top, width,
291
+ height, column_lefts, row_tops, row_heights)
292
+ + self._text_markup(theme, table_rows,
293
+ column_lefts, column_widths, row_tops)
294
+ + self._border_markup(theme, left, top, width, height)
295
+ )
296
+ return RenderedComponent(svg=svg, height=height)
297
+
298
+ def _body_cell(self, text: str, theme: Theme, column_width: float) -> list[str]:
299
+ segments = parse_markup(text)
300
+ lines = wrap_into_lines(segments, self.font_size,
301
+ column_width - 2 * self.cell_padding)
302
+ return [_segments_markup(line, theme) for line in lines]
303
+
304
+ def _row_height(self, row: list[list[str]]) -> float:
305
+ line_count = max([1, *(len(cell) for cell in row)])
306
+ return line_count * _line_height(self.font_size) + 2 * self.cell_padding
307
+
308
+ def _background_markup(
309
+ self, theme: Theme, left: float, top: float, width: float, height: float,
310
+ row_tops: list[float], row_heights: list[float],
311
+ ) -> str:
312
+ clip_id = f"table-clip-{left:.0f}-{top:.0f}"
313
+ clip = (
314
+ f'<clipPath id="{clip_id}"><rect x="{left:.1f}" y="{top:.1f}" '
315
+ f'width="{width:.1f}" height="{height:.1f}" rx="{self.border_radius:g}"/></clipPath>'
316
+ )
317
+ stripes = ""
318
+ if self.striped:
319
+ stripes = "".join(
320
+ _filled_rect(left, row_tops[index], width, row_heights[index],
321
+ theme["table_stripe_background"])
322
+ for index in range(2, len(row_tops), 2)
323
+ )
324
+ return (
325
+ clip
326
+ + f'<g clip-path="url(#{clip_id})">'
327
+ + _filled_rect(left, top, width, height, theme["table_background"])
328
+ + _filled_rect(left, top, width,
329
+ row_heights[0], theme["table_header_background"])
330
+ + stripes
331
+ + "</g>"
332
+ )
333
+
334
+ def _dividers_markup(
335
+ self, theme: Theme, left: float, top: float, width: float, height: float,
336
+ column_lefts: list[float], row_tops: list[float], row_heights: list[float],
337
+ ) -> str:
338
+ color = theme["table_divider"]
339
+ lines: list[str] = []
340
+ for row_index in range(len(row_tops) - 1):
341
+ if row_index == 0 or self.row_dividers:
342
+ y = row_tops[row_index] + row_heights[row_index]
343
+ lines.append(_line(left, y, left + width, y, color))
344
+ if self.column_dividers:
345
+ for column_left in column_lefts[1:]:
346
+ lines.append(
347
+ _line(column_left, top, column_left, top + height, color))
348
+ return "".join(lines)
349
+
350
+ def _text_markup(
351
+ self, theme: Theme, table_rows: list[list[list[str]]],
352
+ column_lefts: list[float], column_widths: list[float], row_tops: list[float],
353
+ ) -> str:
354
+ parts: list[str] = []
355
+ for row_index, row in enumerate(table_rows):
356
+ is_header = row_index == 0
357
+ fill = theme["table_header_text"] if is_header else theme["table_text"]
358
+ for column_index, line_markups in enumerate(row):
359
+ alignment = self.alignments[column_index]
360
+ parts.append(
361
+ _cell_lines_markup(
362
+ line_markups,
363
+ x=self._text_x(
364
+ alignment, column_lefts[column_index], column_widths[column_index]),
365
+ top=row_tops[row_index] + self.cell_padding,
366
+ font_size=self.font_size,
367
+ fill=fill,
368
+ anchor=_TEXT_ANCHORS[alignment],
369
+ bold=is_header,
370
+ )
371
+ )
372
+ return "".join(parts)
373
+
374
+ def _text_x(self, alignment: Alignment, column_left: float, column_width: float) -> float:
375
+ if alignment == "left":
376
+ return column_left + self.cell_padding
377
+ if alignment == "right":
378
+ return column_left + column_width - self.cell_padding
379
+ return column_left + column_width / 2
380
+
381
+ def _border_markup(
382
+ self, theme: Theme, left: float, top: float, width: float, height: float
383
+ ) -> str:
384
+ inset = TABLE_LINE_WIDTH / 2
385
+ return (
386
+ f'<rect x="{left + inset:.1f}" y="{top + inset:.1f}" '
387
+ f'width="{width - TABLE_LINE_WIDTH:.1f}" height="{height - TABLE_LINE_WIDTH:.1f}" '
388
+ f'rx="{self.border_radius:g}" fill="none" stroke="{theme["table_border"]}" '
389
+ f'stroke-width="{TABLE_LINE_WIDTH}"/>'
390
+ )
391
+
392
+
393
+ def _running_positions(start: float, sizes: list[float]) -> list[float]:
394
+ positions: list[float] = []
395
+ current = start
396
+ for size in sizes:
397
+ positions.append(current)
398
+ current += size
399
+ return positions
400
+
401
+
402
+ def _filled_rect(x: float, y: float, width: float, height: float, fill: str) -> str:
403
+ return (
404
+ f'<rect x="{x:.1f}" y="{y:.1f}" width="{width:.1f}" '
405
+ f'height="{height:.1f}" fill="{fill}"/>'
406
+ )
407
+
408
+
409
+ def _line(x1: float, y1: float, x2: float, y2: float, color: str) -> str:
410
+ return (
411
+ f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
412
+ f'stroke="{color}" stroke-width="{TABLE_LINE_WIDTH}"/>'
413
+ )
414
+
415
+
416
+ def _cell_lines_markup(
417
+ contents: list[str], x: float, top: float, font_size: float,
418
+ fill: str, anchor: str, bold: bool,
419
+ ) -> str:
420
+ line_height = _line_height(font_size)
421
+ return "".join(
422
+ _text_element(
423
+ x=x,
424
+ baseline=_baseline(top + index * line_height, font_size),
425
+ font_size=font_size,
426
+ fill=fill,
427
+ content=content,
428
+ bold=bold,
429
+ anchor=anchor,
430
+ )
431
+ for index, content in enumerate(contents)
432
+ )
433
+
434
+
435
+ DIVIDER_THICKNESS = 2
436
+ DIVIDER_COVERAGE = 1
437
+
438
+
439
+ @dataclass(frozen=True)
440
+ class Divider:
441
+ coverage: float
442
+
443
+ def render(self, theme: Theme, left: float, top: float, width: float) -> RenderedComponent:
444
+ line_width = width * self.coverage
445
+ line_left = left + (width - line_width) / 2
446
+ svg = (
447
+ f'<rect x="{line_left:.1f}" y="{top:.1f}" width="{line_width:.1f}" '
448
+ f'height="{DIVIDER_THICKNESS}" rx="{DIVIDER_THICKNESS / 2:g}" '
449
+ f'fill="{theme["divider"]}"/>'
450
+ )
451
+ return RenderedComponent(svg=svg, height=DIVIDER_THICKNESS)