fix(icons): match portrait placement and join contrasting borders
This commit is contained in:
@@ -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'<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{bx} {by} {bw} {bh}">\n'
|
||||
f'<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{bx} {by} {bw} {bh}" '
|
||||
f'preserveAspectRatio="{align} meet">\n'
|
||||
f" {girl_path(art, girl)}\n"
|
||||
" </svg>"
|
||||
)
|
||||
@@ -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'<svg\b[^>]*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*</svg>\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"<g>{inner}</g>")
|
||||
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'<clipPath id="icon-join"><rect x="{x}" y="{strip_top}" '
|
||||
f'width="{portrait_width}" height="{join_bottom - strip_top}"/></clipPath>'
|
||||
)
|
||||
extension = (
|
||||
'<g clip-path="url(#icon-join)">'
|
||||
f'<g transform="matrix({scale} 0 0 {scale * stretch} {tx} {stretch * ty + (1 - stretch) * strip_top})">'
|
||||
f'{ET.tostring(extension_path, encoding="unicode")}</g></g>'
|
||||
)
|
||||
badge = f" {commit_layer(art.commit, bg)}\n" if art.commit else ""
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}">\n'
|
||||
f' <defs><clipPath id="icon-silhouette">{clip}</clipPath>{join_clip}</defs>\n'
|
||||
f" {inner.strip()}\n"
|
||||
f" {girl_layer(art, girl, GIRL_BOXES[bg])}\n"
|
||||
f"{badge}"
|
||||
f' <g clip-path="url(#icon-silhouette)">{extension}{ET.tostring(portrait, encoding="unicode")}</g>\n'
|
||||
"</svg>\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = '<path d="M0 0 L1 1" aria-label="café 東京"/>'
|
||||
raw = bom + f'<svg viewBox="0 0 20 30">{element}</svg>'.encode("utf-8")
|
||||
declaration = '<?xml version="1.0" encoding="UTF-8"?>\n' if editor_export else ""
|
||||
namespaces = ' xmlns="http://www.w3.org/2000/svg" xmlns:editor="urn:editor"'
|
||||
metadata = '<editor:namedview editor:zoom="1"/>' if editor_export else ""
|
||||
document = f'<svg{namespaces} viewBox="0 0 20 30">{metadata}{element}</svg>'
|
||||
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'<svg xmlns="http://www.w3.org/2000/svg">{inner}</svg>')
|
||||
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"<svg/>")
|
||||
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: '<path d="M0 0H100V90H0Z"/>'},
|
||||
)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user