build-peachscript.py

30.2 KB · raw

1#!/usr/bin/env python32"""Build PeachScript-Regular.otf — 256 historical-runic glyphs.34Glyphs are drawn in a stroke-based grammar: every glyph is a list of5straight line segments rendered as filled rectangles of uniform width.6The four authentic rune-extension techniques (mirror, bind, staveless,7rotated) are applied programmatically over a hand-authored base of 758historical forms drawn from Elder Futhark, Anglo-Saxon Futhorc, Younger9Futhark, medieval calendar runes, Tolkien's variants, and the Franks10Casket cryptograms.1112Output: public/fonts/PeachScript-Regular.otf13        256 glyphs at U+E000..U+E0FF, CFF outlines, UPM 1000.14"""1516from __future__ import annotations1718import math19import sys20from pathlib import Path21from typing import Iterable2223from fontTools.fontBuilder import FontBuilder24from fontTools.pens.t2CharStringPen import T2CharStringPen252627# ---------------------------------------------------------------------------28# Font metrics29# ---------------------------------------------------------------------------3031UPM = 100032ADVANCE = 56033ASCENT = 75034DESCENT = -20035LINE_GAP = 036STROKE = 90  # uniform stroke width — uniform-chisel feel3738# Drawing rectangle (most glyphs sit inside these bounds)39B = 0          # baseline40T = 700        # cap top41M = 350        # mid42LO = 175       # lower third43HI = 525       # upper third4445# Horizontal anchors46L = 100        # leftmost extent (branch tip)47LL = 180       # left-stave x48C = 280        # central-stave x49RR = 380       # right-stave x50R = 460        # rightmost extent515253Stroke = tuple[float, float, float, float]545556# ---------------------------------------------------------------------------57# Stroke -> filled rectangle58# ---------------------------------------------------------------------------5960def 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.6263    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    ]798081def 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()949596# ---------------------------------------------------------------------------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.103104ELDER_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]256257258# ---------------------------------------------------------------------------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.264265ANGLO_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]375376377# ---------------------------------------------------------------------------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.383384YOUNGER_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]455456457# ---------------------------------------------------------------------------458# Layer 1 — Medieval / calendar / Tolkien / Franks Casket (~24)459# ---------------------------------------------------------------------------460461MEDIEVAL: 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]629630631LAYER_1: list[list[Stroke]] = (632    ELDER_FUTHARK + ANGLO_SAXON + YOUNGER_FUTHARK + MEDIEVAL633)634assert len(LAYER_1) == 75, f"Layer 1 expected 75, got {len(LAYER_1)}"635636637# ---------------------------------------------------------------------------638# Geometric transforms639# ---------------------------------------------------------------------------640641def 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]645646647def 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]651652653def _key(strokes: list[Stroke]) -> tuple:654    return tuple(sorted(tuple(round(v, 1) for v in s) for s in strokes))655656657# ---------------------------------------------------------------------------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).665666LAYER_2: list[list[Stroke]] = []667for _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        break678assert len(LAYER_2) == 70, f"Layer 2 expected 70, got {len(LAYER_2)}"679680681# ---------------------------------------------------------------------------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.689690BIND_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.720BIND_FILLER: list[tuple[int, int]] = []721for 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        break732BIND_PAIRS = BIND_PAIRS + BIND_FILLER733BIND_PAIRS = BIND_PAIRS[:60]734735736def 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) > 200740741742def 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]752753754def 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 out767768769LAYER_3: list[list[Stroke]] = [bind(LAYER_1[a], LAYER_1[b]) for a, b in BIND_PAIRS]770assert len(LAYER_3) == 60, f"Layer 3 expected 60, got {len(LAYER_3)}"771772773# ---------------------------------------------------------------------------774# Layer 4 — staveless (Hälsinge) variants (51 glyphs)775# ---------------------------------------------------------------------------776777def 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)]780781782def _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) > 200785786787# 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.790STAVELESS_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]794795796def 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)802803804LAYER_4: list[list[Stroke]] = [staveless_or_rotate(i) for i in STAVELESS_CANDIDATES[:51]]805assert len(LAYER_4) == 51, f"Layer 4 expected 51, got {len(LAYER_4)}"806807808# ---------------------------------------------------------------------------809# Assemble all 256 glyphs810# ---------------------------------------------------------------------------811812ALL_GLYPHS: list[list[Stroke]] = LAYER_1 + LAYER_2 + LAYER_3 + LAYER_4813assert len(ALL_GLYPHS) == 256, f"Total expected 256, got {len(ALL_GLYPHS)}"814815816# ---------------------------------------------------------------------------817# Tuning overrides — replace specific weak glyphs with stronger forms.818#819# These overrides come from peachscript_tuning_brief.md. Each entry820# replaces a final-index glyph (post-layer-assembly position) with a821# hand-tuned stroke set. Reasons range from broken-rendering risk822# (curved fallback at 214), accidental duplicates (240 = Isa), and823# forms too sparse to read at 11–18px display.824# ---------------------------------------------------------------------------825826OVERRIDES: dict[int, list[Stroke]] = {827    # 187 — short-twig Yr : stave + small downward fork high828    187: [829        (C, B, C, T),830        (C, T * 2 // 3, L + (R - L) // 3, T // 3),831        (C, T * 2 // 3, L + (R - L) * 2 // 3, T // 3),832    ],833    # 188 — short-twig As : stave with one upward-right and one downward-right branch.834    # The brief's pseudocode collapses the branch endpoints onto the stave;835    # interpreting the descriptive text instead.836    188: [837        (C, B, C, T),838        (C, T * 3 // 4, R - 60, T),839        (C, T // 4, R - 60, B),840    ],841    # 192 — mirrored Laguz : stave + diagonal foot pointing down-left + mid crossbar842    192: [843        (C, B, C, T),844        (C, B, L, T // 3),845        (L, M, L + (R - L) // 2, M),846    ],847    # 205 — Hälsinge Hagalaz : tilted X, no stave848    205: [849        (L, T // 4, R, T * 3 // 4),850        (L, T * 3 // 4, R, T // 4),851    ],852    # 206 — Hälsinge Naudiz : long diagonal + offset short diagonal853    206: [854        (L, B, R, T),855        (L + (R - L) // 3, T, R, T // 4),856    ],857    # 207 — Hälsinge Tiwaz : open chevron, no stave858    207: [859        (L, T // 3, C, T),860        (C, T, R, T // 3),861    ],862    # 213 — Hälsinge Kenaz pair : > opening left at top + vertical connector863    213: [864        (L, T // 4, L + (R - L) * 2 // 3, T // 2),865        (L + (R - L) * 2 // 3, T // 2, L, T * 3 // 4),866        (L + (R - L) * 2 // 3, T // 4, L + (R - L) * 2 // 3, T * 3 // 4),867    ],868    # 214 — Dagaz horizontal bowtie : two triangles meeting at center869    214: [870        (L, B, C, M),871        (C, M, R, B),872        (L, T, C, M),873        (C, M, R, T),874    ],875    # 225 — Armanen Man variant : two outer staves + downward V from top corners + cross876    225: [877        (L, B, L, T),878        (R, B, R, T),879        (L, T, C, M),880        (C, M, R, T),881        (L, T, R, M),882    ],883    # 226 — compressed Naudiz : stave + two short branches angled in at mid884    226: [885        (C, B, C, T),886        (C, M + 20, C - (R - L) // 3, M - 20),887        (C, M + 20, C + (R - L) // 3, M - 20),888    ],889    # 228 — Dalecarlian Beorc : stave + horizontal at mid + two diagonals back to stave890    228: [891        (C, B, C, T),892        (C, M, C + (R - L) // 2, M),893        (C + (R - L) // 2, M, C, T // 4),894        (C + (R - L) // 2, M, C, T * 3 // 4),895    ],896    # 230 — Franks Casket vowel cryptogram : stave + two short horizontals near top/bottom897    230: [898        (C, B, C, T),899        (C, T // 6, C + (R - L) // 3, T // 6),900        (C, T * 5 // 6, C + (R - L) // 3, T * 5 // 6),901    ],902    # 231 — Hälsinge Jera : two near-parallel diagonals with deliberate gap903    231: [904        (L, T * 3 // 4, R, T // 4),905        (L, T // 4 + 30, R, T * 3 // 4 - 30),906    ],907    # 240 — Armanen Gibor : horizontal mid + two outward-opening K shapes908    240: [909        (L, M, R, M),910        (L, M, L + (R - L) // 3, B),911        (L, M, L + (R - L) // 3, T),912        (R, M, L + (R - L) * 2 // 3, B),913        (R, M, L + (R - L) * 2 // 3, T),914    ],915    # 241 — crossed Isa : two parallel verticals + X916    241: [917        (L + (R - L) // 3, B, L + (R - L) // 3, T),918        (L + (R - L) * 2 // 3, B, L + (R - L) * 2 // 3, T),919        (L, T // 3, R, T * 2 // 3),920        (L, T * 2 // 3, R, T // 3),921    ],922}923924for _idx, _strokes in OVERRIDES.items():925    ALL_GLYPHS[_idx] = _strokes926927928# ---------------------------------------------------------------------------929# Build the OTF930# ---------------------------------------------------------------------------931932def build() -> None:933    repo_root = Path(__file__).resolve().parent.parent934    out_path = repo_root / "public" / "fonts" / "PeachScript-Regular.otf"935936    glyph_names = [".notdef"] + [f"ps{i:03d}" for i in range(256)]937    cmap = {0xE000 + i: f"ps{i:03d}" for i in range(256)}938939    fb = FontBuilder(UPM, isTTF=False)940    fb.setupGlyphOrder(glyph_names)941    fb.setupCharacterMap(cmap)942943    charstrings = {".notdef": draw([(C, B, C, T)])}  # mark as a single stave944    for i, strokes in enumerate(ALL_GLYPHS):945        charstrings[f"ps{i:03d}"] = draw(strokes)946947    private = {948        "BlueValues": [-12, 0, T, T + 12],949        "OtherBlues": [DESCENT - 12, DESCENT],950        "BlueScale": 0.039625,951        "BlueShift": 7,952        "BlueFuzz": 1,953        "StdHW": STROKE,954        "StdVW": STROKE,955        "ForceBold": 0,956        "LanguageGroup": 0,957        "ExpansionFactor": 0.06,958        "initialRandomSeed": 0,959        "defaultWidthX": ADVANCE,960        "nominalWidthX": ADVANCE,961    }962963    fb.setupCFF(964        psName="PeachScript-Regular",965        fontInfo={966            "FullName": "PeachScript Regular",967            "FamilyName": "PeachScript",968            "Weight": "Regular",969            "version": "2.0",970        },971        privateDict=private,972        charStringsDict=charstrings,973    )974975    metrics = {name: (ADVANCE, 0) for name in glyph_names}976    fb.setupHorizontalMetrics(metrics)977    fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT, lineGap=LINE_GAP)978    fb.setupNameTable({979        "familyName": "PeachScript",980        "styleName": "Regular",981        "uniqueFontIdentifier": "PeachScript-Regular;v2.0",982        "fullName": "PeachScript Regular",983        "psName": "PeachScript-Regular",984        "version": "Version 2.000",985    })986    fb.setupOS2(987        sTypoAscender=ASCENT,988        sTypoDescender=DESCENT,989        sTypoLineGap=LINE_GAP,990        usWinAscent=ASCENT,991        usWinDescent=-DESCENT,992        sxHeight=int(M),993        sCapHeight=T,994        achVendID="PEAC",995        fsType=0,996    )997    fb.setupPost()998999    out_path.parent.mkdir(parents=True, exist_ok=True)1000    fb.save(out_path)1001    print(f"wrote {out_path} ({out_path.stat().st_size} bytes)")100210031004if __name__ == "__main__":1005    build()