rotulo 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.
rotulo/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from rotulo.build import build_context, build_font
2
+ from rotulo.params import DISPLAY, READING, Params
3
+
4
+ __all__ = ["DISPLAY", "READING", "Params", "build_context", "build_font"]
rotulo/__main__.py ADDED
@@ -0,0 +1,26 @@
1
+ import argparse
2
+ from pathlib import Path
3
+
4
+ from rotulo.build import build_font
5
+ from rotulo.params import DISPLAY, READING
6
+ from rotulo.variable import build_variable
7
+ from rotulo.web import to_woff2
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(prog="rotulo", description="Build the Rotulo fonts.")
12
+ parser.add_argument("--out", type=Path, default=Path("build"))
13
+ parser.add_argument("--no-variable", action="store_true", help="skip the variable font")
14
+ parser.add_argument("--web", type=Path, help="also write WOFF2 files into this directory")
15
+ args = parser.parse_args()
16
+ for params, style, weight_class in ((DISPLAY, "Display", 200), (READING, "Lectura", 500)):
17
+ print(build_font(params, style, weight_class, args.out))
18
+ if not args.no_variable:
19
+ print(build_variable(args.out))
20
+ if args.web:
21
+ for path in to_woff2(args.out, args.web):
22
+ print(path)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
rotulo/accents.py ADDED
@@ -0,0 +1,50 @@
1
+ from rotulo.chars import DOTLESS_I
2
+ from rotulo.context import Context
3
+ from rotulo.skeleton import Stroke
4
+
5
+
6
+ def add_accents(ctx: Context) -> None:
7
+ g, xh, pen, sh = ctx.glyphs, ctx.xh, ctx.pen, ctx.sharp
8
+
9
+ def compose(ch: str, base: str, extra: list[Stroke]) -> None:
10
+ b = g[base]
11
+ ctx.define(ch, b.width, b.left, b.right, b.strokes, [*b.details, *extra], b.ax)
12
+
13
+ def grave(x: float, y: float) -> Stroke:
14
+ return pen().m(x + xh * 0.06, y).l(x - xh * 0.08, y + xh * 0.18)
15
+
16
+ def circumflex(x: float, y: float) -> Stroke:
17
+ return pen(sharp=sh).m(x - xh * 0.12, y).l(x, y + xh * 0.16).l(x + xh * 0.12, y)
18
+
19
+ def cedilla(x: float) -> Stroke:
20
+ y0, r = -(ctx.w + ctx.gap * 0.5), xh * 0.08
21
+ return pen().m(x, y0).a(x, y0 - r, r, r, 90, -150)
22
+
23
+ marks = {
24
+ "acute": ctx.acute,
25
+ "grave": grave,
26
+ "circumflex": circumflex,
27
+ }
28
+ lower = {"a": "a", "e": "e", "i": DOTLESS_I, "o": "o", "u": "u"}
29
+ upper = {"A": "A", "E": "E", "I": "I", "O": "O", "U": "U"}
30
+ table = {
31
+ "acute": ("áéíóú", "ÁÉÍÓÚ"),
32
+ "grave": ("àèìòù", "ÀÈÌÒÙ"),
33
+ "circumflex": ("âêîôû", "ÂÊÎÔÛ"),
34
+ }
35
+ for mark, (low, up) in table.items():
36
+ draw = marks[mark]
37
+ for ch, base in zip(low, lower.values(), strict=True):
38
+ compose(ch, base, [draw(0 if base == DOTLESS_I else g[base].ax, ctx.yt)])
39
+ for ch, base in zip(up, upper.values(), strict=True):
40
+ compose(ch, base, [draw(g[base].ax, ctx.yc)])
41
+ for ch, base in (("ñ", "n"), ("ã", "a"), ("õ", "o")):
42
+ compose(ch, base, [ctx.tilde(g[base].ax, ctx.yt)])
43
+ for ch, base in (("Ñ", "N"), ("Ã", "A"), ("Õ", "O")):
44
+ compose(ch, base, [ctx.tilde(g[base].ax, ctx.yc)])
45
+ for ch, base in (("ü", "u"), ("ë", "e"), ("ï", DOTLESS_I), ("ÿ", "y")):
46
+ compose(ch, base, ctx.dieresis(0 if base == DOTLESS_I else g[base].ax, ctx.yt))
47
+ for ch, base in (("Ü", "U"), ("Ë", "E"), ("Ï", "I")):
48
+ compose(ch, base, ctx.dieresis(g[base].ax, ctx.yc))
49
+ compose("ç", "c", [cedilla(g["c"].ax)])
50
+ compose("Ç", "C", [cedilla(g["C"].ax)])
rotulo/build.py ADDED
@@ -0,0 +1,97 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ from fontTools.colorLib.builder import ColorPaletteType, buildCOLR, buildCPAL
5
+ from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
6
+ from fontTools.fontBuilder import FontBuilder
7
+
8
+ from rotulo.glyphset import GlyphSet, build_context, collect, glyph_name
9
+ from rotulo.params import Params
10
+ from rotulo.spacing import kerning
11
+
12
+ FAMILY = "Rotulo"
13
+ VERSION = "0.1.0"
14
+ LICENSE = "This Font Software is licensed under the SIL Open Font License, Version 1.1."
15
+ LICENSE_URL = "https://openfontlicense.org"
16
+ DETAIL_LIGHT = (0x2F / 255, 0x5D / 255, 0x62 / 255, 1.0)
17
+ DETAIL_DARK = (0x7F / 255, 0xB7 / 255, 0xB5 / 255, 1.0)
18
+
19
+ __all__ = ["build_context", "build_font", "font_builder", "glyph_name"]
20
+
21
+
22
+ def features(gs: GlyphSet) -> str:
23
+ blocks = []
24
+ rules = [
25
+ f" pos {gs.names[a]} {gs.names[b]} {v};"
26
+ for (a, b), v in sorted(kerning(gs.ctx).items())
27
+ if a in gs.names and b in gs.names
28
+ ]
29
+ if rules:
30
+ blocks.append("feature kern {\n" + "\n".join(rules) + "\n} kern;")
31
+ if gs.alternates:
32
+ subs = "\n".join(f" sub {base} by {alt};" for base, alt in gs.alternates.items())
33
+ blocks.append(
34
+ 'feature ss01 {\n featureNames { name "Lectura"; };\n' + subs + "\n} ss01;"
35
+ )
36
+ return "\n".join(blocks) + "\n"
37
+
38
+
39
+ def font_builder(params: Params, style: str, weight_class: int, gs: GlyphSet) -> Any:
40
+ fb = FontBuilder(1000, isTTF=True)
41
+ fb.setupGlyphOrder(gs.order)
42
+ fb.setupCharacterMap(gs.cmap)
43
+ fb.setupGlyf(gs.glyphs)
44
+ glyf = fb.font["glyf"]
45
+ fb.setupHorizontalMetrics({n: (gs.advances[n], getattr(glyf[n], "xMin", 0)) for n in gs.order})
46
+ y_max = max(getattr(glyf[n], "yMax", 0) for n in gs.order)
47
+ y_min = min(getattr(glyf[n], "yMin", 0) for n in gs.order)
48
+ fb.setupHorizontalHeader(ascent=y_max, descent=y_min)
49
+ ps = f"{FAMILY}-{style}"
50
+ fb.setupNameTable(
51
+ {
52
+ "familyName": FAMILY,
53
+ "styleName": style,
54
+ "uniqueFontIdentifier": f"{ps};{VERSION}",
55
+ "fullName": f"{FAMILY} {style}",
56
+ "psName": ps,
57
+ "version": f"Version {VERSION}",
58
+ "licenseDescription": LICENSE,
59
+ "licenseInfoURL": LICENSE_URL,
60
+ }
61
+ )
62
+ fb.setupOS2(
63
+ usWeightClass=weight_class,
64
+ sTypoAscender=round(params.ascender),
65
+ sTypoDescender=round(params.descender),
66
+ sTypoLineGap=200,
67
+ usWinAscent=y_max,
68
+ usWinDescent=-y_min,
69
+ sxHeight=round(params.x_height),
70
+ sCapHeight=round(params.cap_height),
71
+ fsType=0,
72
+ )
73
+ fb.setupPost()
74
+ if gs.layers:
75
+ fb.font["COLR"] = buildCOLR(gs.layers, version=0)
76
+ fb.font["CPAL"] = buildCPAL(
77
+ [[DETAIL_LIGHT], [DETAIL_DARK]],
78
+ paletteTypes=[
79
+ ColorPaletteType.USABLE_WITH_LIGHT_BACKGROUND,
80
+ ColorPaletteType.USABLE_WITH_DARK_BACKGROUND,
81
+ ],
82
+ )
83
+ addOpenTypeFeaturesFromString(fb.font, features(gs))
84
+ return fb
85
+
86
+
87
+ def build_font(params: Params, style: str, weight_class: int, out: Path, hint: bool = True) -> Path:
88
+ gs = collect(params)
89
+ fb = font_builder(params, style, weight_class, gs)
90
+ out.mkdir(parents=True, exist_ok=True)
91
+ path = out / f"{FAMILY}-{style}.ttf"
92
+ fb.save(str(path))
93
+ if hint:
94
+ from ttfautohint import ttfautohint
95
+
96
+ ttfautohint(in_file=str(path), out_file=str(path))
97
+ return path
rotulo/capsule.py ADDED
@@ -0,0 +1,104 @@
1
+ import math
2
+ from collections.abc import Sequence
3
+
4
+ from rotulo.skeleton import Point, Stroke
5
+
6
+ Cubic = tuple[Point, Point, Point, Point]
7
+ Contour = list[Cubic]
8
+
9
+ KAPPA = 4 / 3 * math.tan(math.pi / 8)
10
+
11
+
12
+ def _sub(a: Point, b: Point) -> Point:
13
+ return (a[0] - b[0], a[1] - b[1])
14
+
15
+
16
+ def _add(a: Point, b: Point, s: float = 1.0) -> Point:
17
+ return (a[0] + b[0] * s, a[1] + b[1] * s)
18
+
19
+
20
+ def _unit(v: Point) -> Point:
21
+ n = math.hypot(*v)
22
+ return (v[0] / n, v[1] / n) if n > 1e-9 else (1.0, 0.0)
23
+
24
+
25
+ def _normal(t: Point) -> Point:
26
+ return (-t[1], t[0])
27
+
28
+
29
+ def _split(c: Cubic) -> tuple[Cubic, Cubic]:
30
+ p0, p1, p2, p3 = c
31
+
32
+ def mid(a: Point, b: Point) -> Point:
33
+ return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
34
+
35
+ a, b, e = mid(p0, p1), mid(p1, p2), mid(p2, p3)
36
+ f, g = mid(a, b), mid(b, e)
37
+ h = mid(f, g)
38
+ return (p0, a, f, h), (h, g, e, p3)
39
+
40
+
41
+ def _tangents(c: Cubic) -> tuple[Point, Point]:
42
+ p0, p1, p2, p3 = c
43
+ start = _sub(p1, p0) if math.hypot(*_sub(p1, p0)) > 1e-6 else _sub(p2, p0)
44
+ end = _sub(p3, p2) if math.hypot(*_sub(p3, p2)) > 1e-6 else _sub(p3, p1)
45
+ return _unit(start), _unit(end)
46
+
47
+
48
+ def _offset(c: Cubic, h: float) -> Cubic:
49
+ p0, p1, p2, p3 = c
50
+ t0, t3 = _tangents(c)
51
+ turn = math.acos(max(-1.0, min(1.0, t0[0] * t3[0] + t0[1] * t3[1])))
52
+ chord = math.hypot(*_sub(p3, p0))
53
+ if turn > 1e-3 and chord > 1e-6:
54
+ radius = chord / (2 * math.sin(turn / 2))
55
+ cross = t0[0] * t3[1] - t0[1] * t3[0]
56
+ if (cross > 0) == (h > 0):
57
+ h = math.copysign(min(abs(h), radius * 0.95), h)
58
+ q0 = _add(p0, _normal(t0), h)
59
+ q3 = _add(p3, _normal(t3), h)
60
+ scale = math.hypot(*_sub(q3, q0)) / chord if chord > 1e-6 else 1.0
61
+ return q0, _add(q0, _sub(p1, p0), scale), _add(q3, _sub(p2, p3), scale), q3
62
+
63
+
64
+ def _cap(center: Point, tangent: Point, h: float) -> list[Cubic]:
65
+ n = _normal(tangent)
66
+ left, tip, right = _add(center, n, h), _add(center, tangent, h), _add(center, n, -h)
67
+ k = KAPPA * h
68
+ return [
69
+ (left, _add(left, tangent, k), _add(tip, n, k), tip),
70
+ (tip, _add(tip, n, -k), _add(right, tangent, k), right),
71
+ ]
72
+
73
+
74
+ def _piece(c: Cubic, h: float) -> Contour:
75
+ t0, t3 = _tangents(c)
76
+ left = _offset(c, h)
77
+ right = _offset(c, -h)
78
+ back: Cubic = (right[3], right[2], right[1], right[0])
79
+ return [left, *_cap(c[3], t3, h), back, *_cap(c[0], (-t0[0], -t0[1]), h)]
80
+
81
+
82
+ def _cubics(stroke: Stroke, dx: float) -> list[Cubic]:
83
+ out: list[Cubic] = []
84
+ cur: Point | None = None
85
+ for kind, pts in stroke.finish():
86
+ moved = [(x + dx, y) for x, y in pts]
87
+ if kind == "M":
88
+ cur = moved[0]
89
+ continue
90
+ assert cur is not None
91
+ if kind == "L":
92
+ end = moved[0]
93
+ d = _sub(end, cur)
94
+ out.append((cur, _add(cur, d, 1 / 3), _add(cur, d, 2 / 3), end))
95
+ else:
96
+ c1, c2, end = moved
97
+ out.extend(_split((cur, c1, c2, end)))
98
+ cur = moved[-1]
99
+ return out
100
+
101
+
102
+ def capsules(strokes: Sequence[Stroke], weight: float, dx: float) -> list[Contour]:
103
+ h = weight / 2
104
+ return [_piece(c, h) for stroke in strokes for c in _cubics(stroke, dx)]
rotulo/chars.py ADDED
@@ -0,0 +1,13 @@
1
+ DOTLESS_I = chr(0x131)
2
+ EN_DASH = chr(0x2013)
3
+ EM_DASH = chr(0x2014)
4
+ PRIME = chr(0x2032)
5
+ DOUBLE_PRIME = chr(0x2033)
6
+ LEFT_SINGLE = chr(0x2018)
7
+ RIGHT_SINGLE = chr(0x2019)
8
+ LEFT_DOUBLE = chr(0x201C)
9
+ RIGHT_DOUBLE = chr(0x201D)
10
+ LEFT_GUILLEMET = chr(0xAB)
11
+ RIGHT_GUILLEMET = chr(0xBB)
12
+ ELLIPSIS = chr(0x2026)
13
+ MIDDLE_DOT = chr(0xB7)
rotulo/context.py ADDED
@@ -0,0 +1,142 @@
1
+ import math
2
+ from collections.abc import Sequence
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+ from rotulo.params import Params
7
+ from rotulo.skeleton import DEG, Point, Stroke, Transform
8
+
9
+ Side = Literal["h", "o", "v"]
10
+
11
+
12
+ @dataclass
13
+ class Glyph:
14
+ width: float
15
+ left: Side
16
+ right: Side
17
+ strokes: list[Stroke]
18
+ details: list[Stroke]
19
+ ax: float
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class BarFillet:
24
+ th: float
25
+ c: Point
26
+ phi: float
27
+
28
+
29
+ @dataclass
30
+ class Context:
31
+ p: Params
32
+ glyphs: dict[str, Glyph] = field(default_factory=dict)
33
+
34
+ def __post_init__(self) -> None:
35
+ p = self.p
36
+ self.xh = p.x_height
37
+ self.cap = p.cap_height
38
+ self.asc = p.ascender
39
+ self.desc = p.descender
40
+ self.w = p.weight
41
+ self.o = self.xh * p.overshoot / 100
42
+ self.gap = p.gap
43
+ self.bowl = self.xh * p.width * 0.96
44
+ self.nw = self.bowl * 0.9
45
+ self.h2 = self.nw / 2
46
+ self.ry = self.xh / 2 + self.o
47
+ self.yt = self.xh + self.w + self.gap
48
+ self.yc = self.cap + self.w + self.gap
49
+ self.dot_len = self.xh * 0.1 + self.w * 0.3
50
+ self.dv = (
51
+ 0.0 if p.dot == "round" else self.dot_len if p.dot == "line" else self.dot_len * 0.7
52
+ )
53
+ self.hr = self.xh * 0.3
54
+ self.hs = p.tail_close
55
+ self.hx = self.hr * p.tail
56
+ self.lr = self.xh * 0.2
57
+ self.sharp = 0.35
58
+ self.fig = self.cap * 0.58
59
+
60
+ def pen(self, transform: Transform | None = None, sharp: float = 1.0) -> Stroke:
61
+ return Stroke(self.p.corner * sharp, transform)
62
+
63
+ def define(
64
+ self,
65
+ ch: str,
66
+ width: float,
67
+ left: Side,
68
+ right: Side,
69
+ strokes: Sequence[Stroke],
70
+ details: Sequence[Stroke] = (),
71
+ ax: float | None = None,
72
+ ) -> None:
73
+ self.glyphs[ch] = Glyph(
74
+ width, left, right, list(strokes), list(details), width / 2 if ax is None else ax
75
+ )
76
+
77
+ def ring(
78
+ self, cx: float, cy: float, rx: float, ry: float, transform: Transform | None = None
79
+ ) -> Stroke:
80
+ return self.pen(transform).a(cx, cy, rx, ry, 90, 450)
81
+
82
+ def dot(self, x: float, y: float, transform: Transform | None = None) -> Stroke:
83
+ s = self.pen(transform)
84
+ if self.p.dot == "round":
85
+ return s.m(x, y).l(x + 0.01, y)
86
+ if self.p.dot == "line":
87
+ return s.m(x, y).l(x, y + self.dot_len)
88
+ return s.m(x - self.dot_len * 0.35, y).l(x + self.dot_len * 0.35, y + self.dot_len * 0.7)
89
+
90
+ def acute(self, x: float, y: float) -> Stroke:
91
+ return self.pen().m(x - self.xh * 0.06, y).l(x + self.xh * 0.08, y + self.xh * 0.18)
92
+
93
+ def dieresis(self, x: float, y: float) -> list[Stroke]:
94
+ return [self.dot(x - self.xh * 0.15, y), self.dot(x + self.xh * 0.15, y)]
95
+
96
+ def tilde(self, x: float, y: float) -> Stroke:
97
+ t, a = self.xh * 0.2, self.xh * 0.06
98
+ return (
99
+ self.pen().a(x - t / 2, y + a, t / 2, a, 180, 0).a(x + t / 2, y + a, t / 2, a, 180, 360)
100
+ )
101
+
102
+ def soft_tail(
103
+ self,
104
+ x: float,
105
+ top: float,
106
+ wx: float,
107
+ sweep: float,
108
+ base: Stroke | None = None,
109
+ bottom: float | None = None,
110
+ ) -> Stroke:
111
+ bot = self.desc if bottom is None else bottom
112
+ hr = self.hr
113
+ ys = bot + hr * 1.6
114
+ s = base if base is not None else self.pen().m(x, top)
115
+ return (
116
+ s.l(x, ys)
117
+ .c(x, ys - (ys - bot) * 0.55, x - wx * 0.45, bot, x - wx, bot)
118
+ .a(x - wx, bot + hr, wx * 0.8, hr, -90, -sweep)
119
+ )
120
+
121
+ def bar_fillet(
122
+ self, cx: float, cy: float, rx: float, ry: float, bar_y: float, r: float, side: int
123
+ ) -> BarFillet:
124
+ def at(th: float) -> tuple[Point, Point]:
125
+ nx, ny = math.cos(th) / rx, math.sin(th) / ry
126
+ nl = math.hypot(nx, ny)
127
+ e = (cx + rx * math.cos(th), cy + ry * math.sin(th))
128
+ return e, (e[0] - r * nx / nl, e[1] - r * ny / nl)
129
+
130
+ def f(th: float) -> float:
131
+ return at(th)[1][1] - (bar_y + side * r)
132
+
133
+ lo, hi = (0.0, math.pi / 2) if side > 0 else (-math.pi / 2, 0.0)
134
+ for _ in range(50):
135
+ mid = (lo + hi) / 2
136
+ if math.copysign(1, f(mid)) == math.copysign(1, f(lo)):
137
+ lo = mid
138
+ else:
139
+ hi = mid
140
+ th = (lo + hi) / 2
141
+ e, c = at(th)
142
+ return BarFillet(th / DEG, c, math.atan2(e[1] - c[1], e[0] - c[0]) / DEG)
rotulo/figures.py ADDED
@@ -0,0 +1,217 @@
1
+ import math
2
+
3
+ from rotulo.chars import DOUBLE_PRIME, EN_DASH, PRIME
4
+ from rotulo.context import Context
5
+ from rotulo.skeleton import DEG, Stroke
6
+
7
+
8
+ def two_end(cx: float, cy: float, rx: float, ry: float) -> float:
9
+ def f(deg: float) -> float:
10
+ t = deg * DEG
11
+ px, py = cx + rx * math.cos(t), cy + ry * math.sin(t)
12
+ tx, ty = rx * math.sin(t), -ry * math.cos(t)
13
+ return tx * (0 - py) - ty * (0 - px)
14
+
15
+ prev = 150.0
16
+ for step in range(1, 300):
17
+ cur = 150.0 - step
18
+ if math.copysign(1, f(cur)) != math.copysign(1, f(prev)):
19
+ lo, hi = cur, prev
20
+ for _ in range(40):
21
+ mid = (lo + hi) / 2
22
+ if math.copysign(1, f(mid)) == math.copysign(1, f(lo)):
23
+ lo = mid
24
+ else:
25
+ hi = mid
26
+ return (lo + hi) / 2
27
+ prev = cur
28
+ return -25.0
29
+
30
+
31
+ def add_figures(ctx: Context) -> None:
32
+ p, pen, d = ctx.p, ctx.pen, ctx.define
33
+ cap, xh, asc, desc, o, w, gap, dot_len, dv, fig = (
34
+ ctx.cap,
35
+ ctx.xh,
36
+ ctx.asc,
37
+ ctx.desc,
38
+ ctx.o,
39
+ ctx.w,
40
+ ctx.gap,
41
+ ctx.dot_len,
42
+ ctx.dv,
43
+ ctx.fig,
44
+ )
45
+
46
+ def zero_detail() -> list[Stroke]:
47
+ if p.zero == "plain":
48
+ return []
49
+ if p.zero == "dot":
50
+ return [ctx.dot(fig / 2, cap / 2 - dv / 2)]
51
+ th = math.atan(1.6)
52
+ c, s = math.cos(th), math.sin(th)
53
+ re = 1 / math.sqrt((c / (fig / 2)) ** 2 + (s / (cap / 2)) ** 2)
54
+ ln = max(12.0, re - w - gap)
55
+ return [pen().m(fig / 2 - ln * c, cap / 2 - ln * s).l(fig / 2 + ln * c, cap / 2 + ln * s)]
56
+
57
+ d("0", fig, "o", "o", [ctx.ring(fig / 2, cap / 2, fig / 2, cap / 2 + o)], zero_detail())
58
+ if p.one_flag:
59
+ d(
60
+ "1",
61
+ fig,
62
+ "o",
63
+ "o",
64
+ [
65
+ pen()
66
+ .m(fig * 0.05, cap * 0.68)
67
+ .c(fig * 0.3, cap * 0.76, fig * 0.5, cap * 0.86, fig * 0.6, cap)
68
+ .l(fig * 0.6, 0)
69
+ ],
70
+ )
71
+ else:
72
+ d("1", fig, "o", "o", [pen().m(fig * 0.2, cap * 0.82).l(fig * 0.6, cap).l(fig * 0.6, 0)])
73
+ end2 = two_end(fig / 2, cap - fig / 2, fig / 2, fig / 2 + o)
74
+ d(
75
+ "2",
76
+ fig,
77
+ "o",
78
+ "o",
79
+ [pen().a(fig / 2, cap - fig / 2, fig / 2, fig / 2 + o, 155, end2).l(0, 0).l(fig, 0)],
80
+ )
81
+ d(
82
+ "3",
83
+ fig,
84
+ "o",
85
+ "o",
86
+ [
87
+ pen()
88
+ .a(fig / 2, cap * 0.76, fig * 0.45, cap * 0.24, 150, -90)
89
+ .a(fig / 2, cap * 0.26, fig / 2, cap * 0.26, 90, -150)
90
+ ],
91
+ )
92
+ d(
93
+ "4",
94
+ fig,
95
+ "o",
96
+ "o",
97
+ [pen().m(fig * 0.72, 0).l(fig * 0.72, cap).l(0, cap * 0.3).l(fig * 0.72, cap * 0.3)],
98
+ )
99
+ d(
100
+ "5",
101
+ fig,
102
+ "o",
103
+ "o",
104
+ [
105
+ pen()
106
+ .m(fig * 0.9, cap)
107
+ .l(fig * 0.12, cap)
108
+ .a(fig / 2, cap * 0.31, fig / 2, cap * 0.31 + o, 140, -145)
109
+ ],
110
+ )
111
+ d(
112
+ "6",
113
+ fig,
114
+ "o",
115
+ "o",
116
+ [
117
+ pen()
118
+ .m(fig * 0.8, cap)
119
+ .c(fig * 0.38, cap * 0.98, 0, cap * 0.78, 0, cap * 0.31)
120
+ .a(fig / 2, cap * 0.31, fig / 2, cap * 0.31 + o, 180, 540)
121
+ ],
122
+ )
123
+ d("7", fig, "o", "o", [pen().m(0, cap).l(fig, cap).l(fig * 0.3, 0)])
124
+ d(
125
+ "8",
126
+ fig,
127
+ "o",
128
+ "o",
129
+ [
130
+ ctx.ring(fig / 2, cap * 0.76, fig * 0.42, cap * 0.24),
131
+ ctx.ring(fig / 2, cap * 0.26, fig / 2, cap * 0.26 + o),
132
+ ],
133
+ )
134
+ if p.nine_straight:
135
+ d(
136
+ "9",
137
+ fig,
138
+ "o",
139
+ "o",
140
+ [
141
+ ctx.ring(fig / 2, cap * 0.69, fig / 2, cap * 0.31 + o),
142
+ pen().m(fig, cap * 0.69).l(fig, 0),
143
+ ],
144
+ )
145
+ else:
146
+ d(
147
+ "9",
148
+ fig,
149
+ "o",
150
+ "o",
151
+ [pen().a(fig / 2, cap * 0.69, fig / 2, cap * 0.31 + o, -20, 340).l(fig * 0.22, 0)],
152
+ )
153
+
154
+ d(".", 0, "v", "v", [], [ctx.dot(0, 0)])
155
+ d(",", 0, "v", "v", [], [pen().m(0, dot_len).l(-dot_len * 0.3, -dot_len * 0.8)])
156
+ d(":", 0, "v", "v", [], [ctx.dot(0, 0), ctx.dot(0, xh - dv)])
157
+ d(
158
+ ";",
159
+ 0,
160
+ "v",
161
+ "v",
162
+ [],
163
+ [pen().m(0, dot_len).l(-dot_len * 0.3, -dot_len * 0.8), ctx.dot(0, xh - dv)],
164
+ )
165
+ d("-", xh * 0.4, "v", "v", [pen().m(0, xh * 0.48).l(xh * 0.4, xh * 0.48)])
166
+ d(EN_DASH, xh * 0.8, "v", "v", [pen().m(0, xh * 0.48).l(xh * 0.8, xh * 0.48)])
167
+ qw, qs = cap * 0.5, dv + w + gap
168
+
169
+ def inv(x: float, y: float) -> tuple[float, float]:
170
+ return qw - x, xh - y
171
+
172
+ d(
173
+ "?",
174
+ qw,
175
+ "o",
176
+ "o",
177
+ [pen().a(qw / 2, cap - qw / 2, qw / 2, qw / 2 + o, 160, -90).l(qw / 2, qs)],
178
+ [ctx.dot(qw / 2, 0)],
179
+ )
180
+ d(
181
+ "¿",
182
+ qw,
183
+ "o",
184
+ "o",
185
+ [pen(inv).a(qw / 2, cap - qw / 2, qw / 2, qw / 2 + o, 160, -90).l(qw / 2, qs)],
186
+ [ctx.dot(qw / 2, xh - dv)],
187
+ )
188
+ d("!", 0, "o", "o", [pen().m(0, cap).l(0, qs)], [ctx.dot(0, 0)])
189
+ d("¡", 0, "o", "o", [pen().m(0, xh - qs).l(0, xh - cap)], [ctx.dot(0, xh - dv)])
190
+ dr = xh * 0.13
191
+ d("°", dr * 2, "v", "v", [ctx.ring(dr, cap - dr, dr, dr)])
192
+ d(PRIME, xh * 0.12, "v", "v", [pen().m(xh * 0.12, cap).l(0, cap - xh * 0.3)])
193
+ d(
194
+ DOUBLE_PRIME,
195
+ xh * 0.32,
196
+ "v",
197
+ "v",
198
+ [
199
+ pen().m(xh * 0.12, cap).l(0, cap - xh * 0.3),
200
+ pen().m(xh * 0.32, cap).l(xh * 0.2, cap - xh * 0.3),
201
+ ],
202
+ )
203
+ d("'", 0, "v", "v", [pen().m(0, cap).l(0, cap - xh * 0.28)])
204
+ d(
205
+ '"',
206
+ xh * 0.2,
207
+ "v",
208
+ "v",
209
+ [
210
+ pen().m(0, cap).l(0, cap - xh * 0.28),
211
+ pen().m(xh * 0.2, cap).l(xh * 0.2, cap - xh * 0.28),
212
+ ],
213
+ )
214
+ pw, pm, ph = xh * 0.24, (asc + desc) / 2, (asc - desc) / 2
215
+ d("(", pw, "v", "v", [pen().a(pw * 2.4, pm, pw * 2.4, ph, 130, 230)])
216
+ d(")", pw, "v", "v", [pen().a(-pw * 1.4, pm, pw * 2.4, ph, 50, -50)])
217
+ d("/", xh * 0.5, "v", "v", [pen().m(0, desc * 0.5).l(xh * 0.5, asc)])