#!/usr/bin/env python3 """Build PeachScript-Regular.otf — 256 historical-runic glyphs. Glyphs are drawn in a stroke-based grammar: every glyph is a list of straight line segments rendered as filled rectangles of uniform width. The four authentic rune-extension techniques (mirror, bind, staveless, rotated) are applied programmatically over a hand-authored base of 75 historical forms drawn from Elder Futhark, Anglo-Saxon Futhorc, Younger Futhark, medieval calendar runes, Tolkien's variants, and the Franks Casket cryptograms. Output: public/fonts/PeachScript-Regular.otf 256 glyphs at U+E000..U+E0FF, CFF outlines, UPM 1000. """ from __future__ import annotations import math import sys from pathlib import Path from typing import Iterable from fontTools.fontBuilder import FontBuilder from fontTools.pens.t2CharStringPen import T2CharStringPen # --------------------------------------------------------------------------- # Font metrics # --------------------------------------------------------------------------- UPM = 1000 ADVANCE = 560 ASCENT = 750 DESCENT = -200 LINE_GAP = 0 STROKE = 90 # uniform stroke width — uniform-chisel feel # Drawing rectangle (most glyphs sit inside these bounds) B = 0 # baseline T = 700 # cap top M = 350 # mid LO = 175 # lower third HI = 525 # upper third # Horizontal anchors L = 100 # leftmost extent (branch tip) LL = 180 # left-stave x C = 280 # central-stave x RR = 380 # right-stave x R = 460 # rightmost extent Stroke = tuple[float, float, float, float] # --------------------------------------------------------------------------- # Stroke -> filled rectangle # --------------------------------------------------------------------------- def stroke_rect(x1: float, y1: float, x2: float, y2: float, w: float = STROKE) -> list[tuple[float, float]]: """Return the four corners of the rectangle representing a stroke. The rectangle is centered on the line segment, width w, with butt ends. Joins between strokes simply overlap — non-zero winding fill handles the union cleanly. """ dx, dy = x2 - x1, y2 - y1 length = math.hypot(dx, dy) if length < 0.5: return [] nx = -dy / length * (w / 2) ny = dx / length * (w / 2) return [ (x1 + nx, y1 + ny), (x2 + nx, y2 + ny), (x2 - nx, y2 - ny), (x1 - nx, y1 - ny), ] def draw(strokes: Iterable[Stroke]): """Render a list of strokes into a CFF charstring.""" pen = T2CharStringPen(ADVANCE, None) for s in strokes: rect = stroke_rect(*s) if not rect: continue pen.moveTo(rect[0]) pen.lineTo(rect[1]) pen.lineTo(rect[2]) pen.lineTo(rect[3]) pen.closePath() return pen.getCharString() # --------------------------------------------------------------------------- # Layer 1 — hand-authored historical glyphs # --------------------------------------------------------------------------- # # Each entry is a list of strokes. Coordinates are in font units. The # Elder Futhark forms (entries 0..23) are drawn from the Kylver Stone # canon. Subsequent forms extend through the historical corpus. ELDER_FUTHARK: list[list[Stroke]] = [ # 0 — Fehu ᚠ : central stave, two upward-right branches [ (C, B, C, T), (C, 560, R - 20, T), (C, 380, R - 20, 540), ], # 1 — Uruz ᚢ : inverted-U with right leg foreshortened [ (LL, B, LL, T), (LL, T, RR, T), (RR, T, RR + 20, 60), ], # 2 — Thurisaz ᚦ : stave with right-side triangular thorn [ (LL, B, LL, T), (LL, HI + 30, R - 20, M + 20), (LL, M - 70, R - 20, M + 20), ], # 3 — Ansuz ᚨ : stave with two backward-angled branches at top-right [ (LL, B, LL, T), (LL, T - 40, R - 20, T - 200), (LL, T - 200, R - 20, T - 360), ], # 4 — Raido ᚱ : stave + R-loop top + diagonal leg [ (LL, B, LL, T), (LL, T, R - 20, T - 80), (R - 20, T - 80, LL, M + 30), (LL, M + 30, R - 20, B), ], # 5 — Kenaz ᚲ : open angle bracket pointing right [ (L + 20, T, R - 20, M), (L + 20, B, R - 20, M), ], # 6 — Gebo ᚷ : pure X, no stave [ (L, B, R, T), (L, T, R, B), ], # 7 — Wunjo ᚹ : stave with triangular pennant [ (LL, B, LL, T), (LL, T, R - 20, T - 80), (R - 20, T - 80, LL, T - 220), ], # 8 — Hagalaz ᚺ : two parallel staves with single diagonal crossbar [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, HI - 20, RR + 20, LO + 60), ], # 9 — Naudiz ᚾ : stave with diagonal cross [ (C, B, C, T), (C - 160, M - 120, C + 160, M + 120), ], # 10 — Isa ᛁ : single vertical [ (C, B, C, T), ], # 11 — Jera ᛃ : two opposing angular brackets, offset [ (L + 20, T, C, HI - 30), (C, HI - 30, L + 20, M), (R - 20, M, C, LO + 30), (C, LO + 30, R - 20, B), ], # 12 — Eihwaz ᛇ : diagonal stave with branches each end [ (LL + 20, B, RR - 20, T), (RR - 20, T, R + 20, T - 120), (LL + 20, B, L - 20, 120), ], # 13 — Pertho ᛈ : bracket opening right [ (LL, B, LL, T), (LL, T, R - 20, T - 100), (R - 20, T - 100, R - 20, 100), (R - 20, 100, LL, B), ], # 14 — Algiz ᛉ : Y / upward fork [ (C, B, C, M + 50), (C, M + 50, L + 20, T), (C, M + 50, R - 20, T), ], # 15 — Sowilo ᛊ : angular zigzag (lightning) [ (R - 20, T, L + 60, HI - 20), (L + 60, HI - 20, R - 60, M - 20), (R - 60, M - 20, L + 20, B), ], # 16 — Tiwaz ᛏ : upward arrow [ (C, B, C, T), (C, T, L + 40, T - 160), (C, T, R - 40, T - 160), ], # 17 — Berkano ᛒ : stave with two stacked right-side triangles [ (LL, B, LL, T), (LL, T, R - 20, T - 130), (R - 20, T - 130, LL, M + 40), (LL, M + 40, R - 20, M - 130), (R - 20, M - 130, LL, B + 30), ], # 18 — Ehwaz ᛖ : two staves with inner X [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, T, RR + 20, M), (RR + 20, T, LL, M), ], # 19 — Mannaz ᛗ : two staves + inner X reaching from top corners [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, T, RR + 20, M - 40), (RR + 20, T, LL, M - 40), ], # 20 — Laguz ᛚ : stave with single short upper-right branch [ (LL, B, LL, T), (LL, T, R - 60, T - 200), ], # 21 — Ingwaz ᛜ : rhombus [ (LL, M, C, T - 60), (C, T - 60, RR + 20, M), (RR + 20, M, C, B + 60), (C, B + 60, LL, M), ], # 22 — Dagaz ᛞ : two staves with full X between [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, B, RR + 20, T), (LL, T, RR + 20, B), ], # 23 — Othalan ᛟ : rhombus with splayed legs [ (LL, M + 50, C, T - 30), (C, T - 30, RR + 20, M + 50), (RR + 20, M + 50, C, M - 100), (C, M - 100, LL, M + 50), (LL, M + 50, L - 30, B), (RR + 20, M + 50, R + 30, B), ], ] # --------------------------------------------------------------------------- # Layer 1 — Anglo-Saxon Futhorc additions (~15) # --------------------------------------------------------------------------- # # Forms specific to the Anglo-Saxon corpus that diverge visibly from Elder # Futhark. Drawn from the Ruthwell Cross and Vienna Codex inscriptions. ANGLO_SAXON: list[list[Stroke]] = [ # 24 — Os ᚩ : Ansuz with branches further down the stave [ (LL, B, LL, T), (LL, M + 100, R - 20, M), (LL, M, R - 20, M - 100), ], # 25 — Ac ᚪ : Ansuz inverted (branches at bottom) [ (LL, B, LL, T), (LL, 240, R - 20, 100), (LL, 100, R - 20, B - 30), ], # 26 — Æsc ᚫ : stave with two branches each side at top (twin-Ansuz) [ (C, B, C, T), (C, T - 40, L + 20, T - 200), (C, T - 200, L + 20, T - 360), (C, T - 40, R - 20, T - 200), (C, T - 200, R - 20, T - 360), ], # 27 — Yr ᚣ : Algiz + tick at top (descender Y) [ (C, B - 100, C, M + 50), (C, M + 50, L + 20, T - 80), (C, M + 50, R - 20, T - 80), (L + 60, T, R - 60, T), ], # 28 — Ior ᛡ : crossed-S / fish form [ (L + 40, T - 80, R - 40, T - 80), (C, T - 80, C, B + 80), (L + 40, B + 80, R - 40, B + 80), ], # 29 — Ear ᛠ : Ansuz with extra lower branch [ (LL, B, LL, T), (LL, T - 40, R - 20, T - 200), (LL, T - 200, R - 20, T - 360), (LL, T - 360, R - 20, T - 520), ], # 30 — Cweorth ᛢ : Pertho with inner crossbar [ (LL, B, LL, T), (LL, T, R - 20, T - 100), (R - 20, T - 100, R - 20, 100), (R - 20, 100, LL, B), (LL, M, R - 20, M), ], # 31 — Calc ᛣ : Algiz with broader fork (chalice) [ (C, B, C, M), (C, M, L, T), (C, M, R, T), (L, T, L + 60, T - 80), (R, T, R - 60, T - 80), ], # 32 — Stan ᛥ : tree-like stave with paired branches both directions [ (C, B, C, T), (C, T, L + 20, T - 140), (C, T, R - 20, T - 140), (C, M + 80, L + 60, M - 60), (C, M + 80, R - 60, M - 60), ], # 33 — Gar ᚸ : Gebo overlaid on Ingwaz [ (L, B, R, T), (L, T, R, B), (LL, M, C, T - 60), (C, T - 60, RR + 20, M), (RR + 20, M, C, B + 60), (C, B + 60, LL, M), ], # 34 — Cealc variant : narrow Algiz with side ticks [ (C, B, C, T), (C, M + 100, L + 60, T), (C, M + 100, R - 60, T), ], # 35 — Anglo-Saxon long-branch : Naudiz with twin diagonals [ (C, B, C, T), (L + 20, M, C, M + 140), (C, M - 140, R - 20, M), ], # 36 — Ymbir : Tiwaz with crossbar at base [ (C, B, C, T), (C, T, L + 40, T - 160), (C, T, R - 40, T - 160), (L + 40, B, R - 40, B), ], # 37 — Daeg-variant : Dagaz pinched at center [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, B, C, M), (C, M, RR + 20, T), (LL, T, C, M), (C, M, RR + 20, B), ], # 38 — Anglo-Saxon Hagalaz variant : double-bar [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, HI, RR + 20, HI), (LL, LO, RR + 20, LO), ], ] # --------------------------------------------------------------------------- # Layer 1 — Younger Futhark (~12) # --------------------------------------------------------------------------- # # Simplified Viking-Age forms. Younger Futhark trades nuance for speed # of carving — fewer strokes, more decisive geometry. YOUNGER_FUTHARK: list[list[Stroke]] = [ # 39 — Fé (short-twig) : single right-tilting branch [ (C, B, C, T), (C, T - 60, R - 40, T), ], # 40 — Úr (short-twig) : single short branch high on stave [ (C, B, C, T), (C, T - 30, R - 40, T - 180), ], # 41 — Þurs (short-twig) : small triangular thorn [ (LL, B, LL, T), (LL, M + 80, R - 80, M), (LL, M - 80, R - 80, M), ], # 42 — Óss : Ansuz with single branch [ (LL, B, LL, T), (LL, T - 40, R - 20, T - 200), ], # 43 — Reið : Raido without the leg [ (LL, B, LL, T), (LL, T, R - 20, T - 80), (R - 20, T - 80, LL, M + 30), ], # 44 — Kaun ᚴ : single angled branch low on stave [ (C, B, C, T), (C, M + 50, R - 60, T - 40), ], # 45 — Hagall (short-twig) : asterisk / star [ (C, B, C, T), (L + 30, M, R - 30, M), (L + 50, M - 160, R - 50, M + 160), ], # 46 — Naud (short-twig) : single tick across stave [ (C, B, C, T), (L + 80, M - 60, R - 80, M + 60), ], # 47 — Ár ᛅ : Ansuz mirrored, minimal [ (RR + 20, B, RR + 20, T), (RR + 20, T - 40, L + 20, T - 200), ], # 48 — Sól (short-twig) : single diagonal [ (R - 40, T, L + 40, B), ], # 49 — Týr ᛐ : Tiwaz with shorter arms [ (C, B, C, T), (C, T, L + 80, T - 100), (C, T, R - 80, T - 100), ], # 50 — Maðr (short-twig) : single upward branch [ (C, B, C, T), (C, T - 100, R - 60, T), ], # 51 — Ýr (younger) : Algiz inverted (downward fork) [ (C, T, C, M - 50), (C, M - 50, L + 20, B), (C, M - 50, R - 20, B), ], ] # --------------------------------------------------------------------------- # Layer 1 — Medieval / calendar / Tolkien / Franks Casket (~24) # --------------------------------------------------------------------------- MEDIEVAL: list[list[Stroke]] = [ # 52 — Medieval dotted-K : Kenaz with pommel [ (L + 20, T, R - 20, M), (L + 20, B, R - 20, M), (R - 60, M, R + 20, M), ], # 53 — Medieval Æ : ligature stave with inner crossbar [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, M + 80, RR + 20, M + 80), (LL, M - 80, RR + 20, M - 80), (LL, T, RR + 20, B), ], # 54 — Arlaug ᛮ (calendar Golden Number 17) [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, T, RR + 20, M), (LL, M, RR + 20, T), ], # 55 — Tvimadur ᛯ (calendar Golden Number 18) [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, T - 80, C, T), (C, T, RR + 20, T - 80), (LL, B + 80, C, B), (C, B, RR + 20, B + 80), ], # 56 — Belgthor ᛰ (calendar Golden Number 19) [ (C, B, C, T), (L + 30, T, R - 30, T), (L + 30, M, R - 30, M), (L + 30, B, R - 30, B), ], # 57 — Medieval Y-rune : Algiz with horizontal at top [ (C, B, C, M + 50), (C, M + 50, L + 20, T), (C, M + 50, R - 20, T), (L + 20, T, R - 20, T), ], # 58 — Medieval dotted-S : Sowilo with central tick [ (R - 20, T, L + 60, HI - 20), (L + 60, HI - 20, R - 60, M - 20), (R - 60, M - 20, L + 20, B), (C - 30, M - 20, C + 30, M - 20), ], # 59 — Calendar weekday : two stacked V's [ (L + 40, T, C, T - 200), (C, T - 200, R - 40, T), (L + 40, T - 320, C, T - 520), (C, T - 520, R - 40, T - 320), ], # 60 — Dalecarlian Æ : Ehwaz with center mark [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, T, RR + 20, M), (RR + 20, T, LL, M), (C, M - 80, C, M + 20), ], # 61 — Dalecarlian Ø : Othalan tightened [ (LL, M + 50, C, T - 80), (C, T - 80, RR + 20, M + 50), (RR + 20, M + 50, C, M - 60), (C, M - 60, LL, M + 50), ], # 62 — Medieval Hagalaz with double crossbar [ (LL, B, LL, T), (RR + 20, B, RR + 20, T), (LL, HI, RR + 20, LO + 80), (LL, LO + 80, RR + 20, HI), ], # 63 — Pentadic-1 : single-tick stave [ (C, B, C, T), (C, T - 40, R - 60, T - 40), ], # 64 — Pentadic-3 : triple ticks [ (C, B, C, T), (C, T - 40, R - 60, T - 40), (C, T - 200, R - 60, T - 200), (C, T - 360, R - 60, T - 360), ], # 65 — Pentadic-5 : full pentadic frame [ (C, B, C, T), (C, T, R - 60, T - 80), (R - 60, T - 80, C, T - 160), (C, T - 160, R - 60, T - 240), ], # 66 — Tolkien K : Kenaz with stave [ (LL, B, LL, T), (LL, M, R - 20, T), (LL, M, R - 20, B), ], # 67 — Tolkien OO : doubled Os [ (L + 20, B, L + 20, T), (R - 20, B, R - 20, T), (L + 20, M + 100, R - 20, M), (L + 20, M, R - 20, M - 100), ], # 68 — Tolkien SH : Sowilo with leading tick [ (R - 20, T, L + 60, HI - 20), (L + 60, HI - 20, R - 60, M - 20), (R - 60, M - 20, L + 20, B), (R - 20, T, R + 30, T - 60), ], # 69 — Franks Casket cryptic-1 : tree-fork inverted [ (C, B, C, M), (C, M, L, M + 200), (C, M, R, M + 200), (L, M + 200, L + 60, T), (R, M + 200, R - 60, T), ], # 70 — Franks Casket cryptic-2 : multi-pronged stave [ (C, B, C, T), (C, T - 80, L + 30, T), (C, T - 80, R - 30, T), (C, M + 80, L + 80, M + 200), (C, M + 80, R - 80, M + 200), ], # 71 — Franks Casket cryptic-3 : nested chevrons [ (L + 20, T, C, T - 200), (C, T - 200, R - 20, T), (L + 80, T - 80, C, T - 240), (C, T - 240, R - 80, T - 80), (C, T - 240, C, B), ], # 72 — Franks Casket cryptic-4 : grid [ (LL, B + 80, LL, T - 80), (RR + 20, B + 80, RR + 20, T - 80), (LL, B + 80, RR + 20, B + 80), (LL, M, RR + 20, M), (LL, T - 80, RR + 20, T - 80), ], # 73 — Franks Casket cryptic-5 : zigzag column [ (L + 40, T, R - 40, T - 140), (R - 40, T - 140, L + 40, T - 280), (L + 40, T - 280, R - 40, T - 420), (R - 40, T - 420, L + 40, T - 560), ], # 74 — Medieval bow-rune : nested rhombus [ (LL, M, C, T - 30), (C, T - 30, RR + 20, M), (RR + 20, M, C, B + 30), (C, B + 30, LL, M), (C, M + 200, C, M - 200), ], ] LAYER_1: list[list[Stroke]] = ( ELDER_FUTHARK + ANGLO_SAXON + YOUNGER_FUTHARK + MEDIEVAL ) assert len(LAYER_1) == 75, f"Layer 1 expected 75, got {len(LAYER_1)}" # --------------------------------------------------------------------------- # Geometric transforms # --------------------------------------------------------------------------- def mirror(strokes: list[Stroke]) -> list[Stroke]: """Reflect strokes horizontally around x = ADVANCE/2.""" cx = ADVANCE / 2 return [(2 * cx - x1, y1, 2 * cx - x2, y2) for x1, y1, x2, y2 in strokes] def rotate_180(strokes: list[Stroke]) -> list[Stroke]: """Rotate strokes 180° around (ADVANCE/2, T/2).""" cx, cy = ADVANCE / 2, T / 2 return [(2 * cx - x1, 2 * cy - y1, 2 * cx - x2, 2 * cy - y2) for x1, y1, x2, y2 in strokes] def _key(strokes: list[Stroke]) -> tuple: return tuple(sorted(tuple(round(v, 1) for v in s) for s in strokes)) # --------------------------------------------------------------------------- # Layer 2 — mirror forms (70 glyphs) # --------------------------------------------------------------------------- # # Reflect each layer-1 form horizontally. When the mirror equals the # source (true symmetry around x=ADVANCE/2), substitute a 180° rotation — # also a documented historical extension. Skip the rare case where both # transforms collapse to the source (point-symmetric forms). LAYER_2: list[list[Stroke]] = [] for _i in range(75): _base = LAYER_1[_i] _src_key = _key(_base) _candidate = mirror(_base) if _key(_candidate) == _src_key: _candidate = rotate_180(_base) if _key(_candidate) == _src_key: continue LAYER_2.append(_candidate) if len(LAYER_2) >= 70: break assert len(LAYER_2) == 70, f"Layer 2 expected 70, got {len(LAYER_2)}" # --------------------------------------------------------------------------- # Layer 3 — bind runes (60 glyphs) # --------------------------------------------------------------------------- # # Two runes share a single central stave. We strip non-stave strokes # from the second rune and overlay them on the first. The bind list # starts with documented historical bindings, then continues through # combinatorially attractive pairings. BIND_PAIRS: list[tuple[int, int]] = [ # Historically attested (2, 3), # Thurisaz + Ansuz (4, 5), # Raido + Kenaz (16, 10), # Tiwaz + Isa (17, 18), # Berkano + Ehwaz (19, 20), # Mannaz + Laguz (6, 7), # Gebo + Wunjo (0, 16), # Fehu + Tiwaz (wealth-victory) (3, 14), # Ansuz + Algiz (god-protection) (16, 0), # Tiwaz + Fehu (alt order) (4, 0), # Raido + Fehu # Common Viking-era (44, 0), # Kaun + Fehu (47, 39), # Ár + Fé (alt order) (49, 16), # Týr + Tiwaz (50, 47), # Maðr + Ár (39, 40), # Fé + Úr # Anglo-Saxon (24, 25), # Os + Ac (29, 30), # Ear + Cweorth (31, 14), # Calc + Algiz (32, 16), # Stan + Tiwaz (33, 21), # Gar + Ingwaz # Programmatic remainder ] # Pad out to 60 by combining each Elder Futhark form with the next one # (skipping pairs that would degenerate). This is a programmatic rather # than attested set — but the visual grammar (shared stave + branches) # stays consistent. BIND_FILLER: list[tuple[int, int]] = [] for a in range(24): for b in range(a + 1, 24): if (a, b) in BIND_PAIRS or (b, a) in BIND_PAIRS: continue if a in {6, 10, 21, 22} or b in {6, 10, 21, 22}: continue BIND_FILLER.append((a, b)) if len(BIND_PAIRS) + len(BIND_FILLER) >= 60: break if len(BIND_PAIRS) + len(BIND_FILLER) >= 60: break BIND_PAIRS = BIND_PAIRS + BIND_FILLER BIND_PAIRS = BIND_PAIRS[:60] def is_central_stave(s: Stroke) -> bool: """A stroke counts as the central stave if it sits on x≈C and runs vertically.""" x1, y1, x2, y2 = s return abs(x1 - C) < 30 and abs(x2 - C) < 30 and abs(y2 - y1) > 200 def to_central(strokes: list[Stroke]) -> list[Stroke]: """Translate a left-staved rune onto the central stave so it can bind.""" if not strokes: return strokes # If any stroke is already a central stave, leave the rune alone. if any(is_central_stave(s) for s in strokes): return strokes # Otherwise shift everything from LL → C. dx = C - LL return [(x1 + dx, y1, x2 + dx, y2) for x1, y1, x2, y2 in strokes] def bind(a: list[Stroke], b: list[Stroke]) -> list[Stroke]: a2 = to_central(a) b2 = to_central(b) # Take all strokes from a, then all non-stave strokes from b, # mirrored so they extend on the opposite side. out = list(a2) for s in b2: if is_central_stave(s): continue # Mirror b's branches so they live on the opposite side of the stave. x1, y1, x2, y2 = s out.append((2 * C - x1, y1, 2 * C - x2, y2)) return out LAYER_3: list[list[Stroke]] = [bind(LAYER_1[a], LAYER_1[b]) for a, b in BIND_PAIRS] assert len(LAYER_3) == 60, f"Layer 3 expected 60, got {len(LAYER_3)}" # --------------------------------------------------------------------------- # Layer 4 — staveless (Hälsinge) variants (51 glyphs) # --------------------------------------------------------------------------- def remove_stave(strokes: list[Stroke]) -> list[Stroke]: """Drop the central vertical stave; keep branching strokes.""" return [s for s in strokes if not is_central_stave(s) and not _is_left_stave(s)] def _is_left_stave(s: Stroke) -> bool: x1, y1, x2, y2 = s return abs(x1 - LL) < 30 and abs(x2 - LL) < 30 and abs(y2 - y1) > 200 # Source pool for staveless: layer 1 forms with branching content beyond # just a stave. We try staveless first; if the result is too sparse # (fewer than 1 stroke), we substitute a 180° rotation instead. STAVELESS_CANDIDATES = [ i for i in range(75) if i not in {10, 6, 21, 22, 38, 56, 60, 61, 62, 67, 72} ] def staveless_or_rotate(idx: int) -> list[Stroke]: base = LAYER_1[idx] stripped = remove_stave(base) if len(stripped) >= 2: return stripped return rotate_180(base) LAYER_4: list[list[Stroke]] = [staveless_or_rotate(i) for i in STAVELESS_CANDIDATES[:51]] assert len(LAYER_4) == 51, f"Layer 4 expected 51, got {len(LAYER_4)}" # --------------------------------------------------------------------------- # Assemble all 256 glyphs # --------------------------------------------------------------------------- ALL_GLYPHS: list[list[Stroke]] = LAYER_1 + LAYER_2 + LAYER_3 + LAYER_4 assert len(ALL_GLYPHS) == 256, f"Total expected 256, got {len(ALL_GLYPHS)}" # --------------------------------------------------------------------------- # Tuning overrides — replace specific weak glyphs with stronger forms. # # These overrides come from peachscript_tuning_brief.md. Each entry # replaces a final-index glyph (post-layer-assembly position) with a # hand-tuned stroke set. Reasons range from broken-rendering risk # (curved fallback at 214), accidental duplicates (240 = Isa), and # forms too sparse to read at 11–18px display. # --------------------------------------------------------------------------- OVERRIDES: dict[int, list[Stroke]] = { # 187 — short-twig Yr : stave + small downward fork high 187: [ (C, B, C, T), (C, T * 2 // 3, L + (R - L) // 3, T // 3), (C, T * 2 // 3, L + (R - L) * 2 // 3, T // 3), ], # 188 — short-twig As : stave with one upward-right and one downward-right branch. # The brief's pseudocode collapses the branch endpoints onto the stave; # interpreting the descriptive text instead. 188: [ (C, B, C, T), (C, T * 3 // 4, R - 60, T), (C, T // 4, R - 60, B), ], # 192 — mirrored Laguz : stave + diagonal foot pointing down-left + mid crossbar 192: [ (C, B, C, T), (C, B, L, T // 3), (L, M, L + (R - L) // 2, M), ], # 205 — Hälsinge Hagalaz : tilted X, no stave 205: [ (L, T // 4, R, T * 3 // 4), (L, T * 3 // 4, R, T // 4), ], # 206 — Hälsinge Naudiz : long diagonal + offset short diagonal 206: [ (L, B, R, T), (L + (R - L) // 3, T, R, T // 4), ], # 207 — Hälsinge Tiwaz : open chevron, no stave 207: [ (L, T // 3, C, T), (C, T, R, T // 3), ], # 213 — Hälsinge Kenaz pair : > opening left at top + vertical connector 213: [ (L, T // 4, L + (R - L) * 2 // 3, T // 2), (L + (R - L) * 2 // 3, T // 2, L, T * 3 // 4), (L + (R - L) * 2 // 3, T // 4, L + (R - L) * 2 // 3, T * 3 // 4), ], # 214 — Dagaz horizontal bowtie : two triangles meeting at center 214: [ (L, B, C, M), (C, M, R, B), (L, T, C, M), (C, M, R, T), ], # 225 — Armanen Man variant : two outer staves + downward V from top corners + cross 225: [ (L, B, L, T), (R, B, R, T), (L, T, C, M), (C, M, R, T), (L, T, R, M), ], # 226 — compressed Naudiz : stave + two short branches angled in at mid 226: [ (C, B, C, T), (C, M + 20, C - (R - L) // 3, M - 20), (C, M + 20, C + (R - L) // 3, M - 20), ], # 228 — Dalecarlian Beorc : stave + horizontal at mid + two diagonals back to stave 228: [ (C, B, C, T), (C, M, C + (R - L) // 2, M), (C + (R - L) // 2, M, C, T // 4), (C + (R - L) // 2, M, C, T * 3 // 4), ], # 230 — Franks Casket vowel cryptogram : stave + two short horizontals near top/bottom 230: [ (C, B, C, T), (C, T // 6, C + (R - L) // 3, T // 6), (C, T * 5 // 6, C + (R - L) // 3, T * 5 // 6), ], # 231 — Hälsinge Jera : two near-parallel diagonals with deliberate gap 231: [ (L, T * 3 // 4, R, T // 4), (L, T // 4 + 30, R, T * 3 // 4 - 30), ], # 240 — Armanen Gibor : horizontal mid + two outward-opening K shapes 240: [ (L, M, R, M), (L, M, L + (R - L) // 3, B), (L, M, L + (R - L) // 3, T), (R, M, L + (R - L) * 2 // 3, B), (R, M, L + (R - L) * 2 // 3, T), ], # 241 — crossed Isa : two parallel verticals + X 241: [ (L + (R - L) // 3, B, L + (R - L) // 3, T), (L + (R - L) * 2 // 3, B, L + (R - L) * 2 // 3, T), (L, T // 3, R, T * 2 // 3), (L, T * 2 // 3, R, T // 3), ], } for _idx, _strokes in OVERRIDES.items(): ALL_GLYPHS[_idx] = _strokes # --------------------------------------------------------------------------- # Build the OTF # --------------------------------------------------------------------------- def build() -> None: repo_root = Path(__file__).resolve().parent.parent out_path = repo_root / "public" / "fonts" / "PeachScript-Regular.otf" glyph_names = [".notdef"] + [f"ps{i:03d}" for i in range(256)] cmap = {0xE000 + i: f"ps{i:03d}" for i in range(256)} fb = FontBuilder(UPM, isTTF=False) fb.setupGlyphOrder(glyph_names) fb.setupCharacterMap(cmap) charstrings = {".notdef": draw([(C, B, C, T)])} # mark as a single stave for i, strokes in enumerate(ALL_GLYPHS): charstrings[f"ps{i:03d}"] = draw(strokes) private = { "BlueValues": [-12, 0, T, T + 12], "OtherBlues": [DESCENT - 12, DESCENT], "BlueScale": 0.039625, "BlueShift": 7, "BlueFuzz": 1, "StdHW": STROKE, "StdVW": STROKE, "ForceBold": 0, "LanguageGroup": 0, "ExpansionFactor": 0.06, "initialRandomSeed": 0, "defaultWidthX": ADVANCE, "nominalWidthX": ADVANCE, } fb.setupCFF( psName="PeachScript-Regular", fontInfo={ "FullName": "PeachScript Regular", "FamilyName": "PeachScript", "Weight": "Regular", "version": "2.0", }, privateDict=private, charStringsDict=charstrings, ) metrics = {name: (ADVANCE, 0) for name in glyph_names} fb.setupHorizontalMetrics(metrics) fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT, lineGap=LINE_GAP) fb.setupNameTable({ "familyName": "PeachScript", "styleName": "Regular", "uniqueFontIdentifier": "PeachScript-Regular;v2.0", "fullName": "PeachScript Regular", "psName": "PeachScript-Regular", "version": "Version 2.000", }) fb.setupOS2( sTypoAscender=ASCENT, sTypoDescender=DESCENT, sTypoLineGap=LINE_GAP, usWinAscent=ASCENT, usWinDescent=-DESCENT, sxHeight=int(M), sCapHeight=T, achVendID="PEAC", fsType=0, ) fb.setupPost() out_path.parent.mkdir(parents=True, exist_ok=True) fb.save(out_path) print(f"wrote {out_path} ({out_path.stat().st_size} bytes)") if __name__ == "__main__": build()