#!/usr/bin/env python3 """Generate every LLeMbas SVG asset from one source of truth. Why a generator rather than five hand-written files: the leaf mark appears in the icon, the favicon, the lockup and the banner. Keeping the geometry in one place is the only way those stay identical as the mark is tuned. Why the wordmark is outlines and not : a README banner on GitHub or Gitea cannot load a webfont, so would render in whatever serif the viewer happens to have. Outlines look the same everywhere. Letterforms come from Source Serif 4 (Adobe, SIL OFL 1.1); only the handful of glyphs actually used are extracted, as a static drawing -- no font binary is redistributed. This is a design-time tool. The application never imports it, and the generated files are committed. Re-run it only when the artwork itself changes: pip install fonttools cairosvg python scripts/build_artwork.py cairosvg is needed only for the PWA icons, which have to be PNG: an installed web app's icon is drawn by the operating system's launcher, and neither Android's adaptive-icon masking nor iOS's home screen will take an SVG. The rasterisation happens here, once, and the PNGs are committed like everything else -- the running application still has no build step and no rasteriser. """ from __future__ import annotations import argparse import random import sys from pathlib import Path try: from fontTools.pens.boundsPen import BoundsPen from fontTools.pens.svgPathPen import SVGPathPen from fontTools.pens.transformPen import TransformPen from fontTools.ttLib import TTFont except ImportError: # pragma: no cover - design-time tool sys.exit("fontTools is required for this script: pip install fonttools") ROOT = Path(__file__).resolve().parent.parent ASSETS = ROOT / "assets" STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img" # assets/ holds the design masters; the application serves its own copies from # static/. These are the few the running app actually needs. SERVED_BY_APP = ( "favicon.svg", "logo-mark.svg", "banner.svg", "icon-192.png", "icon-512.png", "icon-maskable-512.png", "apple-touch-icon-180.png", ) FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf") FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf") WORDMARK = "LLeMbas" TAGLINE = "Waybread for the long road of thought" # The capitals of LLeMbas spell LLM. Those three glyphs carry the accent colour. ACCENT_GLYPHS = frozenset({0, 1, 3}) # --- Palette ----------------------------------------------------------------- GOLD_LIGHT = "#EACB74" GOLD = "#C9A227" GOLD_DARK = "#916F13" GOLD_SCORE = "#7A5C10" GOLD_HILIGHT = "#F6E3A8" RUNE_GOLD = "#E0B252" LEAF_EDGE = "#93A5B6" LEAF_LIGHT = "#F1F6FA" LEAF_MID = "#B8C7D5" LEAF_VEIN = "#61758A" LEAF_STEM = "#8A9AA8" NIGHT_TOP = "#080B0F" NIGHT_MID = "#101822" NIGHT_LOW = "#1A2530" PARCHMENT = "#EDE6D6" INK = "#1B1F23" MUTED = "#9AA7B4" # --- The mallorn leaf -------------------------------------------------------- # Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette # still reads as a leaf at 16px, where veins and score lines disappear. LEAF_BLADE = ( "M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" ) LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5" LEAF_STEM_PATH = "M21.2 44.8 L17 49.4" LEAF_VEINS = [ "M26.9 38.8 Q25.2 35.8 24.9 32.1", "M32.3 33.1 Q30.9 30.3 30.4 26.9", "M37.8 27.0 Q36.6 24.6 36.3 21.7", "M26.9 38.8 Q30.5 40.1 33.7 40.3", "M32.3 33.1 Q35.8 34.3 38.6 34.5", "M37.8 27.0 Q40.8 27.9 43.2 28.1", ] HEADER = ' str: """Render the glyphs, offset so the run's left edge sits at x=0. Class names are caller-supplied because , no page CSS reaches the document, so the media query is the only thing keeping the wordmark legible on a dark background. """ return f"""{indent}""" # --- The mark ---------------------------------------------------------------- def mark_defs(prefix: str) -> str: return f""" """ def mark_body(prefix: str, *, detail: bool = True) -> str: """The wafer-and-leaf mark in a 64x64 box. detail=False drops the score lines, rim and veins for small-size use. """ parts = [f' '] if detail: parts.append(f""" """) parts.append(f""" """) if detail: veins = "\n".join(f' ' for v in LEAF_VEINS) parts.append(f""" {veins} """) parts.append(" ") return "\n".join(parts) # --- Asset builders ---------------------------------------------------------- def build_logo_mark() -> str: return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="LLeMbas"> LLeMbas A silver mallorn leaf laid across a scored golden lembas wafer. {mark_defs("m")} {mark_body("m")} """ def build_favicon() -> str: """Small-size variant: no score lines or veins, larger blade, tighter tile.""" return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="LLeMbas"> LLeMbas {mark_defs("f")} """ def build_wordmark() -> str: """Standalone type. Inherits colour so it can sit on any background.""" run = TextRun(FONT_SEMIBOLD, WORDMARK, 100) return f"""{HEADER} viewBox="0 0 {run.width:.2f} {run.height:.2f}" width="{run.width:.2f}" height="{run.height:.2f}" role="img" aria-label="LLeMbas"> LLeMbas {type_style(" ")} {run.paths(ACCENT_GLYPHS, indent=" ")} """ def build_lockup() -> str: """Horizontal mark + wordmark, for the application header.""" cap = 46.0 run = TextRun(FONT_SEMIBOLD, WORDMARK, cap) mark_size = 64.0 gap = 20.0 pad = 4.0 height = mark_size + pad * 2 text_x = pad + mark_size + gap # Optically centre on the cap height rather than the full glyph bounds, so # the ascender of "b" and the overshoot of "e" do not shift the baseline. baseline_y = height / 2 + cap / 2 width = text_x + run.width + pad return f"""{HEADER} viewBox="0 0 {width:.2f} {height:.2f}" width="{width:.2f}" height="{height:.2f}" role="img" aria-label="LLeMbas"> LLeMbas {mark_defs("l")} {type_style(" ")} {mark_body("l")} {run.paths(ACCENT_GLYPHS, indent=" ")} """ # --- PWA icons --------------------------------------------------------------- # Same geometry as everything else, rasterised because a launcher icon has to # be a bitmap. Two shapes are needed, not one: # # "any" -- drawn as supplied, so the wafer's own rounded square is the # silhouette and the corners stay transparent. # "maskable" -- Android crops it to a circle, squircle or rounded square of # the launcher's choosing, so the art must be full-bleed and # the mark must sit inside the central safe zone. An "any" # icon used as maskable gets its corners sliced off. # # The Apple icon is opaque for a different reason: iOS composites a home screen # icon onto black, so transparency reads as a black tile rather than as the # wallpaper showing through. def _framed_mark(prefix: str, *, background: str | None = None, inset: float = 0.0) -> str: """The mark on a 64x64 canvas, optionally opaque and inset from the edges.""" size = 64.0 offset = size * inset scale = 1.0 - inset * 2 plate = f' \n' return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="LLeMbas"> {mark_defs(prefix)} {plate if background else ""} {mark_body(prefix)} """ def _rasterise(svg: str, size: int) -> bytes: try: import cairosvg except ImportError: # pragma: no cover - design-time tool sys.exit("cairosvg is required for the PWA icons: pip install cairosvg") return cairosvg.svg2png( bytestring=svg.encode("utf-8"), output_width=size, output_height=size ) def build_icon_192() -> bytes: return _rasterise(_framed_mark("i192"), 192) def build_icon_512() -> bytes: return _rasterise(_framed_mark("i512"), 512) def build_icon_maskable() -> bytes: # 20% inset leaves the mark inside the central 60%, comfortably within the # 80% safe circle every launcher mask respects. return _rasterise(_framed_mark("imask", background=NIGHT_MID, inset=0.20), 512) def build_apple_touch_icon() -> bytes: # iOS rounds the corners itself, so only a hairline of padding is wanted. return _rasterise(_framed_mark("iios", background=NIGHT_MID, inset=0.06), 180) def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str: """One jagged ridge line spanning the full width.""" rng = random.Random(seed) points = [(0.0, base_y)] x = 0.0 while x < width: step = rng.uniform(width * 0.045, width * 0.11) x = min(x + step, width) peak = base_y - rng.uniform(height * 0.35, height) points.append((x, peak)) # A short shoulder after each peak keeps the ridge from looking like a saw. if x < width: x = min(x + rng.uniform(width * 0.01, width * 0.03), width) points.append((x, peak + rng.uniform(height * 0.08, height * 0.25))) points.append((width, base_y)) coords = " ".join(f"{px:.1f},{py:.1f}" for px, py in points) return f' ' def _stars(width: float, height: float, count: int, seed: int) -> str: rng = random.Random(seed) out = [] for _ in range(count): sx = rng.uniform(0, width) sy = rng.uniform(0, height) r = rng.uniform(0.6, 1.9) opacity = rng.uniform(0.18, 0.85) out.append( f' ' ) return "\n".join(out) def _drifting_leaves(seed: int) -> str: """A few mallorn leaves adrift in the sky, well behind the type.""" rng = random.Random(seed) placements = [ (120, 90, 0.42, -18), (250, 250, 0.30, 24), (1035, 95, 0.36, 12), (1160, 215, 0.46, -32), (905, 300, 0.26, 40), (185, 300, 0.24, -8), ] out = [] for cx, cy, scale, rot in placements: opacity = rng.uniform(0.10, 0.19) out.append( f' ' f'' ) return "\n".join(out) def build_banner() -> str: """README hero. Carries its own dark background rather than relying on the page, because a README is rendered on a light background as often as a dark one. """ width, height = 1280.0, 420.0 cap = 92.0 run = TextRun(FONT_SEMIBOLD, WORDMARK, cap) tag = TextRun(FONT_ITALIC, TAGLINE, 26.0) mark_size = 136.0 gap = 34.0 lockup_w = mark_size + gap + run.width lockup_x = (width - lockup_w) / 2 baseline_y = 232.0 mark_y = baseline_y - cap / 2 - mark_size / 2 tag_x = (width - tag.width) / 2 - tag.x0 tag_y = baseline_y + 68.0 mark_scale = mark_size / 64.0 return f"""{HEADER} viewBox="0 0 {width:.0f} {height:.0f}" width="{width:.0f}" height="{height:.0f}" role="img" aria-label="LLeMbas - {TAGLINE}"> LLeMbas {TAGLINE}. A mallorn leaf and wafer above the mountains at night. {mark_defs("b").removeprefix(" ").removesuffix(" ").rstrip()} {_stars(width, 300, 130, 11)} {_drifting_leaves(5)} {_mountains(width, 366, 3, 150, "#1C2836")} {_mountains(width, 392, 8, 112, "#111A25")} {_mountains(width, 416, 21, 74, "#080D13")} {mark_body("b")} {run.paths(ACCENT_GLYPHS, indent=" ")} {tag.paths(indent=" ", base_class="tag")} """ # --- Entry point ------------------------------------------------------------- BUILDERS = { "logo-mark.svg": build_logo_mark, "favicon.svg": build_favicon, "wordmark.svg": build_wordmark, "logo-lockup.svg": build_lockup, "banner.svg": build_banner, "icon-192.png": build_icon_192, "icon-512.png": build_icon_512, "icon-maskable-512.png": build_icon_maskable, "apple-touch-icon-180.png": build_apple_touch_icon, } def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", type=Path, default=ASSETS) parser.add_argument("--only", nargs="*", choices=sorted(BUILDERS), default=None) args = parser.parse_args() args.out.mkdir(parents=True, exist_ok=True) STATIC_IMG.mkdir(parents=True, exist_ok=True) for filename in args.only or BUILDERS: content = BUILDERS[filename]() # The PNG builders return bytes; everything else returns SVG source. data = content if isinstance(content, bytes) else content.encode("utf-8") path = args.out / filename path.write_bytes(data) print(f"wrote {path.relative_to(ROOT)} ({len(data):,} bytes)") if filename in SERVED_BY_APP: served = STATIC_IMG / filename served.write_bytes(data) print(f" -> {served.relative_to(ROOT)}") if __name__ == "__main__": main()