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/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>"]