352 lines
14 KiB
Python
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()
|