f3478eae923b

regenerate PeachScript-Regular.otf with 256 historical-runic glyphs across four authentic extension layers

time
2026-04-27 18:08 UTC · 6 months ago
machine
koh2
parent
fc2b557daaaf
2 files public/fonts/PeachScript-Regular.otfbinaryscripts/build-peachscript.py+892 −0
public/fonts/PeachScript-Regular.otfbinary

binary file, not shown

view file

scripts/build-peachscript.py+892 −0
@@ -0,0 +1,892 @@1+#!/usr/bin/env python32+"""Build PeachScript-Regular.otf — 256 historical-runic glyphs.3+4+Glyphs are drawn in a stroke-based grammar: every glyph is a list of5+straight line segments rendered as filled rectangles of uniform width.6+The four authentic rune-extension techniques (mirror, bind, staveless,7+rotated) are applied programmatically over a hand-authored base of 758+historical forms drawn from Elder Futhark, Anglo-Saxon Futhorc, Younger9+Futhark, medieval calendar runes, Tolkien's variants, and the Franks10+Casket cryptograms.11+12+Output: public/fonts/PeachScript-Regular.otf13+        256 glyphs at U+E000..U+E0FF, CFF outlines, UPM 1000.14+"""15+16+from __future__ import annotations17+18+import math19+import sys20+from pathlib import Path21+from typing import Iterable22+23+from fontTools.fontBuilder import FontBuilder24+from fontTools.pens.t2CharStringPen import T2CharStringPen25+26+27+# ---------------------------------------------------------------------------28+# Font metrics29+# ---------------------------------------------------------------------------30+31+UPM = 100032+ADVANCE = 56033+ASCENT = 75034+DESCENT = -20035+LINE_GAP = 036+STROKE = 90  # uniform stroke width — uniform-chisel feel37+38+# Drawing rectangle (most glyphs sit inside these bounds)39+B = 0          # baseline40+T = 700        # cap top41+M = 350        # mid42+LO = 175       # lower third43+HI = 525       # upper third44+45+# Horizontal anchors46+L = 100        # leftmost extent (branch tip)47+LL = 180       # left-stave x48+C = 280        # central-stave x49+RR = 380       # right-stave x50+R = 460        # rightmost extent51+52+53+Stroke = tuple[float, float, float, float]54+55+56+# ---------------------------------------------------------------------------57+# Stroke -> filled rectangle58+# ---------------------------------------------------------------------------59+60+def stroke_rect(x1: float, y1: float, x2: float, y2: float, w: float = STROKE) -> list[tuple[float, float]]:61+    """Return the four corners of the rectangle representing a stroke.62+63+    The rectangle is centered on the line segment, width w, with butt64+    ends. Joins between strokes simply overlap — non-zero winding fill65+    handles the union cleanly.66+    """67+    dx, dy = x2 - x1, y2 - y168+    length = math.hypot(dx, dy)69+    if length < 0.5:70+        return []71+    nx = -dy / length * (w / 2)72+    ny = dx / length * (w / 2)73+    return [74+        (x1 + nx, y1 + ny),75+        (x2 + nx, y2 + ny),76+        (x2 - nx, y2 - ny),77+        (x1 - nx, y1 - ny),78+    ]79+80+81+def draw(strokes: Iterable[Stroke]):82+    """Render a list of strokes into a CFF charstring."""83+    pen = T2CharStringPen(ADVANCE, None)84+    for s in strokes:85+        rect = stroke_rect(*s)86+        if not rect:87+            continue88+        pen.moveTo(rect[0])89+        pen.lineTo(rect[1])90+        pen.lineTo(rect[2])91+        pen.lineTo(rect[3])92+        pen.closePath()93+    return pen.getCharString()94+95+96+# ---------------------------------------------------------------------------97+# Layer 1 — hand-authored historical glyphs98+# ---------------------------------------------------------------------------99+#100+# Each entry is a list of strokes. Coordinates are in font units. The101+# Elder Futhark forms (entries 0..23) are drawn from the Kylver Stone102+# canon. Subsequent forms extend through the historical corpus.103+104+ELDER_FUTHARK: list[list[Stroke]] = [105+    # 0 — Fehu ᚠ : central stave, two upward-right branches106+    [107+        (C, B, C, T),108+        (C, 560, R - 20, T),109+        (C, 380, R - 20, 540),110+    ],111+    # 1 — Uruz ᚢ : inverted-U with right leg foreshortened112+    [113+        (LL, B, LL, T),114+        (LL, T, RR, T),115+        (RR, T, RR + 20, 60),116+    ],117+    # 2 — Thurisaz ᚦ : stave with right-side triangular thorn118+    [119+        (LL, B, LL, T),120+        (LL, HI + 30, R - 20, M + 20),121+        (LL, M - 70, R - 20, M + 20),122+    ],123+    # 3 — Ansuz ᚨ : stave with two backward-angled branches at top-right124+    [125+        (LL, B, LL, T),126+        (LL, T - 40, R - 20, T - 200),127+        (LL, T - 200, R - 20, T - 360),128+    ],129+    # 4 — Raido ᚱ : stave + R-loop top + diagonal leg130+    [131+        (LL, B, LL, T),132+        (LL, T, R - 20, T - 80),133+        (R - 20, T - 80, LL, M + 30),134+        (LL, M + 30, R - 20, B),135+    ],136+    # 5 — Kenaz ᚲ : open angle bracket pointing right137+    [138+        (L + 20, T, R - 20, M),139+        (L + 20, B, R - 20, M),140+    ],141+    # 6 — Gebo ᚷ : pure X, no stave142+    [143+        (L, B, R, T),144+        (L, T, R, B),145+    ],146+    # 7 — Wunjo ᚹ : stave with triangular pennant147+    [148+        (LL, B, LL, T),149+        (LL, T, R - 20, T - 80),150+        (R - 20, T - 80, LL, T - 220),151+    ],152+    # 8 — Hagalaz ᚺ : two parallel staves with single diagonal crossbar153+    [154+        (LL, B, LL, T),155+        (RR + 20, B, RR + 20, T),156+        (LL, HI - 20, RR + 20, LO + 60),157+    ],158+    # 9 — Naudiz ᚾ : stave with diagonal cross159+    [160+        (C, B, C, T),161+        (C - 160, M - 120, C + 160, M + 120),162+    ],163+    # 10 — Isa ᛁ : single vertical164+    [165+        (C, B, C, T),166+    ],167+    # 11 — Jera ᛃ : two opposing angular brackets, offset168+    [169+        (L + 20, T, C, HI - 30),170+        (C, HI - 30, L + 20, M),171+        (R - 20, M, C, LO + 30),172+        (C, LO + 30, R - 20, B),173+    ],174+    # 12 — Eihwaz ᛇ : diagonal stave with branches each end175+    [176+        (LL + 20, B, RR - 20, T),177+        (RR - 20, T, R + 20, T - 120),178+        (LL + 20, B, L - 20, 120),179+    ],180+    # 13 — Pertho ᛈ : bracket opening right181+    [182+        (LL, B, LL, T),183+        (LL, T, R - 20, T - 100),184+        (R - 20, T - 100, R - 20, 100),185+        (R - 20, 100, LL, B),186+    ],187+    # 14 — Algiz ᛉ : Y / upward fork188+    [189+        (C, B, C, M + 50),190+        (C, M + 50, L + 20, T),191+        (C, M + 50, R - 20, T),192+    ],193+    # 15 — Sowilo ᛊ : angular zigzag (lightning)194+    [195+        (R - 20, T, L + 60, HI - 20),196+        (L + 60, HI - 20, R - 60, M - 20),197+        (R - 60, M - 20, L + 20, B),198+    ],199+    # 16 — Tiwaz ᛏ : upward arrow200+    [201+        (C, B, C, T),202+        (C, T, L + 40, T - 160),203+        (C, T, R - 40, T - 160),204+    ],205+    # 17 — Berkano ᛒ : stave with two stacked right-side triangles206+    [207+        (LL, B, LL, T),208+        (LL, T, R - 20, T - 130),209+        (R - 20, T - 130, LL, M + 40),210+        (LL, M + 40, R - 20, M - 130),211+        (R - 20, M - 130, LL, B + 30),212+    ],213+    # 18 — Ehwaz ᛖ : two staves with inner X214+    [215+        (LL, B, LL, T),216+        (RR + 20, B, RR + 20, T),217+        (LL, T, RR + 20, M),218+        (RR + 20, T, LL, M),219+    ],220+    # 19 — Mannaz ᛗ : two staves + inner X reaching from top corners221+    [222+        (LL, B, LL, T),223+        (RR + 20, B, RR + 20, T),224+        (LL, T, RR + 20, M - 40),225+        (RR + 20, T, LL, M - 40),226+    ],227+    # 20 — Laguz ᛚ : stave with single short upper-right branch228+    [229+        (LL, B, LL, T),230+        (LL, T, R - 60, T - 200),231+    ],232+    # 21 — Ingwaz ᛜ : rhombus233+    [234+        (LL, M, C, T - 60),235+        (C, T - 60, RR + 20, M),236+        (RR + 20, M, C, B + 60),237+        (C, B + 60, LL, M),238+    ],239+    # 22 — Dagaz ᛞ : two staves with full X between240+    [241+        (LL, B, LL, T),242+        (RR + 20, B, RR + 20, T),243+        (LL, B, RR + 20, T),244+        (LL, T, RR + 20, B),245+    ],246+    # 23 — Othalan ᛟ : rhombus with splayed legs247+    [248+        (LL, M + 50, C, T - 30),249+        (C, T - 30, RR + 20, M + 50),250+        (RR + 20, M + 50, C, M - 100),251+        (C, M - 100, LL, M + 50),252+        (LL, M + 50, L - 30, B),253+        (RR + 20, M + 50, R + 30, B),254+    ],255+]256+257+258+# ---------------------------------------------------------------------------259+# Layer 1 — Anglo-Saxon Futhorc additions (~15)260+# ---------------------------------------------------------------------------261+#262+# Forms specific to the Anglo-Saxon corpus that diverge visibly from Elder263+# Futhark. Drawn from the Ruthwell Cross and Vienna Codex inscriptions.264+265+ANGLO_SAXON: list[list[Stroke]] = [266+    # 24 — Os ᚩ : Ansuz with branches further down the stave267+    [268+        (LL, B, LL, T),269+        (LL, M + 100, R - 20, M),270+        (LL, M, R - 20, M - 100),271+    ],272+    # 25 — Ac ᚪ : Ansuz inverted (branches at bottom)273+    [274+        (LL, B, LL, T),275+        (LL, 240, R - 20, 100),276+        (LL, 100, R - 20, B - 30),277+    ],278+    # 26 — Æsc ᚫ : stave with two branches each side at top (twin-Ansuz)279+    [280+        (C, B, C, T),281+        (C, T - 40, L + 20, T - 200),282+        (C, T - 200, L + 20, T - 360),283+        (C, T - 40, R - 20, T - 200),284+        (C, T - 200, R - 20, T - 360),285+    ],286+    # 27 — Yr ᚣ : Algiz + tick at top (descender Y)287+    [288+        (C, B - 100, C, M + 50),289+        (C, M + 50, L + 20, T - 80),290+        (C, M + 50, R - 20, T - 80),291+        (L + 60, T, R - 60, T),292+    ],293+    # 28 — Ior ᛡ : crossed-S / fish form294+    [295+        (L + 40, T - 80, R - 40, T - 80),296+        (C, T - 80, C, B + 80),297+        (L + 40, B + 80, R - 40, B + 80),298+    ],299+    # 29 — Ear ᛠ : Ansuz with extra lower branch300+    [301+        (LL, B, LL, T),302+        (LL, T - 40, R - 20, T - 200),303+        (LL, T - 200, R - 20, T - 360),304+        (LL, T - 360, R - 20, T - 520),305+    ],306+    # 30 — Cweorth ᛢ : Pertho with inner crossbar307+    [308+        (LL, B, LL, T),309+        (LL, T, R - 20, T - 100),310+        (R - 20, T - 100, R - 20, 100),311+        (R - 20, 100, LL, B),312+        (LL, M, R - 20, M),313+    ],314+    # 31 — Calc ᛣ : Algiz with broader fork (chalice)315+    [316+        (C, B, C, M),317+        (C, M, L, T),318+        (C, M, R, T),319+        (L, T, L + 60, T - 80),320+        (R, T, R - 60, T - 80),321+    ],322+    # 32 — Stan ᛥ : tree-like stave with paired branches both directions323+    [324+        (C, B, C, T),325+        (C, T, L + 20, T - 140),326+        (C, T, R - 20, T - 140),327+        (C, M + 80, L + 60, M - 60),328+        (C, M + 80, R - 60, M - 60),329+    ],330+    # 33 — Gar ᚸ : Gebo overlaid on Ingwaz331+    [332+        (L, B, R, T),333+        (L, T, R, B),334+        (LL, M, C, T - 60),335+        (C, T - 60, RR + 20, M),336+        (RR + 20, M, C, B + 60),337+        (C, B + 60, LL, M),338+    ],339+    # 34 — Cealc variant : narrow Algiz with side ticks340+    [341+        (C, B, C, T),342+        (C, M + 100, L + 60, T),343+        (C, M + 100, R - 60, T),344+    ],345+    # 35 — Anglo-Saxon long-branch : Naudiz with twin diagonals346+    [347+        (C, B, C, T),348+        (L + 20, M, C, M + 140),349+        (C, M - 140, R - 20, M),350+    ],351+    # 36 — Ymbir : Tiwaz with crossbar at base352+    [353+        (C, B, C, T),354+        (C, T, L + 40, T - 160),355+        (C, T, R - 40, T - 160),356+        (L + 40, B, R - 40, B),357+    ],358+    # 37 — Daeg-variant : Dagaz pinched at center359+    [360+        (LL, B, LL, T),361+        (RR + 20, B, RR + 20, T),362+        (LL, B, C, M),363+        (C, M, RR + 20, T),364+        (LL, T, C, M),365+        (C, M, RR + 20, B),366+    ],367+    # 38 — Anglo-Saxon Hagalaz variant : double-bar368+    [369+        (LL, B, LL, T),370+        (RR + 20, B, RR + 20, T),371+        (LL, HI, RR + 20, HI),372+        (LL, LO, RR + 20, LO),373+    ],374+]375+376+377+# ---------------------------------------------------------------------------378+# Layer 1 — Younger Futhark (~12)379+# ---------------------------------------------------------------------------380+#381+# Simplified Viking-Age forms. Younger Futhark trades nuance for speed382+# of carving — fewer strokes, more decisive geometry.383+384+YOUNGER_FUTHARK: list[list[Stroke]] = [385+    # 39 — Fé (short-twig) : single right-tilting branch386+    [387+        (C, B, C, T),388+        (C, T - 60, R - 40, T),389+    ],390+    # 40 — Úr (short-twig) : single short branch high on stave391+    [392+        (C, B, C, T),393+        (C, T - 30, R - 40, T - 180),394+    ],395+    # 41 — Þurs (short-twig) : small triangular thorn396+    [397+        (LL, B, LL, T),398+        (LL, M + 80, R - 80, M),399+        (LL, M - 80, R - 80, M),400+    ],401+    # 42 — Óss : Ansuz with single branch402+    [403+        (LL, B, LL, T),404+        (LL, T - 40, R - 20, T - 200),405+    ],406+    # 43 — Reið : Raido without the leg407+    [408+        (LL, B, LL, T),409+        (LL, T, R - 20, T - 80),410+        (R - 20, T - 80, LL, M + 30),411+    ],412+    # 44 — Kaun ᚴ : single angled branch low on stave413+    [414+        (C, B, C, T),415+        (C, M + 50, R - 60, T - 40),416+    ],417+    # 45 — Hagall (short-twig) : asterisk / star418+    [419+        (C, B, C, T),420+        (L + 30, M, R - 30, M),421+        (L + 50, M - 160, R - 50, M + 160),422+    ],423+    # 46 — Naud (short-twig) : single tick across stave424+    [425+        (C, B, C, T),426+        (L + 80, M - 60, R - 80, M + 60),427+    ],428+    # 47 — Ár ᛅ : Ansuz mirrored, minimal429+    [430+        (RR + 20, B, RR + 20, T),431+        (RR + 20, T - 40, L + 20, T - 200),432+    ],433+    # 48 — Sól (short-twig) : single diagonal434+    [435+        (R - 40, T, L + 40, B),436+    ],437+    # 49 — Týr ᛐ : Tiwaz with shorter arms438+    [439+        (C, B, C, T),440+        (C, T, L + 80, T - 100),441+        (C, T, R - 80, T - 100),442+    ],443+    # 50 — Maðr (short-twig) : single upward branch444+    [445+        (C, B, C, T),446+        (C, T - 100, R - 60, T),447+    ],448+    # 51 — Ýr (younger) : Algiz inverted (downward fork)449+    [450+        (C, T, C, M - 50),451+        (C, M - 50, L + 20, B),452+        (C, M - 50, R - 20, B),453+    ],454+]455+456+457+# ---------------------------------------------------------------------------458+# Layer 1 — Medieval / calendar / Tolkien / Franks Casket (~24)459+# ---------------------------------------------------------------------------460+461+MEDIEVAL: list[list[Stroke]] = [462+    # 52 — Medieval dotted-K : Kenaz with pommel463+    [464+        (L + 20, T, R - 20, M),465+        (L + 20, B, R - 20, M),466+        (R - 60, M, R + 20, M),467+    ],468+    # 53 — Medieval Æ : ligature stave with inner crossbar469+    [470+        (LL, B, LL, T),471+        (RR + 20, B, RR + 20, T),472+        (LL, M + 80, RR + 20, M + 80),473+        (LL, M - 80, RR + 20, M - 80),474+        (LL, T, RR + 20, B),475+    ],476+    # 54 — Arlaug ᛮ (calendar Golden Number 17)477+    [478+        (LL, B, LL, T),479+        (RR + 20, B, RR + 20, T),480+        (LL, T, RR + 20, M),481+        (LL, M, RR + 20, T),482+    ],483+    # 55 — Tvimadur ᛯ (calendar Golden Number 18)484+    [485+        (LL, B, LL, T),486+        (RR + 20, B, RR + 20, T),487+        (LL, T - 80, C, T),488+        (C, T, RR + 20, T - 80),489+        (LL, B + 80, C, B),490+        (C, B, RR + 20, B + 80),491+    ],492+    # 56 — Belgthor ᛰ (calendar Golden Number 19)493+    [494+        (C, B, C, T),495+        (L + 30, T, R - 30, T),496+        (L + 30, M, R - 30, M),497+        (L + 30, B, R - 30, B),498+    ],499+    # 57 — Medieval Y-rune : Algiz with horizontal at top500+    [501+        (C, B, C, M + 50),502+        (C, M + 50, L + 20, T),503+        (C, M + 50, R - 20, T),504+        (L + 20, T, R - 20, T),505+    ],506+    # 58 — Medieval dotted-S : Sowilo with central tick507+    [508+        (R - 20, T, L + 60, HI - 20),509+        (L + 60, HI - 20, R - 60, M - 20),510+        (R - 60, M - 20, L + 20, B),511+        (C - 30, M - 20, C + 30, M - 20),512+    ],513+    # 59 — Calendar weekday : two stacked V's514+    [515+        (L + 40, T, C, T - 200),516+        (C, T - 200, R - 40, T),517+        (L + 40, T - 320, C, T - 520),518+        (C, T - 520, R - 40, T - 320),519+    ],520+    # 60 — Dalecarlian Æ : Ehwaz with center mark521+    [522+        (LL, B, LL, T),523+        (RR + 20, B, RR + 20, T),524+        (LL, T, RR + 20, M),525+        (RR + 20, T, LL, M),526+        (C, M - 80, C, M + 20),527+    ],528+    # 61 — Dalecarlian Ø : Othalan tightened529+    [530+        (LL, M + 50, C, T - 80),531+        (C, T - 80, RR + 20, M + 50),532+        (RR + 20, M + 50, C, M - 60),533+        (C, M - 60, LL, M + 50),534+    ],535+    # 62 — Medieval Hagalaz with double crossbar536+    [537+        (LL, B, LL, T),538+        (RR + 20, B, RR + 20, T),539+        (LL, HI, RR + 20, LO + 80),540+        (LL, LO + 80, RR + 20, HI),541+    ],542+    # 63 — Pentadic-1 : single-tick stave543+    [544+        (C, B, C, T),545+        (C, T - 40, R - 60, T - 40),546+    ],547+    # 64 — Pentadic-3 : triple ticks548+    [549+        (C, B, C, T),550+        (C, T - 40, R - 60, T - 40),551+        (C, T - 200, R - 60, T - 200),552+        (C, T - 360, R - 60, T - 360),553+    ],554+    # 65 — Pentadic-5 : full pentadic frame555+    [556+        (C, B, C, T),557+        (C, T, R - 60, T - 80),558+        (R - 60, T - 80, C, T - 160),559+        (C, T - 160, R - 60, T - 240),560+    ],561+    # 66 — Tolkien K : Kenaz with stave562+    [563+        (LL, B, LL, T),564+        (LL, M, R - 20, T),565+        (LL, M, R - 20, B),566+    ],567+    # 67 — Tolkien OO : doubled Os568+    [569+        (L + 20, B, L + 20, T),570+        (R - 20, B, R - 20, T),571+        (L + 20, M + 100, R - 20, M),572+        (L + 20, M, R - 20, M - 100),573+    ],574+    # 68 — Tolkien SH : Sowilo with leading tick575+    [576+        (R - 20, T, L + 60, HI - 20),577+        (L + 60, HI - 20, R - 60, M - 20),578+        (R - 60, M - 20, L + 20, B),579+        (R - 20, T, R + 30, T - 60),580+    ],581+    # 69 — Franks Casket cryptic-1 : tree-fork inverted582+    [583+        (C, B, C, M),584+        (C, M, L, M + 200),585+        (C, M, R, M + 200),586+        (L, M + 200, L + 60, T),587+        (R, M + 200, R - 60, T),588+    ],589+    # 70 — Franks Casket cryptic-2 : multi-pronged stave590+    [591+        (C, B, C, T),592+        (C, T - 80, L + 30, T),593+        (C, T - 80, R - 30, T),594+        (C, M + 80, L + 80, M + 200),595+        (C, M + 80, R - 80, M + 200),596+    ],597+    # 71 — Franks Casket cryptic-3 : nested chevrons598+    [599+        (L + 20, T, C, T - 200),600+        (C, T - 200, R - 20, T),601+        (L + 80, T - 80, C, T - 240),602+        (C, T - 240, R - 80, T - 80),603+        (C, T - 240, C, B),604+    ],605+    # 72 — Franks Casket cryptic-4 : grid606+    [607+        (LL, B + 80, LL, T - 80),608+        (RR + 20, B + 80, RR + 20, T - 80),609+        (LL, B + 80, RR + 20, B + 80),610+        (LL, M, RR + 20, M),611+        (LL, T - 80, RR + 20, T - 80),612+    ],613+    # 73 — Franks Casket cryptic-5 : zigzag column614+    [615+        (L + 40, T, R - 40, T - 140),616+        (R - 40, T - 140, L + 40, T - 280),617+        (L + 40, T - 280, R - 40, T - 420),618+        (R - 40, T - 420, L + 40, T - 560),619+    ],620+    # 74 — Medieval bow-rune : nested rhombus621+    [622+        (LL, M, C, T - 30),623+        (C, T - 30, RR + 20, M),624+        (RR + 20, M, C, B + 30),625+        (C, B + 30, LL, M),626+        (C, M + 200, C, M - 200),627+    ],628+]629+630+631+LAYER_1: list[list[Stroke]] = (632+    ELDER_FUTHARK + ANGLO_SAXON + YOUNGER_FUTHARK + MEDIEVAL633+)634+assert len(LAYER_1) == 75, f"Layer 1 expected 75, got {len(LAYER_1)}"635+636+637+# ---------------------------------------------------------------------------638+# Geometric transforms639+# ---------------------------------------------------------------------------640+641+def mirror(strokes: list[Stroke]) -> list[Stroke]:642+    """Reflect strokes horizontally around x = ADVANCE/2."""643+    cx = ADVANCE / 2644+    return [(2 * cx - x1, y1, 2 * cx - x2, y2) for x1, y1, x2, y2 in strokes]645+646+647+def rotate_180(strokes: list[Stroke]) -> list[Stroke]:648+    """Rotate strokes 180° around (ADVANCE/2, T/2)."""649+    cx, cy = ADVANCE / 2, T / 2650+    return [(2 * cx - x1, 2 * cy - y1, 2 * cx - x2, 2 * cy - y2) for x1, y1, x2, y2 in strokes]651+652+653+def _key(strokes: list[Stroke]) -> tuple:654+    return tuple(sorted(tuple(round(v, 1) for v in s) for s in strokes))655+656+657+# ---------------------------------------------------------------------------658+# Layer 2 — mirror forms (70 glyphs)659+# ---------------------------------------------------------------------------660+#661+# Reflect each layer-1 form horizontally. When the mirror equals the662+# source (true symmetry around x=ADVANCE/2), substitute a 180° rotation —663+# also a documented historical extension. Skip the rare case where both664+# transforms collapse to the source (point-symmetric forms).665+666+LAYER_2: list[list[Stroke]] = []667+for _i in range(75):668+    _base = LAYER_1[_i]669+    _src_key = _key(_base)670+    _candidate = mirror(_base)671+    if _key(_candidate) == _src_key:672+        _candidate = rotate_180(_base)673+        if _key(_candidate) == _src_key:674+            continue675+    LAYER_2.append(_candidate)676+    if len(LAYER_2) >= 70:677+        break678+assert len(LAYER_2) == 70, f"Layer 2 expected 70, got {len(LAYER_2)}"679+680+681+# ---------------------------------------------------------------------------682+# Layer 3 — bind runes (60 glyphs)683+# ---------------------------------------------------------------------------684+#685+# Two runes share a single central stave. We strip non-stave strokes686+# from the second rune and overlay them on the first. The bind list687+# starts with documented historical bindings, then continues through688+# combinatorially attractive pairings.689+690+BIND_PAIRS: list[tuple[int, int]] = [691+    # Historically attested692+    (2, 3),    # Thurisaz + Ansuz693+    (4, 5),    # Raido + Kenaz694+    (16, 10),  # Tiwaz + Isa695+    (17, 18),  # Berkano + Ehwaz696+    (19, 20),  # Mannaz + Laguz697+    (6, 7),    # Gebo + Wunjo698+    (0, 16),   # Fehu + Tiwaz (wealth-victory)699+    (3, 14),   # Ansuz + Algiz (god-protection)700+    (16, 0),   # Tiwaz + Fehu (alt order)701+    (4, 0),    # Raido + Fehu702+    # Common Viking-era703+    (44, 0),   # Kaun + Fehu704+    (47, 39),  # Ár + Fé (alt order)705+    (49, 16),  # Týr + Tiwaz706+    (50, 47),  # Maðr + Ár707+    (39, 40),  # Fé + Úr708+    # Anglo-Saxon709+    (24, 25),  # Os + Ac710+    (29, 30),  # Ear + Cweorth711+    (31, 14),  # Calc + Algiz712+    (32, 16),  # Stan + Tiwaz713+    (33, 21),  # Gar + Ingwaz714+    # Programmatic remainder715+]716+# Pad out to 60 by combining each Elder Futhark form with the next one717+# (skipping pairs that would degenerate). This is a programmatic rather718+# than attested set — but the visual grammar (shared stave + branches)719+# stays consistent.720+BIND_FILLER: list[tuple[int, int]] = []721+for a in range(24):722+    for b in range(a + 1, 24):723+        if (a, b) in BIND_PAIRS or (b, a) in BIND_PAIRS:724+            continue725+        if a in {6, 10, 21, 22} or b in {6, 10, 21, 22}:726+            continue727+        BIND_FILLER.append((a, b))728+        if len(BIND_PAIRS) + len(BIND_FILLER) >= 60:729+            break730+    if len(BIND_PAIRS) + len(BIND_FILLER) >= 60:731+        break732+BIND_PAIRS = BIND_PAIRS + BIND_FILLER733+BIND_PAIRS = BIND_PAIRS[:60]734+735+736+def is_central_stave(s: Stroke) -> bool:737+    """A stroke counts as the central stave if it sits on x≈C and runs vertically."""738+    x1, y1, x2, y2 = s739+    return abs(x1 - C) < 30 and abs(x2 - C) < 30 and abs(y2 - y1) > 200740+741+742+def to_central(strokes: list[Stroke]) -> list[Stroke]:743+    """Translate a left-staved rune onto the central stave so it can bind."""744+    if not strokes:745+        return strokes746+    # If any stroke is already a central stave, leave the rune alone.747+    if any(is_central_stave(s) for s in strokes):748+        return strokes749+    # Otherwise shift everything from LL → C.750+    dx = C - LL751+    return [(x1 + dx, y1, x2 + dx, y2) for x1, y1, x2, y2 in strokes]752+753+754+def bind(a: list[Stroke], b: list[Stroke]) -> list[Stroke]:755+    a2 = to_central(a)756+    b2 = to_central(b)757+    # Take all strokes from a, then all non-stave strokes from b,758+    # mirrored so they extend on the opposite side.759+    out = list(a2)760+    for s in b2:761+        if is_central_stave(s):762+            continue763+        # Mirror b's branches so they live on the opposite side of the stave.764+        x1, y1, x2, y2 = s765+        out.append((2 * C - x1, y1, 2 * C - x2, y2))766+    return out767+768+769+LAYER_3: list[list[Stroke]] = [bind(LAYER_1[a], LAYER_1[b]) for a, b in BIND_PAIRS]770+assert len(LAYER_3) == 60, f"Layer 3 expected 60, got {len(LAYER_3)}"771+772+773+# ---------------------------------------------------------------------------774+# Layer 4 — staveless (Hälsinge) variants (51 glyphs)775+# ---------------------------------------------------------------------------776+777+def remove_stave(strokes: list[Stroke]) -> list[Stroke]:778+    """Drop the central vertical stave; keep branching strokes."""779+    return [s for s in strokes if not is_central_stave(s) and not _is_left_stave(s)]780+781+782+def _is_left_stave(s: Stroke) -> bool:783+    x1, y1, x2, y2 = s784+    return abs(x1 - LL) < 30 and abs(x2 - LL) < 30 and abs(y2 - y1) > 200785+786+787+# Source pool for staveless: layer 1 forms with branching content beyond788+# just a stave. We try staveless first; if the result is too sparse789+# (fewer than 1 stroke), we substitute a 180° rotation instead.790+STAVELESS_CANDIDATES = [791+    i for i in range(75)792+    if i not in {10, 6, 21, 22, 38, 56, 60, 61, 62, 67, 72}793+]794+795+796+def staveless_or_rotate(idx: int) -> list[Stroke]:797+    base = LAYER_1[idx]798+    stripped = remove_stave(base)799+    if len(stripped) >= 2:800+        return stripped801+    return rotate_180(base)802+803+804+LAYER_4: list[list[Stroke]] = [staveless_or_rotate(i) for i in STAVELESS_CANDIDATES[:51]]805+assert len(LAYER_4) == 51, f"Layer 4 expected 51, got {len(LAYER_4)}"806+807+808+# ---------------------------------------------------------------------------809+# Assemble all 256 glyphs810+# ---------------------------------------------------------------------------811+812+ALL_GLYPHS: list[list[Stroke]] = LAYER_1 + LAYER_2 + LAYER_3 + LAYER_4813+assert len(ALL_GLYPHS) == 256, f"Total expected 256, got {len(ALL_GLYPHS)}"814+815+816+# ---------------------------------------------------------------------------817+# Build the OTF818+# ---------------------------------------------------------------------------819+820+def build() -> None:821+    repo_root = Path(__file__).resolve().parent.parent822+    out_path = repo_root / "public" / "fonts" / "PeachScript-Regular.otf"823+824+    glyph_names = [".notdef"] + [f"ps{i:03d}" for i in range(256)]825+    cmap = {0xE000 + i: f"ps{i:03d}" for i in range(256)}826+827+    fb = FontBuilder(UPM, isTTF=False)828+    fb.setupGlyphOrder(glyph_names)829+    fb.setupCharacterMap(cmap)830+831+    charstrings = {".notdef": draw([(C, B, C, T)])}  # mark as a single stave832+    for i, strokes in enumerate(ALL_GLYPHS):833+        charstrings[f"ps{i:03d}"] = draw(strokes)834+835+    private = {836+        "BlueValues": [],837+        "OtherBlues": [],838+        "FamilyBlues": [],839+        "FamilyOtherBlues": [],840+        "StemSnapH": [],841+        "StemSnapV": [],842+        "ForceBold": 0,843+        "LanguageGroup": 0,844+        "ExpansionFactor": 0.06,845+        "initialRandomSeed": 0,846+        "defaultWidthX": ADVANCE,847+        "nominalWidthX": ADVANCE,848+    }849+850+    fb.setupCFF(851+        psName="PeachScript-Regular",852+        fontInfo={853+            "FullName": "PeachScript Regular",854+            "FamilyName": "PeachScript",855+            "Weight": "Regular",856+            "version": "2.0",857+        },858+        privateDict=private,859+        charStringsDict=charstrings,860+    )861+862+    metrics = {name: (ADVANCE, 0) for name in glyph_names}863+    fb.setupHorizontalMetrics(metrics)864+    fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT, lineGap=LINE_GAP)865+    fb.setupNameTable({866+        "familyName": "PeachScript",867+        "styleName": "Regular",868+        "uniqueFontIdentifier": "PeachScript-Regular;v2.0",869+        "fullName": "PeachScript Regular",870+        "psName": "PeachScript-Regular",871+        "version": "Version 2.000",872+    })873+    fb.setupOS2(874+        sTypoAscender=ASCENT,875+        sTypoDescender=DESCENT,876+        sTypoLineGap=LINE_GAP,877+        usWinAscent=ASCENT,878+        usWinDescent=-DESCENT,879+        sxHeight=int(M),880+        sCapHeight=T,881+        achVendID="PEAC",882+        fsType=0,883+    )884+    fb.setupPost()885+886+    out_path.parent.mkdir(parents=True, exist_ok=True)887+    fb.save(out_path)888+    print(f"wrote {out_path} ({out_path.stat().st_size} bytes)")889+890+891+if __name__ == "__main__":892+    build()

view file