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
↓ peach-browser-f3478eae.tar.gzatlas at this save
files changed2 files
public/fonts/PeachScript-Regular.otfbinaryscripts/build-peachscript.py+892 −0scripts/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()