diff --git a/scripts/generate_icons.py b/scripts/generate_icons.py index 3c972af578..84644beaa6 100644 --- a/scripts/generate_icons.py +++ b/scripts/generate_icons.py @@ -18,8 +18,8 @@ Sources of truth — two axes, composed per target: assets/backgrounds/squircle-mac-dark.svg mac HIG grid The master SVGs (assets/icon-master.svg light, assets/icon-master-dark.svg - dark) are GENERATED artifacts — squircle background + girl nested into the - 824px HIG content safe zone. The light master drives every squircle target; + dark) are GENERATED artifacts — squircle background + scaled girl artwork. + The light master drives every squircle target; the dark master drives the dark-appearance targets. macOS is the exception: its icns targets render from an in-memory mac master that puts the same squircle on Apple's 824x824 (r=185.4) grid — centered in 1024 with 100px @@ -31,10 +31,10 @@ and a seven-character SHA badge. The girl and tile geometry do not change. Only apps/desktop outputs use this identity. Website, bootstrap, dashboard, and the shared master SVGs retain the default brand. -The girl is nested via its art bbox as viewBox, so it always lands centered in -the box (824 safe zone for squircles / height-fitted for the marks) without -distortion. The girl art corners sit ~185px from the squircle arc centers vs -the 245px radius, so no art touches the rounded corners on any OS mask. +The girl's position and uniform scale are registered to the reference artwork. +She renders in front of the border, clipped only to the outer rounded silhouette. +Only her bottom sliver extends to the border; the fitted face and hair stay fixed. +Standalone wordmarks remain centered and have no border. GENERATED OUTPUTS ARE NOT COMMITTED. Everything this script writes is gitignored and regenerated on demand by the consuming pipelines (website @@ -93,6 +93,7 @@ import io import os import re import sys +import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image @@ -108,17 +109,16 @@ except ImportError: # The nous dark background (#0d1117) — fixed dark tile/background everywhere. DARK_HEX = "#0d1117" DARK_RGB = (13, 17, 23) +BORDER_FRACTION = 0.0407747197 -# Girl placement per background: (x, y, w, h) in that background's coordinate -# space. Full-bleed squircles put the girl in the 824px HIG content safe zone -# (centered, 100px pad on a 1024 canvas); the mac-grid squircle is itself 824 -# on 1024, so the girl box scales by 824/1024 to keep the same relative size -# inside the shape. Marks reuse the full-bleed squircles. +# Portrait boxes fitted to the reference at equal visible tile width, with +# uniform scaling about the tile center followed by an up-left translation. +# Keep their y coordinate: bottom anchoring would undo the registration. GIRL_BOXES = { - "squircle-light.svg": (100, 100, 824, 824), - "squircle-dark.svg": (100, 100, 824, 824), - "squircle-mac-light.svg": (180.5, 180.5, 663, 663), - "squircle-mac-dark.svg": (180.5, 180.5, 663, 663), + "squircle-light.svg": (72.149433, 104.703674, 872.767801, 872.767801), + "squircle-dark.svg": (72.149433, 104.703674, 872.767801, 872.767801), + "squircle-mac-light.svg": (157.949166, 184.039504, 702.522501, 702.522501), + "squircle-mac-dark.svg": (157.949166, 184.039504, 702.522501, 702.522501), } # The brand-kit SVG canvas (both girl svgs share this viewBox). GIRL_VIEWBOX = 5487.0615 @@ -235,13 +235,17 @@ def girl_bbox(art: IconArt, girl: str) -> tuple[float, float, float, float]: return art.bboxes[girl] -def girl_layer(art: IconArt, girl: str, box: tuple[float, float, float, float]) -> str: +def girl_layer( + art: IconArt, girl: str, box: tuple[float, float, float, float], + *, align: str = "xMidYMid", +) -> str: """Nested-svg layer: girl art (bbox as viewBox) placed into `box` — the box's aspect is preserved via 'meet', so the girl never distorts.""" bx, by, bw, bh = girl_bbox(art, girl) x, y, w, h = box return ( - f'\n' + f'\n' f" {girl_path(art, girl)}\n" " " ) @@ -253,11 +257,13 @@ def background_inner(art: IconArt, name: str) -> tuple[str, int, int]: if art.colors: text = text.replace('fill="#ffffff"', f'fill="{art.colors[0]}"') text = text.replace(f'fill="{DARK_HEX}"', f'fill="{art.colors[1]}"') - m = re.search(r']*viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"[^>]*>', text) + root = ET.fromstring(text) + m = re.fullmatch(r"0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)", root.get("viewBox", "")) assert m, f"cannot parse viewBox of {name}" w, h = float(m.group(1)), float(m.group(2)) - inner = re.sub(r"^.*?>\s*", "", text, count=1, flags=re.S) - inner = re.sub(r"\s*\s*$", "", inner, flags=re.S) + # Editor exports include XML declarations and root-scoped namespaces. + # Parse away the prolog and retain child namespaces when embedding. + inner = "".join(ET.tostring(child, encoding="unicode") for child in root) return inner, int(w), int(h) @@ -304,12 +310,54 @@ def compose_svg(art: IconArt, girl: str, bg: str) -> str: coordinate space (resvg scales to whatever output size is requested, so the composition is size-agnostic — no manual box scaling).""" inner, w, h = background_inner(art, bg) + background = ET.fromstring(f"{inner}") + tile = background.find("{http://www.w3.org/2000/svg}rect") + assert tile is not None, f"no background rectangle in {bg}" + geometry = {key: float(tile.attrib[key]) for key in ("x", "y", "width", "height", "rx")} + thickness = geometry["width"] * BORDER_FRACTION + # An inward stroke keeps the outer platform geometry unchanged. Subtracting + # the same inset from rx (not scaling rx) keeps the corner thickness uniform. + inset = {"x": 1, "y": 1, "width": -2, "height": -2, "rx": -1} + silhouette = ET.Element("rect", {key: str(value) for key, value in geometry.items()}) + for key, value in geometry.items(): + tile.set(key, str(value + inset[key] * thickness / 2)) + tile.set("stroke", "#000000" if girl == "black" else "#ffffff") + tile.set("stroke-width", str(thickness)) + inner = "".join(ET.tostring(child, encoding="unicode") for child in background) + clip = ET.tostring(silhouette, encoding="unicode") + box = GIRL_BOXES[bg] + portrait = ET.fromstring(girl_layer(art, girl, box, align="xMidYMax")) + x, y, portrait_width, portrait_height = box + # Extrude a thin slice just above the bottom contour behind the artwork. + # Sampling above its antialiased tips avoids stretching transparent padding. + strip_height = 1 + strip_top = y + portrait_height - portrait_height * 0.01 + join_bottom = geometry["y"] + geometry["height"] - thickness + 4 + stretch = (join_bottom - strip_top) / strip_height + bx, by, bw, bh = girl_bbox(art, girl) + scale = min(portrait_width / bw, portrait_height / bh) + tx = x + (portrait_width - bw * scale) / 2 - bx * scale + ty = y + portrait_height - bh * scale - by * scale + # Flatten the viewport transform: very thin nested SVG viewBoxes can be + # culled by the renderer at small icon sizes, silently losing the extension. + extension_path = ET.fromstring(girl_path(art, girl)) + extension_path.attrib.pop("id", None) + join_clip = ( + f'' + ) + extension = ( + '' + f'' + f'{ET.tostring(extension_path, encoding="unicode")}' + ) badge = f" {commit_layer(art.commit, bg)}\n" if art.commit else "" return ( f'\n' + f' {clip}{join_clip}\n' f" {inner.strip()}\n" - f" {girl_layer(art, girl, GIRL_BOXES[bg])}\n" f"{badge}" + f' {extension}{ET.tostring(portrait, encoding="unicode")}\n' "\n" ) diff --git a/tests/scripts/test_icon_generation_exit.py b/tests/scripts/test_icon_generation_exit.py index 3888e0bb89..32a31b7a76 100644 --- a/tests/scripts/test_icon_generation_exit.py +++ b/tests/scripts/test_icon_generation_exit.py @@ -2,6 +2,7 @@ import importlib.util import io import sys +import xml.etree.ElementTree as ET from pathlib import Path from types import ModuleType, SimpleNamespace @@ -10,7 +11,8 @@ from PIL import Image @pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"]) -def test_svg_readers_accept_bom_without_rewriting_assets(tmp_path, monkeypatch, bom): +@pytest.mark.parametrize("editor_export", [False, True]) +def test_svg_readers_accept_bom_without_rewriting_assets(tmp_path, monkeypatch, bom, editor_export): monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py")) script = Path(__file__).resolve().parents[2] / "scripts/generate_icons.py" spec = importlib.util.spec_from_file_location("icon_readers_under_test", script) @@ -19,11 +21,20 @@ def test_svg_readers_accept_bom_without_rewriting_assets(tmp_path, monkeypatch, spec.loader.exec_module(module) path = tmp_path / "art.svg" element = '' - raw = bom + f'{element}'.encode("utf-8") + declaration = '\n' if editor_export else "" + namespaces = ' xmlns="http://www.w3.org/2000/svg" xmlns:editor="urn:editor"' + metadata = '' if editor_export else "" + document = f'{metadata}{element}' + raw = bom + (declaration + document).encode("utf-8") path.write_bytes(raw) art = SimpleNamespace(girls={"black": path}, paths={}, backgrounds=tmp_path, colors=None) assert module.girl_path(art, "black") == element - assert module.background_inner(art, path.name) == (element, 20, 30) + inner, width, height = module.background_inner(art, path.name) + composed = ET.fromstring(f'{inner}') + assert (width, height) == (20, 30) + assert [ET.tostring(child) for child in composed] == [ + ET.tostring(child) for child in ET.fromstring(document) + ] assert path.read_bytes() == raw path.write_bytes(bom + b"") art.paths.clear() @@ -75,3 +86,66 @@ def test_write_status_includes_every_target(tmp_path, monkeypatch, capsys, failu output = capsys.readouterr().out assert "last.png: PNG (2, 2)" in output assert ("FAILED" in output) is (failure is not None) + + +@pytest.mark.parametrize("platform", ["", "mac-"]) +@pytest.mark.parametrize("appearance,girl", [("light", "black"), ("dark", "white")]) +@pytest.mark.parametrize("colors", [None, ("#f5cc32", "#443808"), ("#e34850", "#4a1117")]) +def test_icon_portrait_overlays_border_inside_outer_silhouette(monkeypatch, platform, appearance, girl, colors): + monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py")) + source = Path(__file__).resolve().parents[2] + spec = importlib.util.spec_from_file_location("icon_geometry_under_test", source / "scripts/generate_icons.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + art = SimpleNamespace( + backgrounds=source / "assets/backgrounds", colors=colors, commit="0123456", + bboxes={girl: (0, 0, 100, 90)}, paths={girl: ''}, + ) + name = f"squircle-{platform}{appearance}.svg" + ns = {"svg": "http://www.w3.org/2000/svg"} + original = ET.parse(art.backgrounds / name).find("svg:rect", ns) + result = ET.fromstring(module.compose_svg(art, girl, name)) + tile = result.find("svg:rect", ns) + assert original is not None and tile is not None + x, y, width, height, radius = (float(original.attrib[key]) for key in ("x", "y", "width", "height", "rx")) + thickness = width * module.BORDER_FRACTION + assert float(tile.attrib["stroke-width"]) == pytest.approx(thickness) + assert tile.get("stroke") == ("#000000" if appearance == "light" else "#ffffff") + assert float(tile.attrib["x"]) - thickness / 2 == pytest.approx(x) + assert float(tile.attrib["y"]) - thickness / 2 == pytest.approx(y) + assert float(tile.attrib["width"]) + thickness == pytest.approx(width) + assert float(tile.attrib["height"]) + thickness == pytest.approx(height) + assert float(tile.attrib["rx"]) + thickness / 2 == pytest.approx(radius) + expected_fill = (colors or ("#ffffff", module.DARK_HEX))[appearance == "dark"] + assert tile.get("fill") == expected_fill + clip = result.find("svg:defs/svg:clipPath/svg:rect", ns) + assert clip is not None + assert tuple(float(clip.attrib[key]) for key in ("x", "y", "width", "height", "rx")) == ( + x, y, width, height, radius, + ) + group = result[-1] + portrait = group[-1] + assert portrait is not None and group is not None + assert portrait.get("preserveAspectRatio") == "xMidYMax meet" + assert tuple(float(portrait.attrib[key]) for key in ("x", "y", "width", "height")) == module.GIRL_BOXES[name] + clip_path = result.find("svg:defs/svg:clipPath", ns) + assert clip_path is not None + assert group.get("clip-path") == f"url(#{clip_path.attrib['id']})" + extension = group[0] + assert extension is not portrait + join_clip = result.find("svg:defs/svg:clipPath[@id='icon-join']/svg:rect", ns) + assert join_clip is not None + strip_x, strip_y, strip_width, strip_height = ( + float(join_clip.attrib[key]) for key in ("x", "y", "width", "height") + ) + px, py, pw, ph = module.GIRL_BOXES[name] + assert (strip_x, strip_width) == (px, pw) + assert 0 < py + ph - strip_y < ph / 50 + assert strip_y + strip_height > y + height - thickness + assert strip_y + strip_height < y + height + assert extension.get("clip-path") == "url(#icon-join)" + extension_ink = extension.find("svg:g/svg:path", ns) + portrait_ink = portrait.find("svg:path", ns) + assert extension_ink is not None and portrait_ink is not None + assert extension_ink.get("d") == portrait_ink.get("d")