diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 565ce21b..d2c239c0 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index f656fe84..47a95d43 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index cc53b560..03b80b12 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index fc58b108..0b623401 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index d13d1dde..44bbce3d 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index c0e47545..98d96710 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index a1c33daa..a0578767 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index a0ce97e3..f2c3a546 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index ad01d424..616ef5fd 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 9a46f34a..65453186 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/docs/brand/minstrel-logo-source.png b/docs/brand/minstrel-logo-source.png new file mode 100644 index 00000000..4ae840fb Binary files /dev/null and b/docs/brand/minstrel-logo-source.png differ diff --git a/tools/gen-brand-assets.py b/tools/gen-brand-assets.py new file mode 100644 index 00000000..2c20b0a0 --- /dev/null +++ b/tools/gen-brand-assets.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Trace the Minstrel logo and emit every brand asset that derives from it. + +Run by hand after the source artwork changes, never in CI — it needs potrace +and Pillow, which the build images don't carry, and its outputs are committed +(the app must render itself with no outbound network). + + python3 tools/gen-brand-assets.py + +Why a generator at all: the mark ships in four places that cannot share one +file, because each needs a different colour mechanism — currentColor in the +component, literal fills in mark.svg, a prefers-color-scheme swap in +favicon.svg, and flattened pixels in the rasters. Hand-copying ~20KB of path +data four ways is how a silhouette change lands in three of them and not the +fourth. The paths have one source here instead. + +The trace pipeline, and why each step is what it is: + + * Components are separated first (connected-component labelling) so the hat + and the note arc can take different fills. Grouping is by component id + against the source raster, which is stable as long as SOURCE doesn't move. + * Tracing runs on the ORIGINAL antialiased greyscale, upsampled and blurred + — not on the binary mask. Tracing the mask supersampled hits 100% IoU by + reproducing the pixel staircase exactly: a perfect score for jagged art + and a 120KB path. Smoothing first finds a sub-pixel boundary instead, + which is 99.74% IoU at 20KB. + * potrace reads PBM, where bit 1 is BLACK, so the ink mask is inverted on + the way in. Getting this backwards traces the background and still + produces a plausible-looking SVG. +""" +import re, shutil, subprocess, sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT / "docs/brand/minstrel-logo-source.png" + +PARCHMENT = "#E8E4D8" +ACCENT = "#4A6B5C" # --fs-accent; the one row Minstrel overrides +OBSIDIAN = "#14171A" # plate for rasters — see the contrast note in README + +# Component ids from the source raster: the hat body, brim, feather, band and +# jewel take the text colour; the noteheads and the arc swashes take the accent. +HAT = [396, 58, 251, 497, 442] +NOTES = [31, 207, 30, 247, 317, 135, 78, 161, 186, 107, 215] +FRAME = [1] # the source's rounded-rect border — deliberately NOT traced: an + # adaptive icon gets masked to the launcher's own shape, so a + # baked border would be clipped raggedly, and at header size it + # is noise around a mark that is already busy. + +SUPERSAMPLE, BLUR = 4, 1.5 +POTRACE = ["-s", "--flat", "-a", "1.334", "-O", "0.2", "-u", "4", "-t", "20"] + + +def components(gray): + """Label 8-connected runs of ink. Two-pass union-find; no scipy here.""" + ink = gray > 128 + H, W = ink.shape + parent, labels, nxt = {}, np.zeros((H, W), np.int32), 1 + + def find(x): + r = x + while parent[r] != r: + r = parent[r] + while parent[x] != r: + parent[x], x = r, parent[x] + return r + + for y in range(H): + if not ink[y].any(): + continue + prev = labels[y - 1] if y else None + for x in np.nonzero(ink[y])[0]: + nb = [labels[y, x - 1]] if x and labels[y, x - 1] else [] + if y: + nb += [prev[xx] for xx in (x - 1, x, x + 1) if 0 <= xx < W and prev[xx]] + if nb: + m = min(nb) + labels[y, x] = m + for n in nb: + rm, rn = find(m), find(n) + if rm != rn: + parent[max(rm, rn)] = min(rm, rn) + else: + parent[nxt] = nxt + labels[y, x] = nxt + nxt += 1 + flat = np.array([0] + [find(i) for i in range(1, nxt)], np.int32) + return flat[labels] + + +def trace(labels, gray, ids, tmp): + keep = np.isin(labels, ids) + # Dilate the group mask so the component's own antialiased fringe survives; + # thresholding a hard-cut mask would shave a half-pixel off every edge. + grown = Image.fromarray((keep * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(5)) + img = Image.fromarray(np.where(np.asarray(grown), gray, 0.0).astype(np.uint8)) + H, W = labels.shape + img = img.resize((W * SUPERSAMPLE, H * SUPERSAMPLE), Image.BICUBIC) + img = img.filter(ImageFilter.GaussianBlur(BLUR)) + img.point(lambda p: 0 if p > 128 else 255).convert("1", dither=Image.NONE).save(tmp / "g.pbm") + subprocess.run(["potrace", *POTRACE, str(tmp / "g.pbm"), "-o", str(tmp / "g.svg")], check=True) + s = (tmp / "g.svg").read_text() + return (" ".join(re.findall(r' width: + out.append(line) + line = tok + else: + line = f"{line} {tok}".strip() + out.append(line) + return ("\n" + indent).join(out) + + +def svg_doc(vb, tr, hat_fill, notes_fill, hat_d, notes_d, style="", head=""): + return (f'\n' + f'{head}{style}' + f' \n' + f' \n' + f' \n' + f' \n\n') + + +def rasterize(svg_path, out, px, plate=None, radius_frac=0.0, art_frac=1.0): + """Render the mark to a PNG, optionally on a rounded plate. + + Rasters carry their own background because a PNG cannot answer to a colour + scheme and iOS composites the touch icon onto white regardless. Obsidian + rather than the raised iron: the accent clears the 3:1 graphics floor + against obsidian (3.04:1) and fails against iron (2.80:1). Lightening the + plate makes this worse, not better, because the accent is a dark colour. + """ + art = round(px * art_frac) + subprocess.run(["rsvg-convert", "-w", str(art), "-o", str(out), str(svg_path)], check=True) + mark = Image.open(out).convert("RGBA") + canvas = Image.new("RGBA", (px, px), (0, 0, 0, 0)) + if plate: + r = round(px * radius_frac) + layer = Image.new("RGBA", (px, px), (0, 0, 0, 0)) + ImageDraw.Draw(layer).rounded_rectangle([0, 0, px - 1, px - 1], radius=r, fill=plate) + canvas.alpha_composite(layer) + canvas.alpha_composite(mark, ((px - mark.width) // 2, (px - mark.height) // 2)) + canvas.save(out) + + +def main(): + tmp = ROOT / ".brand-tmp" + tmp.mkdir(exist_ok=True) + if not SOURCE.exists(): + sys.exit(f"source artwork missing: {SOURCE}") + + a = np.asarray(Image.open(SOURCE).convert("RGB")).astype(np.float32) + gray = 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2] + labels = components(gray) + + hat_d, tr = trace(labels, gray, HAT, tmp) + notes_d, _ = trace(labels, gray, NOTES, tmp) + + # Tight viewBox around the traced art (frame excluded), in potrace's user + # space, which is source pixels x SUPERSAMPLE. + ys, xs = np.nonzero(np.isin(labels, HAT + NOTES)) + pad = 6 + x0, x1 = xs.min() - pad, xs.max() + pad + y0, y1 = ys.min() - pad, ys.max() + pad + S = SUPERSAMPLE + vb = f"{x0*S} {y0*S} {(x1-x0)*S} {(y1-y0)*S}" + w, h = (x1 - x0) * S, (y1 - y0) * S + + (ROOT / "web/static/brand/mark.svg").write_text(svg_doc( + vb, tr, PARCHMENT, ACCENT, hat_d, notes_d, + head=" \n")) + + (ROOT / "web/static/brand/favicon.svg").write_text(svg_doc( + vb, tr, "currentColor", ACCENT, hat_d, notes_d, + head=" \n", + style=" \n")) + + plated = ROOT / ".brand-tmp/plated.svg" + plated.write_text(svg_doc(vb, tr, PARCHMENT, ACCENT, hat_d, notes_d)) + + # Web rasters: plate radius and art scale measured off the icons these + # replace, so the new mark sits where the old one did. + for out, px in [("web/static/brand/icon-512.png", 512), + ("web/static/apple-touch-icon.png", 180), + ("web/static/favicon.png", 32)]: + rasterize(plated, ROOT / out, px, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88) + + # Adaptive icon foreground: transparent, art inside the 66/108dp safe zone + # so no launcher mask can clip it. The plate comes from the XML instead. + res = ROOT / "android/app/src/main/res" + for d, fg, legacy in [("mdpi", 108, 48), ("hdpi", 162, 72), ("xhdpi", 216, 96), + ("xxhdpi", 324, 144), ("xxxhdpi", 432, 192)]: + rasterize(plated, res / f"mipmap-{d}/ic_launcher_foreground.png", fg, art_frac=0.58) + rasterize(plated, res / f"mipmap-{d}/ic_launcher.png", legacy, + plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88) + + (ROOT / "web/src/lib/components/MinstrelMark.svelte").write_text( + COMPONENT.format(vb=vb, tr=tr, w=w, h=h, + hat=wrap(hat_d, indent=" " * 6), notes=wrap(notes_d, indent=" " * 6), + accent=ACCENT)) + + print(f"viewBox {vb} aspect {w/h:.4f} hat {len(hat_d)/1024:.1f}KB notes {len(notes_d)/1024:.1f}KB") + shutil.rmtree(tmp) + + +COMPONENT = ''' + + +''' + +if __name__ == "__main__": + main() diff --git a/web/src/app.html b/web/src/app.html index 91cf4ffa..6e7479b6 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -3,7 +3,7 @@ + + - - - + + + diff --git a/web/static/brand/icon-512.png b/web/static/brand/icon-512.png index a0935f4a..57609907 100644 Binary files a/web/static/brand/icon-512.png and b/web/static/brand/icon-512.png differ diff --git a/web/static/brand/mark.svg b/web/static/brand/mark.svg index fcd6845d..c5daac0e 100644 --- a/web/static/brand/mark.svg +++ b/web/static/brand/mark.svg @@ -1,27 +1,274 @@ - - - - + + + + + diff --git a/web/static/favicon.png b/web/static/favicon.png index ac8e7c56..fc98152a 100644 Binary files a/web/static/favicon.png and b/web/static/favicon.png differ