|
1 | | -"""Generate the marketplace icon (128x128 PNG) from the brand SVG. |
| 1 | +"""Generate the marketplace icon (128×128 PNG) from the brand SVG. |
2 | 2 |
|
3 | | -Re-run when the brand mark changes; the resulting ``icon.png`` is |
4 | | -committed alongside this script so contributors don't need to install |
5 | | -Pillow just to read it. The script depends on Pillow (``pip install |
6 | | -pillow``); a single one-shot, not part of the extension build. |
| 3 | +The source of truth is ``media/icon-source.svg`` (concept B: navy |
| 4 | +shield with a teal accent border + teal check). This script is a |
| 5 | +pure-Python renderer that produces the matching PNG so contributors |
| 6 | +don't need to install Inkscape / Cairo / a browser to read the icon. |
7 | 7 |
|
8 | | -The mark is the same shield + teal checkmark used in the |
9 | | -pipeline-check docs hero. Path data lifted verbatim from |
10 | | -``dmartinochoa/pipeline-check`` ``docs/index.md`` so the marketplace |
11 | | -icon, the docs hero, and the favicon share a single visual identity. |
| 8 | +Implementation notes |
| 9 | +-------------------- |
| 10 | +
|
| 11 | +* Renders at 4× supersampling (512×512) and downsamples to 128×128 |
| 12 | + with Lanczos resampling. PIL's straight ``ImageDraw`` antialiasing is |
| 13 | + blocky on diagonal strokes; the supersample → downsample pipeline |
| 14 | + gives clean edges that match what a real SVG renderer would produce. |
| 15 | +
|
| 16 | +* The shield path uses two quadratic Beziers (top-left and top-right |
| 17 | + arcs at the base) — sampled into polygon points for ``ImageDraw``. |
| 18 | +
|
| 19 | +* The check uses ``ImageDraw.line`` with ``joint="curve"`` for the |
| 20 | + vertex join, plus explicit circles at the two endpoints to simulate |
| 21 | + ``stroke-linecap="round"``. |
| 22 | +
|
| 23 | +Dependencies: Pillow (``pip install pillow``). Run once after the |
| 24 | +brand mark changes; the resulting ``icon.png`` is committed alongside |
| 25 | +this script. |
12 | 26 | """ |
13 | 27 | from __future__ import annotations |
14 | 28 |
|
15 | 29 | from pathlib import Path |
16 | 30 |
|
17 | 31 | from PIL import Image, ImageDraw |
18 | 32 |
|
| 33 | +# Output size + supersample factor. The renderer composes everything in |
| 34 | +# the supersampled space then downsizes — keeps the diagonal stroke of |
| 35 | +# the check from going staircase-blocky. |
19 | 36 | ICON_SIZE = 128 |
20 | | -SCALE = 2 # viewBox is 64x64 in the brand SVG. |
| 37 | +SS = 4 |
| 38 | +SCALE = SS # design SVG viewBox is already 128×128, so we just scale by SS |
21 | 39 |
|
22 | | -# Pipeline-Check design tokens (lifted from |
23 | | -# pipeline_check/core/_design_tokens.css + extra.css). |
| 40 | +# Pipeline-Check design tokens (mirrors media/icon-source.svg and |
| 41 | +# pipeline_check/core/_design_tokens.css). |
24 | 42 | NAVY_950 = "#04101a" |
25 | | -WHITE = "#f0f2f5" |
26 | 43 | TEAL = "#1ba3a9" |
27 | 44 |
|
28 | 45 | OUT_DIR = Path(__file__).resolve().parent.parent |
29 | 46 | OUT_PATH = OUT_DIR / "icon.png" |
30 | 47 |
|
31 | 48 |
|
32 | | -def _cubic_bezier( |
| 49 | +def _quadratic_bezier( |
33 | 50 | p0: tuple[float, float], |
34 | 51 | p1: tuple[float, float], |
35 | 52 | p2: tuple[float, float], |
36 | | - p3: tuple[float, float], |
37 | | - steps: int = 32, |
| 53 | + steps: int = 48, |
38 | 54 | ) -> list[tuple[float, float]]: |
39 | | - """Sample a cubic Bezier curve into ``steps + 1`` polygon points.""" |
40 | | - points: list[tuple[float, float]] = [] |
| 55 | + """Sample a quadratic Bezier curve into ``steps + 1`` polygon points.""" |
| 56 | + out: list[tuple[float, float]] = [] |
41 | 57 | for i in range(steps + 1): |
42 | 58 | t = i / steps |
43 | 59 | mt = 1 - t |
44 | | - x = ( |
45 | | - mt ** 3 * p0[0] |
46 | | - + 3 * mt ** 2 * t * p1[0] |
47 | | - + 3 * mt * t ** 2 * p2[0] |
48 | | - + t ** 3 * p3[0] |
49 | | - ) |
50 | | - y = ( |
51 | | - mt ** 3 * p0[1] |
52 | | - + 3 * mt ** 2 * t * p1[1] |
53 | | - + 3 * mt * t ** 2 * p2[1] |
54 | | - + t ** 3 * p3[1] |
55 | | - ) |
56 | | - points.append((x, y)) |
57 | | - return points |
| 60 | + x = mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0] |
| 61 | + y = mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1] |
| 62 | + out.append((x, y)) |
| 63 | + return out |
58 | 64 |
|
59 | 65 |
|
60 | 66 | def _shield_outline() -> list[tuple[float, float]]: |
61 | | - """Return the shield polygon in viewBox (64x64) coordinates. |
62 | | -
|
63 | | - Mirrors the SVG path:: |
| 67 | + """Shield polygon in the design's 128×128 viewBox coords. |
64 | 68 |
|
65 | | - M32 6 L54 13 V31 |
66 | | - C54 44.5 44.5 53.5 32 58 |
67 | | - C19.5 53.5 10 44.5 10 31 |
68 | | - V13 Z |
| 69 | + Mirrors media/icon-source.svg:: |
69 | 70 |
|
70 | | - Two cubic Beziers form the bottom curve; the rest is straight. |
| 71 | + M 64 12 L 110 26 L 110 62 Q 110 92 64 116 Q 18 92 18 62 L 18 26 Z |
71 | 72 | """ |
72 | 73 | pts: list[tuple[float, float]] = [ |
73 | | - (32.0, 6.0), # top center |
74 | | - (54.0, 13.0), # top right |
75 | | - (54.0, 31.0), # vertical line down right side |
| 74 | + (64.0, 12.0), # top-centre cusp |
| 75 | + (110.0, 26.0), # top-right |
| 76 | + (110.0, 62.0), # right-side straight down |
76 | 77 | ] |
77 | | - # First bezier: down-right curve to bottom apex. |
78 | | - pts.extend(_cubic_bezier( |
79 | | - (54.0, 31.0), (54.0, 44.5), (44.5, 53.5), (32.0, 58.0), |
80 | | - )) |
81 | | - # Second bezier: bottom apex back up to left side. |
82 | | - pts.extend(_cubic_bezier( |
83 | | - (32.0, 58.0), (19.5, 53.5), (10.0, 44.5), (10.0, 31.0), |
84 | | - )) |
85 | | - pts.append((10.0, 13.0)) # vertical line up left side |
| 78 | + # Bottom-right curve down to the apex. |
| 79 | + pts.extend(_quadratic_bezier((110.0, 62.0), (110.0, 92.0), (64.0, 116.0))) |
| 80 | + # Bottom-left curve back up. |
| 81 | + pts.extend(_quadratic_bezier((64.0, 116.0), (18.0, 92.0), (18.0, 62.0))) |
| 82 | + pts.append((18.0, 26.0)) # left-side straight up |
86 | 83 | return pts |
87 | 84 |
|
88 | 85 |
|
89 | | -def _scaled( |
90 | | - points: list[tuple[float, float]], |
91 | | -) -> list[tuple[float, float]]: |
92 | | - return [(x * SCALE, y * SCALE) for x, y in points] |
| 86 | +def _scaled(pts: list[tuple[float, float]]) -> list[tuple[float, float]]: |
| 87 | + return [(x * SCALE, y * SCALE) for x, y in pts] |
| 88 | + |
| 89 | + |
| 90 | +def _round_cap( |
| 91 | + draw: ImageDraw.ImageDraw, |
| 92 | + centre: tuple[float, float], |
| 93 | + width: float, |
| 94 | + fill: str, |
| 95 | +) -> None: |
| 96 | + """Draw a filled circle to simulate ``stroke-linecap="round"``.""" |
| 97 | + r = width / 2.0 |
| 98 | + draw.ellipse( |
| 99 | + (centre[0] - r, centre[1] - r, centre[0] + r, centre[1] + r), |
| 100 | + fill=fill, |
| 101 | + ) |
93 | 102 |
|
94 | 103 |
|
95 | 104 | def main() -> None: |
96 | | - img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), NAVY_950) |
97 | | - draw = ImageDraw.Draw(img) |
| 105 | + big_size = ICON_SIZE * SS |
| 106 | + img = Image.new("RGBA", (big_size, big_size), (0, 0, 0, 0)) |
| 107 | + draw = ImageDraw.Draw(img, "RGBA") |
98 | 108 |
|
99 | | - # Shield outline, no fill, 5px stroke (≈2.5 in viewBox * SCALE). |
100 | 109 | shield = _scaled(_shield_outline()) |
101 | | - draw.line(shield + [shield[0]], fill=WHITE, width=5, joint="curve") |
102 | | - |
103 | | - # Checkmark: M22 32 L29 39 L43 24, 6px stroke (3 in viewBox * SCALE). |
104 | | - check = _scaled([(22.0, 32.0), (29.0, 39.0), (43.0, 24.0)]) |
105 | | - draw.line(check, fill=TEAL, width=6, joint="curve") |
106 | 110 |
|
| 111 | + # Fill the navy shield. |
| 112 | + draw.polygon(shield, fill=NAVY_950) |
| 113 | + |
| 114 | + # Stroke the teal accent border (width 4 in viewBox space). |
| 115 | + border_width = 4 * SCALE |
| 116 | + draw.line( |
| 117 | + shield + [shield[0]], |
| 118 | + fill=TEAL, |
| 119 | + width=border_width, |
| 120 | + joint="curve", |
| 121 | + ) |
| 122 | + # Round-cap the implicit "close path" join at the top cusp so it |
| 123 | + # doesn't read as a notch. |
| 124 | + _round_cap(draw, shield[0], border_width, TEAL) |
| 125 | + |
| 126 | + # Inner ribbon at 25% opacity. Implemented by drawing the path at |
| 127 | + # full opacity onto a transparent layer, then alpha-compositing |
| 128 | + # with reduced opacity onto the main image. |
| 129 | + ribbon = _scaled( |
| 130 | + [(64.0, 22.0), (100.0, 32.0), (100.0, 62.0)] |
| 131 | + + _quadratic_bezier((100.0, 62.0), (100.0, 86.0), (64.0, 106.0)) |
| 132 | + + _quadratic_bezier((64.0, 106.0), (28.0, 86.0), (28.0, 62.0)) |
| 133 | + + [(28.0, 32.0)] |
| 134 | + ) |
| 135 | + ribbon_layer = Image.new("RGBA", (big_size, big_size), (0, 0, 0, 0)) |
| 136 | + ribbon_draw = ImageDraw.Draw(ribbon_layer, "RGBA") |
| 137 | + ribbon_draw.line( |
| 138 | + ribbon + [ribbon[0]], |
| 139 | + fill=TEAL, |
| 140 | + width=1 * SCALE, |
| 141 | + joint="curve", |
| 142 | + ) |
| 143 | + # Reduce to 25% opacity. |
| 144 | + alpha = ribbon_layer.split()[-1].point(lambda v: int(v * 0.25)) |
| 145 | + ribbon_layer.putalpha(alpha) |
| 146 | + img.alpha_composite(ribbon_layer) |
| 147 | + |
| 148 | + # Check mark. |
| 149 | + check_pts = _scaled([(40.0, 64.0), (56.0, 80.0), (88.0, 48.0)]) |
| 150 | + check_width = 11 * SCALE |
| 151 | + draw.line(check_pts, fill=TEAL, width=check_width, joint="curve") |
| 152 | + _round_cap(draw, check_pts[0], check_width, TEAL) |
| 153 | + _round_cap(draw, check_pts[-1], check_width, TEAL) |
| 154 | + |
| 155 | + # Downsample with Lanczos for smooth edges. |
| 156 | + img = img.resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS) |
107 | 157 | img.save(OUT_PATH, "PNG", optimize=True) |
108 | | - print(f"wrote {OUT_PATH} ({ICON_SIZE}x{ICON_SIZE})") |
| 158 | + print(f"wrote {OUT_PATH} ({ICON_SIZE}×{ICON_SIZE}) from media/icon-source.svg") |
109 | 159 |
|
110 | 160 |
|
111 | 161 | if __name__ == "__main__": |
|
0 commit comments