codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/render_svg.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""SVG drawing helpers and document assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .fills import resolve_paint
|
|
9
|
+
from .styles import DEFAULT_THEME, Theme, current_theme
|
|
10
|
+
from .textutil import (
|
|
11
|
+
cjk_font_family_css,
|
|
12
|
+
contains_cjk,
|
|
13
|
+
expand_subscripts,
|
|
14
|
+
has_math_markup,
|
|
15
|
+
latin_font_family_css,
|
|
16
|
+
needs_script_split,
|
|
17
|
+
sym_font_family_css,
|
|
18
|
+
math_lines,
|
|
19
|
+
script_runs,
|
|
20
|
+
xml_escape,
|
|
21
|
+
has_rich_markup,
|
|
22
|
+
parse_rich,
|
|
23
|
+
run_width,
|
|
24
|
+
run_widths,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _opacity_attrs(opacity: float) -> str:
|
|
29
|
+
if opacity >= 0.999:
|
|
30
|
+
return ""
|
|
31
|
+
return f' opacity="{opacity:.3f}"'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _dash_attr(dasharray: Optional[str]) -> str:
|
|
35
|
+
if not dasharray:
|
|
36
|
+
return ""
|
|
37
|
+
return f' stroke-dasharray="{dasharray}"'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def draw_rect(
|
|
41
|
+
buf: List[str],
|
|
42
|
+
x: float,
|
|
43
|
+
y: float,
|
|
44
|
+
w: float,
|
|
45
|
+
h: float,
|
|
46
|
+
*,
|
|
47
|
+
fill: str = "#fff",
|
|
48
|
+
stroke: str = "#111",
|
|
49
|
+
stroke_width: float = 2.0,
|
|
50
|
+
radius: float = 0.0,
|
|
51
|
+
opacity: float = 1.0,
|
|
52
|
+
dasharray: Optional[str] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
r_attr = f' rx="{radius}" ry="{radius}"' if radius else ""
|
|
55
|
+
fill = resolve_paint(buf, fill)
|
|
56
|
+
buf.append(
|
|
57
|
+
f'<rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}"'
|
|
58
|
+
f' fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}"'
|
|
59
|
+
f"{r_attr}{_dash_attr(dasharray)}{_opacity_attrs(opacity)} />"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def draw_line(
|
|
64
|
+
buf: List[str],
|
|
65
|
+
x1: float,
|
|
66
|
+
y1: float,
|
|
67
|
+
x2: float,
|
|
68
|
+
y2: float,
|
|
69
|
+
*,
|
|
70
|
+
stroke: str = "#111",
|
|
71
|
+
stroke_width: float = 2.0,
|
|
72
|
+
opacity: float = 1.0,
|
|
73
|
+
dasharray: Optional[str] = None,
|
|
74
|
+
marker_end: bool = False,
|
|
75
|
+
marker_start: bool = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
me = ' marker-end="url(#arrowhead)"' if marker_end else ""
|
|
78
|
+
ms = ' marker-start="url(#arrowhead-start)"' if marker_start else ""
|
|
79
|
+
buf.append(
|
|
80
|
+
f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}"'
|
|
81
|
+
f' stroke="{stroke}" stroke-width="{stroke_width}"'
|
|
82
|
+
f"{_dash_attr(dasharray)}{_opacity_attrs(opacity)}{me}{ms} />"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def draw_circle(
|
|
88
|
+
buf: List[str],
|
|
89
|
+
cx: float,
|
|
90
|
+
cy: float,
|
|
91
|
+
r: float,
|
|
92
|
+
*,
|
|
93
|
+
fill: str = "#888",
|
|
94
|
+
stroke: str = "none",
|
|
95
|
+
stroke_width: float = 0.0,
|
|
96
|
+
opacity: float = 1.0,
|
|
97
|
+
dasharray: Optional[str] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
fill = resolve_paint(buf, fill)
|
|
100
|
+
buf.append(
|
|
101
|
+
f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{r:.2f}"'
|
|
102
|
+
f' fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}"'
|
|
103
|
+
f"{_dash_attr(dasharray)}{_opacity_attrs(opacity)} />"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def draw_ellipse(
|
|
108
|
+
buf: List[str],
|
|
109
|
+
cx: float,
|
|
110
|
+
cy: float,
|
|
111
|
+
rx: float,
|
|
112
|
+
ry: float,
|
|
113
|
+
*,
|
|
114
|
+
fill: str = "none",
|
|
115
|
+
stroke: str = "#111",
|
|
116
|
+
stroke_width: float = 2.0,
|
|
117
|
+
opacity: float = 1.0,
|
|
118
|
+
dasharray: Optional[str] = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
fill = resolve_paint(buf, fill)
|
|
121
|
+
buf.append(
|
|
122
|
+
f'<ellipse cx="{cx:.2f}" cy="{cy:.2f}" rx="{rx:.2f}" ry="{ry:.2f}"'
|
|
123
|
+
f' fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}"'
|
|
124
|
+
f"{_dash_attr(dasharray)}{_opacity_attrs(opacity)} />"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def draw_path(
|
|
129
|
+
buf: List[str],
|
|
130
|
+
d: str,
|
|
131
|
+
*,
|
|
132
|
+
fill: str = "none",
|
|
133
|
+
stroke: str = "#111",
|
|
134
|
+
stroke_width: float = 2.0,
|
|
135
|
+
opacity: float = 1.0,
|
|
136
|
+
dasharray: Optional[str] = None,
|
|
137
|
+
marker_end: bool = False,
|
|
138
|
+
marker_start: bool = False,
|
|
139
|
+
fill_rule: Optional[str] = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
me = ' marker-end="url(#arrowhead)"' if marker_end else ""
|
|
142
|
+
ms = ' marker-start="url(#arrowhead-start)"' if marker_start else ""
|
|
143
|
+
fr = f' fill-rule="{fill_rule}"' if fill_rule else ""
|
|
144
|
+
fill = resolve_paint(buf, fill)
|
|
145
|
+
buf.append(
|
|
146
|
+
f'<path d="{d}" fill="{fill}"{fr} stroke="{stroke}"'
|
|
147
|
+
f' stroke-width="{stroke_width}" stroke-linejoin="round" stroke-linecap="round"'
|
|
148
|
+
f"{_dash_attr(dasharray)}{_opacity_attrs(opacity)}{me}{ms} />"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def draw_arrowhead(
|
|
153
|
+
buf: List[str],
|
|
154
|
+
x: float,
|
|
155
|
+
y: float,
|
|
156
|
+
angle: float,
|
|
157
|
+
*,
|
|
158
|
+
size: float = 8.0,
|
|
159
|
+
color: str = "#111",
|
|
160
|
+
opacity: float = 1.0,
|
|
161
|
+
shape: str = "triangle",
|
|
162
|
+
length: Optional[float] = None,
|
|
163
|
+
width: Optional[float] = None,
|
|
164
|
+
hollow: bool = False,
|
|
165
|
+
fill: str = "#ffffff",
|
|
166
|
+
stroke_width: float = 1.2,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Draw an oriented arrowhead whose tip is at (x, y).
|
|
169
|
+
|
|
170
|
+
``angle`` (radians) is the travel direction. Shapes: triangle, stealth, vee,
|
|
171
|
+
open (= hollow triangle), circle, diamond, bar, square, none.
|
|
172
|
+
"""
|
|
173
|
+
if shape == "open":
|
|
174
|
+
shape, hollow = "triangle", True
|
|
175
|
+
L = float(length if length is not None else size)
|
|
176
|
+
W = float(width if width is not None else 0.9 * size)
|
|
177
|
+
if shape == "none" or L <= 0:
|
|
178
|
+
return
|
|
179
|
+
c, s = math.cos(angle), math.sin(angle)
|
|
180
|
+
|
|
181
|
+
def P(back: float, side: float) -> str: # local frame → page
|
|
182
|
+
return f"{x - c * back - s * side:.2f},{y - s * back + c * side:.2f}"
|
|
183
|
+
|
|
184
|
+
op = _opacity_attrs(opacity)
|
|
185
|
+
floor = current_theme().edge_min_stroke
|
|
186
|
+
sw = max(floor, stroke_width)
|
|
187
|
+
if hollow:
|
|
188
|
+
paint = f'fill="{fill}" stroke="{color}" stroke-width="{sw:.2f}" stroke-linejoin="miter"'
|
|
189
|
+
inset = sw / 2 # keep the outline's outer edge on the tip
|
|
190
|
+
else:
|
|
191
|
+
paint = 'fill="' + color + '" stroke="none"'
|
|
192
|
+
inset = 0.0
|
|
193
|
+
hw = W / 2
|
|
194
|
+
if shape == "circle":
|
|
195
|
+
r = L / 2 - inset
|
|
196
|
+
cx, cy = x - c * L / 2, y - s * L / 2
|
|
197
|
+
buf.append(f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{r:.2f}" {paint}{op} />')
|
|
198
|
+
return
|
|
199
|
+
if shape == "bar":
|
|
200
|
+
buf.append(
|
|
201
|
+
f'<path d="M {P(sw / 2, -hw)} L {P(sw / 2, hw)}" fill="none" stroke="{color}" '
|
|
202
|
+
f'stroke-width="{max(sw, floor * 1.75):.2f}" stroke-linecap="butt"{op} />'
|
|
203
|
+
)
|
|
204
|
+
return
|
|
205
|
+
if shape == "vee":
|
|
206
|
+
buf.append(
|
|
207
|
+
f'<path d="M {P(L, -hw)} L {P(0, 0)} L {P(L, hw)}" fill="none" stroke="{color}" '
|
|
208
|
+
f'stroke-width="{max(sw, floor * 1.6):.2f}" stroke-linejoin="miter" stroke-linecap="round"{op} />'
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
if shape == "diamond":
|
|
212
|
+
pts = [P(inset, 0), P(L / 2, -hw), P(L - inset, 0), P(L / 2, hw)]
|
|
213
|
+
elif shape == "square":
|
|
214
|
+
pts = [P(inset, -hw + inset), P(L - inset, -hw + inset), P(L - inset, hw - inset),
|
|
215
|
+
P(inset, hw - inset)]
|
|
216
|
+
elif shape == "stealth":
|
|
217
|
+
pts = [P(inset, 0), P(L, -hw), P(L * 0.62, 0), P(L, hw)]
|
|
218
|
+
else: # triangle
|
|
219
|
+
pts = [P(inset * 2, 0), P(L, -hw + inset), P(L, hw - inset)]
|
|
220
|
+
buf.append(f'<polygon points="{" ".join(pts)}" {paint}{op} />')
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def draw_head(buf: List[str], x: float, y: float, angle: float, head, *, color: str,
|
|
224
|
+
opacity: float = 1.0, stroke_width: float = 1.2) -> None:
|
|
225
|
+
"""Draw a :class:`~codefig.styles.ResolvedHead`."""
|
|
226
|
+
if head is None or not head.visible:
|
|
227
|
+
return
|
|
228
|
+
draw_arrowhead(buf, x, y, angle, size=head.length, length=head.length, width=head.width,
|
|
229
|
+
shape=head.shape, hollow=head.hollow, fill=head.fill, color=color,
|
|
230
|
+
opacity=opacity, stroke_width=stroke_width)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def draw_mark(buf: List[str], kind: str, cx: float, cy: float, size: float, *,
|
|
234
|
+
color: str = "#111", stroke_width: Optional[float] = None) -> None:
|
|
235
|
+
"""Vector cell marks (font independent): check | cross | dot | circle | dash."""
|
|
236
|
+
s = size / 2
|
|
237
|
+
th = current_theme()
|
|
238
|
+
sw = stroke_width if stroke_width is not None else (
|
|
239
|
+
max(1.2, size * 0.16) if th.unit == 1.0 else th.edge_width)
|
|
240
|
+
if kind == "check":
|
|
241
|
+
d = f"M {cx - s * 0.8:.2f},{cy + s * 0.05:.2f} L {cx - s * 0.2:.2f},{cy + s * 0.65:.2f} L {cx + s * 0.85:.2f},{cy - s * 0.65:.2f}"
|
|
242
|
+
elif kind == "cross":
|
|
243
|
+
d = (f"M {cx - s * 0.6:.2f},{cy - s * 0.6:.2f} L {cx + s * 0.6:.2f},{cy + s * 0.6:.2f} "
|
|
244
|
+
f"M {cx + s * 0.6:.2f},{cy - s * 0.6:.2f} L {cx - s * 0.6:.2f},{cy + s * 0.6:.2f}")
|
|
245
|
+
elif kind == "dash":
|
|
246
|
+
d = f"M {cx - s * 0.6:.2f},{cy:.2f} L {cx + s * 0.6:.2f},{cy:.2f}"
|
|
247
|
+
elif kind == "dot":
|
|
248
|
+
buf.append(f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{s * 0.38:.2f}" fill="{color}" />')
|
|
249
|
+
return
|
|
250
|
+
elif kind == "circle":
|
|
251
|
+
buf.append(f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{s * 0.55:.2f}" fill="none" '
|
|
252
|
+
f'stroke="{color}" stroke-width="{sw:.2f}" />')
|
|
253
|
+
return
|
|
254
|
+
else:
|
|
255
|
+
raise ValueError(f"unknown mark {kind!r}; use check|cross|dot|circle|dash")
|
|
256
|
+
buf.append(f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{sw:.2f}" '
|
|
257
|
+
f'stroke-linecap="round" stroke-linejoin="round" />')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def angle_of(x1: float, y1: float, x2: float, y2: float) -> float:
|
|
261
|
+
return math.atan2(y2 - y1, x2 - x1)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _script_inner(text: str, *, mono: bool, force_family: Optional[str] = None) -> str:
|
|
265
|
+
"""Wrap mixed Latin/CJK runs in per-script font-family tspans."""
|
|
266
|
+
if force_family is not None or not needs_script_split(text):
|
|
267
|
+
return xml_escape(text)
|
|
268
|
+
latin = latin_font_family_css(mono)
|
|
269
|
+
cjk = cjk_font_family_css()
|
|
270
|
+
sym = sym_font_family_css()
|
|
271
|
+
parts = []
|
|
272
|
+
for script, run in script_runs(text):
|
|
273
|
+
fam = cjk if script == "cjk" else sym if script == "sym" else latin
|
|
274
|
+
parts.append(f'<tspan font-family="{fam}">{xml_escape(run)}</tspan>')
|
|
275
|
+
return "".join(parts)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# v0.13.0: scripts are positioned with explicit dy / y offsets instead of
|
|
279
|
+
# ``baseline-shift`` (cairosvg — PNG / PDF export — and several viewers ignore it, so
|
|
280
|
+
# P_1 rendered as "P1"). Offsets are fractions of the *base* font size.
|
|
281
|
+
SUB_SHIFT = 0.24
|
|
282
|
+
SUPER_SHIFT = 0.38
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def script_offset(kind: str, font_size: float) -> float:
|
|
286
|
+
"""Vertical offset (SVG y grows down) of a ``sub`` / ``super`` run for body text
|
|
287
|
+
of ``font_size``; 0 for ordinary runs."""
|
|
288
|
+
if kind == "sub":
|
|
289
|
+
return SUB_SHIFT * font_size
|
|
290
|
+
if kind == "super":
|
|
291
|
+
return -SUPER_SHIFT * font_size
|
|
292
|
+
return 0.0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _tspan_math(segs, font_size: float, *, mono: bool = False, start: float = 0.0) -> Tuple[str, float]:
|
|
296
|
+
"""Emit tspan markup for one line of math segments (with script runs).
|
|
297
|
+
|
|
298
|
+
Every segment is its own tspan with a relative ``dy`` so the pen returns to the
|
|
299
|
+
baseline after a script. Returns ``(markup, offset at the end of the line)``."""
|
|
300
|
+
from .textutil import _plain_line_width
|
|
301
|
+
|
|
302
|
+
parts = []
|
|
303
|
+
sub_fs = current_theme().script_size(font_size)
|
|
304
|
+
cur = start
|
|
305
|
+
segs = [(k, b) for k, b in segs if b]
|
|
306
|
+
pend_dx = 0.0 # pen correction after a stacked sub/super pair
|
|
307
|
+
i = 0
|
|
308
|
+
while i < len(segs):
|
|
309
|
+
kind, body = segs[i]
|
|
310
|
+
inner = _script_inner(body, mono=mono)
|
|
311
|
+
off = script_offset(kind, font_size)
|
|
312
|
+
dy = off - cur
|
|
313
|
+
cur = off
|
|
314
|
+
attrs = ""
|
|
315
|
+
if abs(pend_dx) > 1e-6:
|
|
316
|
+
attrs += f' dx="{pend_dx:.2f}"'
|
|
317
|
+
pend_dx = 0.0
|
|
318
|
+
if abs(dy) > 1e-6:
|
|
319
|
+
attrs += f' dy="{dy:.2f}"'
|
|
320
|
+
if kind in ("sub", "super"):
|
|
321
|
+
attrs += f' font-size="{sub_fs:.2f}"'
|
|
322
|
+
parts.append(f'<tspan{attrs}>{inner}</tspan>')
|
|
323
|
+
nxt = segs[i + 1] if i + 1 < len(segs) else None
|
|
324
|
+
if nxt is not None and {kind, nxt[0]} == {"sub", "super"}:
|
|
325
|
+
# stack the pair: back up over the first script, then skip past the wider
|
|
326
|
+
w1 = _plain_line_width(body, sub_fs, mono, False)
|
|
327
|
+
w2 = _plain_line_width(nxt[1], sub_fs, mono, False)
|
|
328
|
+
off2 = script_offset(nxt[0], font_size)
|
|
329
|
+
parts.append(f'<tspan dx="{-w1:.2f}" dy="{off2 - cur:.2f}" font-size="{sub_fs:.2f}">'
|
|
330
|
+
f'{_script_inner(nxt[1], mono=mono)}</tspan>')
|
|
331
|
+
cur = off2
|
|
332
|
+
pend_dx = max(0.0, w1 - w2)
|
|
333
|
+
i += 2
|
|
334
|
+
continue
|
|
335
|
+
i += 1
|
|
336
|
+
return "".join(parts), cur
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def draw_text(
|
|
340
|
+
buf: List[str],
|
|
341
|
+
text: str,
|
|
342
|
+
x: float,
|
|
343
|
+
y: float,
|
|
344
|
+
*,
|
|
345
|
+
font_size: Optional[float] = None,
|
|
346
|
+
fill: Optional[str] = None,
|
|
347
|
+
weight: str = "normal",
|
|
348
|
+
mono: bool = False,
|
|
349
|
+
family: Optional[str] = None,
|
|
350
|
+
anchor: str = "start",
|
|
351
|
+
opacity: float = 1.0,
|
|
352
|
+
italic: bool = False,
|
|
353
|
+
) -> None:
|
|
354
|
+
th = current_theme()
|
|
355
|
+
font_size = th.snap(th.font_size if font_size is None else font_size)
|
|
356
|
+
if fill is None:
|
|
357
|
+
fill = th.text_color
|
|
358
|
+
if has_rich_markup(text):
|
|
359
|
+
_draw_rich_text(buf, text, x, y, font_size=font_size, fill=fill, weight=weight,
|
|
360
|
+
mono=mono, family=family, anchor=anchor, opacity=opacity, italic=italic)
|
|
361
|
+
return
|
|
362
|
+
if italic:
|
|
363
|
+
sub: List[str] = []
|
|
364
|
+
draw_text(sub, text, x, y, font_size=font_size, fill=fill, weight=weight, mono=mono,
|
|
365
|
+
family=family, anchor=anchor, opacity=opacity)
|
|
366
|
+
buf.append("".join(sub).replace("<text ", '<text font-style="italic" ', 1))
|
|
367
|
+
return
|
|
368
|
+
# Default outer family: Latin stack; CJK runs get their own tspan family.
|
|
369
|
+
if family is None:
|
|
370
|
+
if mono:
|
|
371
|
+
fam = th.mono_family
|
|
372
|
+
else:
|
|
373
|
+
fam = latin_font_family_css(False)
|
|
374
|
+
else:
|
|
375
|
+
fam = family
|
|
376
|
+
op = _opacity_attrs(opacity)
|
|
377
|
+
use_math = has_math_markup(text)
|
|
378
|
+
force = family # if caller forced a family, don't split scripts
|
|
379
|
+
|
|
380
|
+
def line_inner(raw: str) -> str:
|
|
381
|
+
if force is not None:
|
|
382
|
+
return xml_escape(raw)
|
|
383
|
+
return _script_inner(raw, mono=mono)
|
|
384
|
+
|
|
385
|
+
if use_math:
|
|
386
|
+
lines = math_lines(text)
|
|
387
|
+
buf.append(
|
|
388
|
+
f'<text x="{x:.2f}" y="{y:.2f}" font-size="{font_size}" '
|
|
389
|
+
f'font-family="{fam}" font-weight="{weight}" fill="{fill}" '
|
|
390
|
+
f'text-anchor="{anchor}"{op}>'
|
|
391
|
+
)
|
|
392
|
+
if len(lines) == 1:
|
|
393
|
+
buf.append(_tspan_math(lines[0], font_size, mono=mono)[0])
|
|
394
|
+
else:
|
|
395
|
+
for i, segs in enumerate(lines):
|
|
396
|
+
inner, _ = _tspan_math(segs, font_size, mono=mono)
|
|
397
|
+
buf.append(f'<tspan x="{x:.2f}" y="{y + i * font_size * 1.25:.2f}">{inner}</tspan>')
|
|
398
|
+
buf.append("</text>")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
plain = expand_subscripts(text)
|
|
402
|
+
lines = plain.split("\n")
|
|
403
|
+
buf.append(
|
|
404
|
+
f'<text x="{x:.2f}" y="{y:.2f}" font-size="{font_size}" '
|
|
405
|
+
f'font-family="{fam}" font-weight="{weight}" fill="{fill}" '
|
|
406
|
+
f'text-anchor="{anchor}"{op}>'
|
|
407
|
+
)
|
|
408
|
+
if len(lines) == 1:
|
|
409
|
+
buf.append(line_inner(lines[0]))
|
|
410
|
+
else:
|
|
411
|
+
for i, line in enumerate(lines):
|
|
412
|
+
dy = 0 if i == 0 else font_size * 1.25
|
|
413
|
+
buf.append(
|
|
414
|
+
f'<tspan x="{x:.2f}" dy="{dy:.2f}">{line_inner(line)}</tspan>'
|
|
415
|
+
)
|
|
416
|
+
buf.append("</text>")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _draw_rich_text(buf: List[str], text: str, x: float, y: float, *, font_size: float,
|
|
420
|
+
fill: str, weight: str, mono: bool, family: Optional[str], anchor: str,
|
|
421
|
+
opacity: float, italic: bool) -> None:
|
|
422
|
+
"""Inline-span text: one <text> per line, a <tspan> per styled run; highlight
|
|
423
|
+
backgrounds are rects measured with the same per-run font metrics."""
|
|
424
|
+
base_bold = str(weight) in ("bold", "600", "700", "800", "900")
|
|
425
|
+
th = current_theme()
|
|
426
|
+
fam = family or (th.mono_family if mono else latin_font_family_css(False))
|
|
427
|
+
mono_fam = th.mono_family
|
|
428
|
+
op = _opacity_attrs(opacity)
|
|
429
|
+
lh = font_size * 1.25
|
|
430
|
+
sub_fs = th.script_size(font_size)
|
|
431
|
+
for li, runs in enumerate(parse_rich(text)):
|
|
432
|
+
ly = y + li * lh
|
|
433
|
+
widths = run_widths(runs, font_size, mono=mono, bold=base_bold, italic=italic)
|
|
434
|
+
total = sum(widths)
|
|
435
|
+
x0 = x - (total / 2 if anchor == "middle" else total if anchor == "end" else 0.0)
|
|
436
|
+
# highlight backgrounds + underlines (behind / under the glyphs)
|
|
437
|
+
cx = x0
|
|
438
|
+
for r, w in zip(runs, widths):
|
|
439
|
+
if r.bg:
|
|
440
|
+
buf.append(f'<rect x="{cx - 1:.2f}" y="{ly - font_size * 0.86:.2f}" '
|
|
441
|
+
f'width="{w + 2:.2f}" height="{font_size * 1.12:.2f}" rx="1.5" '
|
|
442
|
+
f'fill="{r.bg}" stroke="none"{op} />')
|
|
443
|
+
cx += w
|
|
444
|
+
parts = []
|
|
445
|
+
cx = x0
|
|
446
|
+
for r, w in zip(runs, widths):
|
|
447
|
+
attrs = [f'x="{cx:.2f}"', f'y="{ly + script_offset(r.kind, font_size):.2f}"']
|
|
448
|
+
if r.bold and not base_bold:
|
|
449
|
+
attrs.append('font-weight="bold"')
|
|
450
|
+
if r.italic and not italic:
|
|
451
|
+
attrs.append('font-style="italic"')
|
|
452
|
+
if r.color:
|
|
453
|
+
attrs.append(f'fill="{r.color}"')
|
|
454
|
+
if r.mono and not mono:
|
|
455
|
+
attrs.append(f'font-family="{mono_fam}"')
|
|
456
|
+
if r.kind in ("sub", "super"):
|
|
457
|
+
attrs.append(f'font-size="{sub_fs:.2f}"')
|
|
458
|
+
inner = _script_inner(r.text, mono=mono or r.mono,
|
|
459
|
+
force_family=mono_fam if (r.mono or family) else None)
|
|
460
|
+
parts.append(f'<tspan {" ".join(attrs)}>{inner}</tspan>')
|
|
461
|
+
if r.underline:
|
|
462
|
+
buf.append(f'<line x1="{cx:.2f}" y1="{ly + font_size * 0.15:.2f}" '
|
|
463
|
+
f'x2="{cx + w:.2f}" y2="{ly + font_size * 0.15:.2f}" '
|
|
464
|
+
f'stroke="{r.color or fill}" stroke-width="{max(0.8, font_size / 14):.2f}"{op} />')
|
|
465
|
+
cx += w
|
|
466
|
+
if not parts:
|
|
467
|
+
continue
|
|
468
|
+
fs_attr = ' font-style="italic"' if italic else ""
|
|
469
|
+
buf.append(
|
|
470
|
+
f'<text x="{x0:.2f}" y="{ly:.2f}" font-size="{font_size}" font-family="{fam}" '
|
|
471
|
+
f'font-weight="{weight}" fill="{fill}" text-anchor="start"{fs_attr}{op} '
|
|
472
|
+
f'xml:space="preserve">' + "".join(parts) + "</text>"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def svg_header(width: float, height: float, theme: Theme, *, units: str = "",
|
|
477
|
+
title: Optional[str] = None) -> List[str]:
|
|
478
|
+
"""SVG prologue. ``units="pt"`` writes ``width="…pt"`` (physical size; the
|
|
479
|
+
viewBox stays in the same numbers, so 1 user unit = 1 pt)."""
|
|
480
|
+
# Keep legacy markers for any remaining marker-end=True callers.
|
|
481
|
+
hs = theme.edge_head_size
|
|
482
|
+
hw = hs
|
|
483
|
+
hh = hs * 0.8
|
|
484
|
+
if units:
|
|
485
|
+
size = (f'width="{width:.2f}{units}" height="{height:.2f}{units}" '
|
|
486
|
+
f'viewBox="0 0 {width:.2f} {height:.2f}"')
|
|
487
|
+
else:
|
|
488
|
+
size = (f'width="{width:.0f}" height="{height:.0f}" '
|
|
489
|
+
f'viewBox="0 0 {width:.0f} {height:.0f}"')
|
|
490
|
+
head = [
|
|
491
|
+
f'<?xml version="1.0" encoding="UTF-8"?>',
|
|
492
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" {size}>',
|
|
493
|
+
]
|
|
494
|
+
if title:
|
|
495
|
+
head.append(f"<title>{xml_escape(title)}</title>")
|
|
496
|
+
return head + [
|
|
497
|
+
"<defs>",
|
|
498
|
+
f'<marker id="arrowhead" markerWidth="{hw:.1f}" markerHeight="{hh:.1f}" '
|
|
499
|
+
f'refX="{hw * 0.9:.1f}" refY="{hh / 2:.1f}" orient="auto" markerUnits="userSpaceOnUse">',
|
|
500
|
+
f'<polygon points="0 0, {hw:.1f} {hh / 2:.1f}, 0 {hh:.1f}" fill="{theme.edge_color}" />',
|
|
501
|
+
"</marker>",
|
|
502
|
+
f'<marker id="arrowhead-start" markerWidth="{hw:.1f}" markerHeight="{hh:.1f}" '
|
|
503
|
+
f'refX="{hw * 0.1:.1f}" refY="{hh / 2:.1f}" orient="auto" markerUnits="userSpaceOnUse">',
|
|
504
|
+
f'<polygon points="{hw:.1f} 0, 0 {hh / 2:.1f}, {hw:.1f} {hh:.1f}" fill="{theme.edge_color}" />',
|
|
505
|
+
"</marker>",
|
|
506
|
+
"</defs>",
|
|
507
|
+
f'<rect width="100%" height="100%" fill="#ffffff" />',
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def svg_footer() -> List[str]:
|
|
512
|
+
return ["</svg>"]
|