Files
PageScribe/brand/generate_icons.py
2026-10-18 19:01:01 -07:00

352 lines
14 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Generate the PageScribe icon set in the UmbraTools brand style.
Brand rules taken from the UmbraTools UX Brand Document:
* midnight blue #0A0F1F ground, electric cyan #00BFFF accents,
deep teal #007A8C gradients, bronze #A67C52 for the book,
ice blue #CFE9FF for text
* core motif: an interwoven knot/weave
* composition: a central emblem with a contextual element (here the book,
plus the ruled lines a scribe reads)
* rounded corners, symmetrical geometry, soft glow, gradients into darkness
* legible from 1024px down to launcher sizes
Every gradient is **purely vertical** so the mirrored left and right halves of
the book and the weave come out identical.
The art is authored as SVG (real gradients, gaussian bloom) and rasterised with
cairosvg; the wordmark is drawn with PIL so it always uses Montserrat.
Requirements
------------
pip install cairosvg pillow
Montserrat SemiBold + Light TTFs (SIL Open Font License), found via
$FONT_DIR, ./fonts, or a system install.
Regenerate everything with:
python3 brand/generate_icons.py
"""
from __future__ import annotations
import math
import os
import sys
from pathlib import Path
try:
import cairosvg
except ImportError: # pragma: no cover - developer convenience
sys.exit("cairosvg is required: pip install cairosvg")
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter
# ---------------------------------------------------------------- brand ----
BG = "#0A0F1F"
CYAN = "#00BFFF"
TEAL = "#007A8C"
BRONZE = "#A67C52"
ICE = "#CFE9FF"
HERE = Path(__file__).resolve().parent
def find_font(name: str) -> Path:
"""Locate a Montserrat TTF: explicit env, vendored, or system install."""
candidates = [HERE / "fonts" / name, Path("/tmp/opencode/fonts") / name]
env = os.environ.get("FONT_DIR")
if env:
candidates.insert(0, Path(env) / name)
for c in candidates:
if c.is_file():
return c
for root in ("/usr/share/fonts", "/usr/local/share/fonts", Path.home() / ".fonts"):
for hit in Path(root).rglob(name):
return hit
sys.exit(f"cannot find {name}; set FONT_DIR to the folder holding Montserrat TTFs")
FONT_SEMIBOLD = find_font("Montserrat-SemiBold.ttf")
FONT_LIGHT = find_font("Montserrat-Light.ttf")
D = 1024.0 # design space is always 1024x1024
# ------------------------------------------------------------- geometry ----
RING_R = 340.0 # ring centreline radius
RING_W = 17.0 # ring stroke width
RING_GAP = 22.0 # degrees cut away at each diagonal (reticle)
BRAID_AMP = 76.0 # weave amplitude
BRAID_W = 64.0 # weave stroke width
BRAID_CORE = 20.0 # bright filament inside the weave
BRAID_Y0 = -318.0 # tips land on the ring's top/bottom, inside the
BRAID_Y1 = 318.0 # adaptive safe circle even with the round caps
BRAID_PERIOD = 318.0 # three crossings, symmetric about the centre
SAFE_DP = 64.0 # keep the mark inside the 66dp adaptive safe circle
CANVAS_DP = 108.0
def ring_scale(ring_px: float) -> float:
return (ring_px / 2.0) / RING_R
# --------------------------------------------------------------- shapes ----
def leaf(sign: int) -> str:
"""One page of the open book; sign = -1 (left) or +1 (right)."""
s = sign
return (
f"M 0,-150 "
f"C {s * 60},-181 {s * 162},-183 {s * 233},-159 "
f"L {s * 241},-153 C {s * 249},-148 {s * 252},-140 {s * 252},-131 "
f"L {s * 252},110 C {s * 252},120 {s * 246},130 {s * 235},133 "
f"C {s * 166},153 {s * 62},151 0,116 Z"
)
# (y, inner x, outer x) for the ruled lines on each page.
RULES = [(-96, 122, 226), (-34, 122, 226), (28, 122, 226), (86, 122, 182)]
def braid_points(sign: int, n: int = 360) -> list[tuple[float, float]]:
pts = []
for i in range(n + 1):
y = BRAID_Y0 + (BRAID_Y1 - BRAID_Y0) * i / n
x = sign * BRAID_AMP * math.sin(2 * math.pi * (y - BRAID_Y0) / BRAID_PERIOD)
pts.append((x, y))
return pts
def polyline(pts: list[tuple[float, float]]) -> str:
return (f"M {pts[0][0]:.2f},{pts[0][1]:.2f}"
+ "".join(f" L {x:.2f},{y:.2f}" for x, y in pts[1:]))
def ring_arcs(r: float, gap_deg: float, n: int = 72) -> list[str]:
"""Four arcs of a circle, the cut-outs centred on the diagonals."""
arcs = []
for centre in (0.0, 90.0, 180.0, 270.0):
a0 = math.radians(centre - (90.0 - gap_deg) / 2.0)
a1 = math.radians(centre + (90.0 - gap_deg) / 2.0)
pts = [(r * math.cos(a0 + (a1 - a0) * i / n),
r * math.sin(a0 + (a1 - a0) * i / n)) for i in range(n + 1)]
arcs.append(polyline(pts))
return arcs
# ------------------------------------------------------------- emblem ------
def emblem_defs(mono: bool = False) -> str:
if mono:
return ""
return """
<linearGradient id="weave" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#7FE8FF"/>
<stop offset="34%" stop-color="#00BFFF"/>
<stop offset="100%" stop-color="#0089A6"/>
</linearGradient>
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#E2C79C"/>
<stop offset="28%" stop-color="#C49B6C"/>
<stop offset="66%" stop-color="#A67C52"/>
<stop offset="100%" stop-color="#7B5836"/>
</linearGradient>
<radialGradient id="inner" cx="50%" cy="46%" r="62%">
<stop offset="0%" stop-color="#00BFFF" stop-opacity="0.42"/>
<stop offset="55%" stop-color="#007A8C" stop-opacity="0.16"/>
<stop offset="100%" stop-color="#007A8C" stop-opacity="0"/>
</radialGradient>
<filter id="bloom" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="24"/>
</filter>
<filter id="bloom2" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="9"/>
</filter>
<clipPath id="braidband"><rect x="-260" y="-44" width="520" height="88"/></clipPath>
"""
def braid_pass(stroke: str, width: float, extra: str = "") -> str:
"""Both strands with the middle crossing on top, so the weave reads over/under."""
a = polyline(braid_points(1))
b = polyline(braid_points(-1))
common = f'fill="none" stroke="{stroke}" stroke-width="{width}" stroke-linecap="round" {extra}'
return (f'<path d="{b}" {common}/>'
f'<path d="{a}" {common}/>'
f'<g clip-path="url(#braidband)"><path d="{b}" {common}/></g>')
def emblem(cx: float, cy: float, ring_px: float) -> str:
"""The mark: reticle, open book, and the weave running through its gutter."""
s = ring_scale(ring_px)
arcs = "".join(f'<path d="{d}"/>' for d in ring_arcs(RING_R, RING_GAP))
rules = []
for y, xi, xo in RULES:
for sign in (-1, 1):
x = sign * xo if sign < 0 else xi
rules.append(f'<rect x="{x:.1f}" y="{y - 9:.1f}" width="{xo - xi}" '
f'height="18" rx="9" fill="{ICE}" opacity="0.82"/>')
return f"""<g transform="translate({cx:.1f} {cy:.1f}) scale({s:.5f})">
<circle cx="0" cy="0" r="{RING_R:.0f}" fill="url(#inner)"/>
<!-- bloom pass: the luminous shapes, blurred beneath everything -->
<g filter="url(#bloom)" opacity="0.8">
<g fill="none" stroke="#00BFFF" stroke-width="{RING_W}" stroke-linecap="round">{arcs}</g>
{braid_pass('#00BFFF', BRAID_W)}
<path d="{leaf(-1)}" fill="#007A8C"/>
<path d="{leaf(1)}" fill="#007A8C"/>
</g>
<!-- the book: warm bronze, lit from above -->
<path d="{leaf(-1)}" fill="url(#paper)"/>
<path d="{leaf(1)}" fill="url(#paper)"/>
<g fill="none" stroke="url(#weave)" stroke-width="5" opacity="0.4">
<path d="{leaf(-1)}"/><path d="{leaf(1)}"/>
</g>
<g>{''.join(rules)}</g>
<!-- the weave: cyan ribbon with a bright filament core -->
<g filter="url(#bloom2)" opacity="0.55">{braid_pass('#00BFFF', BRAID_W)}</g>
{braid_pass('url(#weave)', BRAID_W)}
{braid_pass('#EAFDFF', BRAID_CORE, 'opacity="0.9"')}
<!-- the reticle sits on top, so the weave passes behind it -->
<g fill="none" stroke="url(#weave)" stroke-width="{RING_W}" stroke-linecap="round">{arcs}</g>
</g>"""
def background(rounded: float = 0.0, halo: bool = True) -> str:
r = f' rx="{rounded:.0f}"' if rounded else ""
glow = ""
if halo:
glow = (
'<defs>'
'<radialGradient id="halo" cx="50%" cy="44%" r="66%">'
'<stop offset="0%" stop-color="#007A8C" stop-opacity="0.55"/>'
'<stop offset="42%" stop-color="#007A8C" stop-opacity="0.20"/>'
'<stop offset="100%" stop-color="#0A0F1F" stop-opacity="0"/></radialGradient>'
'<radialGradient id="vign" cx="50%" cy="50%" r="74%">'
'<stop offset="52%" stop-color="#05070F" stop-opacity="0"/>'
'<stop offset="100%" stop-color="#05070F" stop-opacity="0.92"/></radialGradient>'
'</defs>'
f'<rect x="0" y="0" width="{D}" height="{D}"{r} fill="url(#halo)"/>'
f'<rect x="0" y="0" width="{D}" height="{D}"{r} fill="url(#vign)"/>'
)
return f'<rect x="0" y="0" width="{D}" height="{D}"{r} fill="{BG}"/>{glow}'
def svg(body: str, mono: bool = False) -> str:
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {D:.0f} {D:.0f}" '
f'width="{D:.0f}" height="{D:.0f}"><defs>{emblem_defs(mono)}</defs>{body}</svg>')
def rasterise(markup: str, px: int, supersample: int = 4) -> Image.Image:
import io
big = px * supersample
png = cairosvg.svg2png(bytestring=markup.encode(), output_width=big, output_height=big)
return Image.open(io.BytesIO(png)).convert("RGBA").resize((px, px), Image.LANCZOS)
# --------------------------------------------------------------- compose ----
def fit_font(path: Path, text: str, target_w: float) -> ImageFont.FreeTypeFont:
"""Largest size at which `text` measures `target_w`."""
probe = ImageFont.truetype(str(path), 100)
box = probe.getbbox(text)
return ImageFont.truetype(str(path), int(100 * target_w / (box[2] - box[0])))
def draw_wordmark(img: Image.Image, scale: float) -> Image.Image:
"""'PageScribe' in Montserrat SemiBold, 'UmbraTools.com' in Light below."""
draw = ImageDraw.Draw(img)
mid = img.width / 2
title = fit_font(FONT_SEMIBOLD, "PageScribe", 660 * scale)
sub = fit_font(FONT_LIGHT, "UmbraTools.com", 330 * scale)
glow = Image.new("RGBA", img.size, (0, 0, 0, 0))
ImageDraw.Draw(glow).text((mid, 872 * scale), "PageScribe", font=title,
fill=(0, 191, 255, 150), anchor="ms")
img.alpha_composite(glow.filter(ImageFilter.GaussianBlur(13 * scale)))
draw.text((mid, 872 * scale), "PageScribe", font=title, fill=ICE, anchor="ms")
draw.text((mid, 948 * scale), "UmbraTools.com", font=sub,
fill=(0, 191, 255, 205), anchor="ms")
return img
def icon_png(px: int, rounded_frac: float = 0.0, ring_px: float = 690.0,
wordmark: bool = False) -> Image.Image:
radius = rounded_frac * D
body = background(radius)
body += emblem(D / 2, 420 if wordmark else D / 2, ring_px)
img = rasterise(svg(body), px)
if wordmark and px >= 512:
img = draw_wordmark(img, scale=px / 1024.0)
return img
def adaptive_layer(px: int, layer: str) -> Image.Image:
ring = SAFE_DP / CANVAS_DP * D
if layer == "background":
return rasterise(svg(background()), px)
if layer == "monochrome":
return monochrome_layer(px)
return rasterise(svg(emblem(D / 2, D / 2, ring)), px)
def monochrome_layer(px: int) -> Image.Image:
"""Single-colour themed icon: the mark with the weave cut out as a groove.
cairosvg's handling of SVG <mask> is unreliable, so the groove is cut with
PIL alpha arithmetic instead: draw the solid shapes, draw the braid, and
subtract the braid's coverage.
"""
s = ring_scale(SAFE_DP / CANVAS_DP * D)
arcs = "".join(f'<path d="{d}"/>' for d in ring_arcs(RING_R, RING_GAP))
wrap = (f'<g transform="translate({D / 2:.1f} {D / 2:.1f}) scale({s:.5f})">{{}}</g>')
shapes = wrap.format(
f'<g fill="#FFFFFF"><path d="{leaf(-1)}"/><path d="{leaf(1)}"/></g>'
f'<g fill="none" stroke="#FFFFFF" stroke-width="{RING_W}" '
f'stroke-linecap="round">{arcs}</g>'
)
braid = "".join(
f'<path d="{polyline(braid_points(sign))}" fill="none" stroke="#FFFFFF" '
f'stroke-width="{BRAID_W}" stroke-linecap="round"/>' for sign in (1, -1)
)
shape_img = rasterise(svg(shapes), px)
groove_img = rasterise(svg(wrap.format(braid)), px)
r, g, b, alpha = shape_img.split()
alpha = ImageChops.subtract(alpha, groove_img.split()[3])
return Image.merge("RGBA", (r, g, b, alpha))
# ------------------------------------------------------------------ main ----
RES = HERE.parent / "app/src/main/res"
BRANDBOOK = HERE
DENSITIES = {"mdpi": 1.0, "hdpi": 1.5, "xhdpi": 2.0, "xxhdpi": 3.0, "xxxhdpi": 4.0}
def main() -> None:
BRANDBOOK.mkdir(parents=True, exist_ok=True)
icon_png(1024, ring_px=580, wordmark=True).save(BRANDBOOK / "pagescribe-icon-1024.png")
icon_png(512, ring_px=580, wordmark=True).save(BRANDBOOK / "pagescribe-playstore-512.png")
icon_png(1024, rounded_frac=0.229, ring_px=690).save(BRANDBOOK / "pagescribe-mark-1024.png")
for name, frac in (("ic_launcher", 0.229), ("ic_launcher_round", 0.5)):
for dens, f in DENSITIES.items():
out = RES / f"mipmap-{dens}"
out.mkdir(parents=True, exist_ok=True)
icon_png(int(round(48 * f)), rounded_frac=frac, ring_px=820).save(out / f"{name}.png")
for dens, f in DENSITIES.items():
out = RES / f"mipmap-{dens}"
for layer in ("background", "foreground", "monochrome"):
adaptive_layer(int(round(108 * f)), layer).save(out / f"ic_launcher_{layer}.png")
print("wrote icon assets")
if __name__ == "__main__":
main()