#!/usr/bin/env python3 """Generate Processional, a monumental unicase display typeface. Processional translates Cosmic Liturgical Brutalism into letterforms: tall pylons carry the weight, thinner slabs establish rhythm, and circular counters behave as apertures cut through architectural mass. The geometry is original and built directly from the primitives below. """ from __future__ import annotations import math import os from pathlib import Path os.environ.setdefault("SOURCE_DATE_EPOCH", "1785028800") from fontTools.fontBuilder import FontBuilder from fontTools.feaLib.builder import addOpenTypeFeaturesFromString from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont from shapely.affinity import scale, translate from shapely.geometry import LineString, Point, Polygon, box from shapely.geometry.polygon import orient from shapely.ops import unary_union UPM = 1000 CAP = 800 ASCENDER = 920 DESCENDER = -180 PYLON = 112 SLAB = 76 DIAGONAL = 96 OUT_DIR = Path(__file__).resolve().parents[1] / "public" / "fonts" def line(*points: tuple[float, float]) -> list[tuple[float, float]]: return list(points) def arc( cx: float, cy: float, rx: float, ry: float, start: float, end: float, steps: int = 128, ) -> list[tuple[float, float]]: return [ ( cx + rx * math.cos(math.radians(start + (end - start) * i / steps)), cy + ry * math.sin(math.radians(start + (end - start) * i / steps)), ) for i in range(steps + 1) ] def cubic(p0, p1, p2, p3, steps: int = 128): points = [] for index in range(steps + 1): t = index / steps u = 1 - t points.append( ( u**3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t**3 * p3[0], u**3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t**3 * p3[1], ) ) return points def cubic_bspline(control_points, steps_per_segment: int = 64): """Sample one continuous cubic B-spline through guiding controls.""" points = [control_points[0]] * 3 + control_points[1:-1] + [control_points[-1]] * 3 sampled = [] for segment in range(len(points) - 3): for step in range(steps_per_segment + 1): if segment and step == 0: continue t = step / steps_per_segment weights = ( (1 - t) ** 3 / 6, (3 * t**3 - 6 * t**2 + 4) / 6, (-3 * t**3 + 3 * t**2 + 3 * t + 1) / 6, t**3 / 6, ) sampled.append( tuple( sum(weights[index] * points[segment + index][axis] for index in range(4)) for axis in range(2) ) ) return sampled def ellipse(cx: float, cy: float, rx: float, ry: float): unit = Point(cx, cy).buffer(1, resolution=128) return scale(unit, xfact=rx, yfact=ry, origin=(cx, cy)) def circle(cx: float, cy: float, radius: float): return Point(cx, cy).buffer(radius, resolution=128) def rounded_rect(x0: float, y0: float, x1: float, y1: float, radius: float): radius = min(radius, (x1 - x0) / 2, (y1 - y0) / 2) return unary_union( [ box(x0 + radius, y0, x1 - radius, y1), box(x0, y0 + radius, x1, y1 - radius), circle(x0 + radius, y0 + radius, radius), circle(x1 - radius, y0 + radius, radius), circle(x0 + radius, y1 - radius, radius), circle(x1 - radius, y1 - radius, radius), ] ) def stroke(points, width: float = PYLON, *, rounded: bool = False): return LineString(points).buffer( width / 2, cap_style="flat", join_style="round" if rounded else "mitre", mitre_limit=4, resolution=48, ) def strokes(*items): """Union path/width tuples into one architectural form.""" return unary_union([stroke(points, width, rounded=rounded) for points, width, rounded in items]) def aperture_form(cx: float = 300, cy: float = 400): """The characteristic outer ellipse with a true circular aperture.""" return ellipse(cx, cy, 250, 410).difference(circle(cx, cy, 138)) def upper_bowl(cy: float = 600, hole: float = 102): outer = ellipse(150, cy, 360, 205).intersection(box(150, cy - 220, 560, cy + 220)) return outer.difference(circle(300, cy, hole)) def lower_bowl(cy: float = 200, hole: float = 98): outer = ellipse(150, cy, 360, 205).intersection(box(150, cy - 220, 560, cy + 220)) return outer.difference(circle(300, cy, hole)) def raw_geometry(char: str): """Return the unspaced geometry for one drawn character.""" left = box(70, 0, 70 + PYLON, CAP) right = box(418, 0, 418 + PYLON, CAP) top = box(70, CAP - SLAB, 530, CAP) middle = box(70, 400 - SLAB / 2, 470, 400 + SLAB / 2) bottom = box(70, 0, 530, SLAB) if char == "A": monument = Polygon([(58, 0), (205, CAP), (395, CAP), (542, 0)]) doorway = box(252, 0, 348, 205) aperture = circle(300, 465, 93) return monument.difference(unary_union([doorway, aperture])) if char == "B": return unary_union([left, upper_bowl(), lower_bowl()]).buffer(0) if char == "C": opened = aperture_form().difference(box(355, 105, 610, 695)) return opened if char == "D": outer = ellipse(150, 400, 390, 410).intersection(box(150, -20, 570, 820)) return unary_union([left, outer]).difference(circle(295, 400, 128)).buffer(0) if char == "E": return unary_union([left, top, middle, bottom]) if char == "F": return unary_union([left, top, middle]) if char == "G": opened = aperture_form().difference(box(360, 125, 610, 690)) bar = unary_union([box(300, 360, 530, 436), box(418, 160, 530, 436)]) return unary_union([opened, bar]).buffer(0) if char == "H": return unary_union([left, right, middle]) if char == "I": return unary_union([box(244, 0, 356, CAP), box(140, CAP - SLAB, 460, CAP), box(140, 0, 460, SLAB)]) if char == "J": hook = stroke(arc(300, 190, 210, 175, 0, -180), PYLON, rounded=True) return unary_union([top, box(420, 190, 532, CAP), hook]).buffer(0) if char == "K": return strokes( (line((126, 0), (126, CAP)), PYLON, False), (line((150, 365), (500, CAP)), DIAGONAL, False), (line((150, 365), (525, 0)), DIAGONAL, False), ) if char == "L": return unary_union([left, bottom]) if char == "M": return strokes( (line((105, 0), (105, CAP)), PYLON, False), (line((105, CAP), (320, 245), (535, CAP)), DIAGONAL, False), (line((535, CAP), (535, 0)), PYLON, False), ) if char == "N": return strokes( (line((115, 0), (115, CAP)), PYLON, False), (line((115, CAP), (505, 0)), DIAGONAL, False), (line((505, 0), (505, CAP)), PYLON, False), ) if char == "O": return aperture_form() if char == "P": return unary_union([left, upper_bowl()]).buffer(0) if char == "Q": return unary_union([aperture_form(), stroke(line((330, 235), (600, -80)), 82)]).buffer(0) if char == "R": return unary_union([left, upper_bowl(), stroke(line((300, 440), (535, 0)), DIAGONAL)]).buffer(0) if char == "S": centerline = cubic_bspline( [ (505, 700), (395, 850), (105, 810), (68, 590), (265, 435), (500, 330), (525, 115), (315, -45), (70, 90), ] ) return stroke(centerline, 108, rounded=True) if char == "T": return unary_union([box(45, CAP - SLAB, 555, CAP), box(244, 0, 356, CAP)]) if char == "U": centerline = line((126, CAP), (126, 225)) + arc(300, 225, 174, 170, 180, 360)[1:] + line((474, 225), (474, CAP))[1:] return stroke(centerline, PYLON, rounded=True) if char == "V": return stroke(line((80, CAP), (300, 0), (520, CAP)), DIAGONAL) if char == "W": return stroke(line((55, CAP), (175, 0), (350, 480), (525, 0), (645, CAP)), DIAGONAL) if char == "X": return strokes( (line((70, CAP), (530, 0)), DIAGONAL, False), (line((530, CAP), (70, 0)), DIAGONAL, False), ) if char == "Y": return strokes( (line((70, CAP), (300, 410), (530, CAP)), DIAGONAL, False), (line((300, 410), (300, 0)), PYLON, False), ) if char == "Z": return strokes( (line((65, 762), (535, 762)), SLAB, False), (line((520, 760), (80, 38)), DIAGONAL, False), (line((65, 38), (535, 38)), SLAB, False), ) if char == "0": return unary_union([aperture_form(), box(265, 260, 335, 540)]).buffer(0) if char == "1": return unary_union([box(244, 0, 356, CAP), stroke(line((110, 640), (300, CAP)), DIAGONAL), box(110, 0, 490, SLAB)]) if char == "2": path = cubic((72, 590), (85, 850), (505, 870), (520, 610)) path += cubic((520, 610), (520, 450), (160, 250), (70, 38))[1:] return unary_union([stroke(path, 104, rounded=True), box(70, 0, 530, SLAB)]).buffer(0) if char == "3": path = cubic((75, 700), (250, 860), (500, 825), (480, 600)) path += cubic((480, 600), (470, 465), (340, 420), (270, 400))[1:] path += cubic((270, 400), (420, 390), (525, 315), (500, 155))[1:] path += cubic((500, 155), (470, -55), (170, -60), (65, 90))[1:] return stroke(path, 105, rounded=True) if char == "4": return unary_union([box(420, 0, 532, CAP), stroke(line((420, CAP), (65, 245)), DIAGONAL), box(65, 215, 555, 305)]) if char == "5": path = line((510, 760), (100, 760), (75, 420)) curve = cubic((75, 420), (420, 520), (540, 325), (500, 145)) curve += cubic((500, 145), (445, -45), (165, -45), (62, 90))[1:] return unary_union([stroke(path, 92), stroke(curve, 104, rounded=True)]).buffer(0) if char == "6": loop = ellipse(300, 250, 235, 250).difference(circle(300, 250, 105)) sweep = stroke(cubic((500, 700), (265, 900), (70, 635), (70, 255)), 104, rounded=True) return unary_union([loop, sweep]).buffer(0) if char == "7": return unary_union([top, stroke(line((500, 760), (150, 0)), DIAGONAL)]) if char == "8": upper = ellipse(300, 595, 218, 205).difference(circle(300, 595, 98)) lower = ellipse(300, 205, 242, 215).difference(circle(300, 205, 108)) bridge = box(244, 365, 356, 435) return unary_union([upper, lower, bridge]).buffer(0) if char == "9": loop = ellipse(300, 550, 235, 250).difference(circle(300, 550, 105)) sweep = stroke(cubic((500, 545), (530, 170), (310, -100), (95, 85)), 104, rounded=True) return unary_union([loop, sweep]).buffer(0) if char == "-": return box(130, 362, 470, 438) if char == "+": return unary_union([box(90, 362, 510, 438), box(262, 190, 338, 610)]) if char == "=": return unary_union([box(100, 470, 500, 546), box(100, 254, 500, 330)]) if char == "/": return stroke(line((95, -65), (505, 865)), 82) if char == "_": return box(70, -90, 530, -14) if char == "|": return box(244, -80, 356, 880) if char == "!": return unary_union([box(244, 190, 356, CAP), circle(300, 48, 48)]) if char == "?": hook = cubic((70, 610), (85, 865), (515, 870), (520, 610)) hook += cubic((520, 610), (520, 430), (300, 430), (300, 235))[1:] return unary_union([stroke(hook, 104, rounded=True), circle(300, 48, 48)]) if char == ".": return circle(300, 48, 48) if char == ",": return stroke(line((325, 85), (250, -105)), 72) if char == ":": return unary_union([circle(300, 555, 44), circle(300, 185, 44)]) if char == ";": return unary_union([circle(300, 555, 44), stroke(line((325, 220), (250, 20)), 70)]) if char == "(": return stroke(arc(360, 400, 210, 430, 110, 250), 76, rounded=True) if char == ")": return stroke(arc(240, 400, 210, 430, -70, 70), 76, rounded=True) if char == "[": return unary_union([box(170, -80, 270, 880), box(170, 804, 440, 880), box(170, -80, 440, -4)]) if char == "]": return unary_union([box(330, -80, 430, 880), box(160, 804, 430, 880), box(160, -80, 430, -4)]) if char == "'": return box(255, 570, 345, CAP) if char == '"': return unary_union([box(195, 570, 275, CAP), box(325, 570, 405, CAP)]) if char == "&": upper = ellipse(255, 610, 170, 190).difference(circle(255, 610, 80)) upper = upper.difference(box(315, 420, 610, 830)) lower = ellipse(280, 230, 225, 240).difference(circle(280, 230, 104)) diagonal = stroke(line((155, 525), (545, -25)), 86) exit_bar = box(330, 190, 560, 270) return unary_union([upper, lower, diagonal, exit_bar]).buffer(0) if char == "@": outer = ellipse(300, 400, 275, 400).difference(ellipse(300, 400, 190, 310)) outer = outer.difference(box(430, 105, 620, 330)) inner = ellipse(275, 405, 112, 155).difference(circle(275, 405, 58)) arm = unary_union([box(342, 360, 530, 430), box(448, 275, 530, 430)]) return unary_union([outer, inner, arm]).buffer(0) raise KeyError(char) DRAWN = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-+=/_|!?.,:;()[]'\"&@" def normalize_vertical(geometry, char: str): if char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789": return geometry target_min, target_max = (-10, 810) if char in "BCDGJOPQRSU0235689" else (0, CAP) _, min_y, _, max_y = geometry.bounds factor = (target_max - target_min) / (max_y - min_y) return translate(scale(geometry, yfact=factor, origin=(0, 0)), yoff=target_min - min_y * factor) def fit_geometry(geometry, char: str): min_x, _, max_x, _ = geometry.bounds narrow = set("!.,:;'\"()[]|I") diagonals = set("ATVWXYZ147") rounds = set("BCDGJOPQRSU0235689@&") if char in ".,:;": left = right = 24 elif char in narrow: left = right = 58 elif char in diagonals: left = right = 38 elif char in rounds: left = right = 46 else: left = right = 44 shifted = translate(geometry, xoff=left - min_x) return shifted, round(max_x - min_x + left + right), left def geometry_to_glyph(geometry): pen = TTGlyphPen(None) if isinstance(geometry, Polygon): polygons = [geometry] else: polygons = [part for part in getattr(geometry, "geoms", []) if isinstance(part, Polygon)] for polygon in polygons: polygon = orient(polygon, sign=-1.0) for contour in [polygon.exterior, *polygon.interiors]: coordinates = list(contour.coords)[:-1] if len(coordinates) < 3: continue pen.moveTo(tuple(round(value) for value in coordinates[0])) for point in coordinates[1:]: pen.lineTo(tuple(round(value) for value in point)) pen.closePath() return pen.glyph() def empty_glyph(): return TTGlyphPen(None).glyph() def build(): OUT_DIR.mkdir(parents=True, exist_ok=True) glyph_order = [".notdef", "space"] cmap = {32: "space", 0x00A0: "space"} glyphs = {".notdef": empty_glyph(), "space": empty_glyph()} metrics = {".notdef": (540, 0), "space": (250, 0)} for char in DRAWN: name = f"uni{ord(char):04X}" geometry = normalize_vertical(raw_geometry(char).buffer(0), char) geometry, advance, side = fit_geometry(geometry, char) glyph_order.append(name) glyphs[name] = geometry_to_glyph(geometry) metrics[name] = (advance, side) cmap[ord(char)] = name for char in "abcdefghijklmnopqrstuvwxyz": cmap[ord(char)] = f"uni{ord(char.upper()):04X}" cmap[0x2018] = "uni0027" cmap[0x2019] = "uni0027" cmap[0x201C] = "uni0022" cmap[0x201D] = "uni0022" builder = FontBuilder(UPM, isTTF=True) builder.setupGlyphOrder(glyph_order) builder.setupCharacterMap(cmap) builder.setupGlyf(glyphs) builder.setupHorizontalMetrics(metrics) builder.setupHorizontalHeader(ascent=ASCENDER, descent=DESCENDER) builder.setupNameTable( { "familyName": "Processional", "styleName": "Regular", "uniqueFontIdentifier": "Processional Regular 1.000; Cameron and Co", "fullName": "Processional Regular", "psName": "Processional-Regular", "version": "Version 1.000", "designer": "Co, with Cameron", "description": "A monumental unicase display face built from pylons, slabs, and circular apertures.", "licenseDescription": "Copyright 2026 Cameron. All rights reserved.", } ) builder.setupOS2( sTypoAscender=ASCENDER, sTypoDescender=DESCENDER, usWinAscent=ASCENDER, usWinDescent=abs(DESCENDER), sxHeight=CAP, sCapHeight=CAP, usWeightClass=700, usWidthClass=3, fsSelection=0x40, ) builder.setupPost(underlinePosition=-120, underlineThickness=64) builder.setupMaxp() ttf_path = OUT_DIR / "processional-regular.ttf" woff2_path = OUT_DIR / "processional-regular.woff2" builder.save(ttf_path) font = TTFont(ttf_path) addOpenTypeFeaturesFromString( font, """ feature kern { pos uni0041 uni0054 -28; pos uni0041 uni0056 -34; pos uni0041 uni0057 -24; pos uni0041 uni0059 -34; pos uni0046 uni0041 -26; pos uni004C uni0054 -24; pos uni0050 uni0041 -30; pos uni0054 uni0041 -28; pos uni0054 uni004F -16; pos uni0056 uni0041 -34; pos uni0057 uni0041 -24; pos uni0059 uni0041 -34; pos uni0059 uni004F -18; } kern; """, ) font.save(ttf_path) font.flavor = "woff2" font.save(woff2_path) print(f"Wrote {ttf_path.relative_to(Path.cwd())}") print(f"Wrote {woff2_path.relative_to(Path.cwd())}") if __name__ == "__main__": build()