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 +6 -0
- svgblock/animations.py +69 -0
- svgblock/block.py +94 -0
- svgblock/components.py +451 -0
- svgblock/composition.py +174 -0
- svgblock/frame.py +44 -0
- svgblock/image_reader.py +99 -0
- svgblock/line_parser.py +118 -0
- svgblock/markup.py +90 -0
- svgblock/py.typed +0 -0
- svgblock/themes.py +658 -0
- svgblock/width_guard.py +57 -0
- svgblock-0.1.0.dist-info/METADATA +446 -0
- svgblock-0.1.0.dist-info/RECORD +17 -0
- svgblock-0.1.0.dist-info/WHEEL +5 -0
- svgblock-0.1.0.dist-info/licenses/LICENSE +21 -0
- svgblock-0.1.0.dist-info/top_level.txt +1 -0
svgblock/__init__.py
ADDED
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)
|