fix(pwa): иконка приложения — значок сайта, а не отвергнутый знак
Предыдущая иконка была собрана из assets/logos/premium_park_favicon_pack — это неудачный вариант знака, владелец его забраковал. Ставим то, что стоит на самом сайте: красная плитка --s-accent с белой «П», как в pptaxi.ru/favicon.svg. Значок в браузере, на экране телефона и на сайте теперь один. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,92 +1,63 @@
|
||||
"""Иконки приложения из фирменного знака «Премиум Парк».
|
||||
"""Иконки приложения — тот же значок, что у сайта pptaxi.ru.
|
||||
|
||||
Знак — вектор (assets/logos/premium_park_favicon_pack/favicon.svg, поле 64×64,
|
||||
чёрные штрихи + янтарная дуга). Растеризуем его сами: путей всего три, команды —
|
||||
только M/L/H/V/C/Z, а тянуть в проект зависимость ради этого не хочется.
|
||||
Сайт отдаёт `favicon.svg`: красная скруглённая плитка --s-accent и белая «П».
|
||||
Повторяем её здесь, чтобы значок в браузере, на экране телефона и на сайте был
|
||||
один и тот же.
|
||||
|
||||
Сглаживание — сверхдискретизацией ×4: рисуем крупнее и уменьшаем. Так края
|
||||
получаются чище, чем у любой заливки «в лоб».
|
||||
Рисуем сами (Pillow), потому что тянуть растеризатор SVG ради одной плитки с
|
||||
буквой незачем. Сглаживание — сверхдискретизацией ×4.
|
||||
|
||||
Запуск (нужен Pillow):
|
||||
python scripts/gen-icons.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
SVG = Path(r"C:\VibeCoding\assets\logos\premium_park_favicon_pack\favicon.svg")
|
||||
OUT = Path(__file__).resolve().parent.parent / "public" / "icons"
|
||||
BG = (255, 255, 255, 255) # --s-paper сайта: знак нарисован для светлой подложки
|
||||
SS = 4 # кратность сверхдискретизации
|
||||
# Полезная часть знака внутри поля 64×64 (замерено по путям).
|
||||
MARK = (11.0, 8.0, 59.4, 43.7)
|
||||
ACCENT = (220, 38, 38, 255) # --s-accent сайта
|
||||
PAPER = (255, 255, 255, 255) # --s-paper
|
||||
SS = 4
|
||||
|
||||
# Шрифт ищем среди системных: у сайта заголовки набраны Manrope, но ради одной
|
||||
# буквы тащить файл шрифта в репозиторий не стоит — берём ближайший гротеск.
|
||||
FONT_CANDIDATES = [
|
||||
r"C:\Windows\Fonts\segoeuib.ttf",
|
||||
r"C:\Windows\Fonts\arialbd.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
]
|
||||
|
||||
|
||||
def _curve(p0, p1, p2, p3, steps=24):
|
||||
for i in range(1, steps + 1):
|
||||
t = i / steps
|
||||
u = 1 - t
|
||||
yield (u*u*u*p0[0] + 3*u*u*t*p1[0] + 3*u*t*t*p2[0] + t*t*t*p3[0],
|
||||
u*u*u*p0[1] + 3*u*u*t*p1[1] + 3*u*t*t*p2[1] + t*t*t*p3[1])
|
||||
def _font(size: int) -> ImageFont.FreeTypeFont:
|
||||
for path in FONT_CANDIDATES:
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size)
|
||||
raise SystemExit("не нашёл жирный шрифт для буквы")
|
||||
|
||||
|
||||
def flatten(d: str) -> list[tuple[float, float]]:
|
||||
"""Путь SVG → многоугольник. Поддержаны M, L, H, V, C, Z — больше в знаке нет."""
|
||||
tokens = re.findall(r"[MLHVCZmlhvcz]|-?\d*\.?\d+", d)
|
||||
pts: list[tuple[float, float]] = []
|
||||
cur = (0.0, 0.0)
|
||||
cmd = ""
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
t = tokens[i]
|
||||
if t.isalpha():
|
||||
cmd = t
|
||||
i += 1
|
||||
continue
|
||||
n = lambda k: float(tokens[i + k])
|
||||
if cmd in "ML":
|
||||
cur = (n(0), n(1)); pts.append(cur); i += 2
|
||||
elif cmd == "H":
|
||||
cur = (n(0), cur[1]); pts.append(cur); i += 1
|
||||
elif cmd == "V":
|
||||
cur = (cur[0], n(0)); pts.append(cur); i += 1
|
||||
elif cmd == "C":
|
||||
p1, p2, p3 = (n(0), n(1)), (n(2), n(3)), (n(4), n(5))
|
||||
pts.extend(_curve(cur, p1, p2, p3)); cur = p3; i += 6
|
||||
else:
|
||||
i += 1
|
||||
return pts
|
||||
|
||||
|
||||
def paths() -> list[tuple[list[tuple[float, float]], str]]:
|
||||
svg = SVG.read_text(encoding="utf-8")
|
||||
out = []
|
||||
for d, fill in re.findall(r'<path d="([^"]+)"\s+fill="([^"]+)"', svg):
|
||||
out.append((flatten(d), fill))
|
||||
return out
|
||||
|
||||
|
||||
def compose(size: int, inset: float) -> Image.Image:
|
||||
"""inset — доля стороны под знак. Для maskable нужна безопасная зона:
|
||||
Android режет иконку по кругу, и знак без запаса теряет края."""
|
||||
def compose(size: int, inset: float, radius_ratio: float = 0.22) -> Image.Image:
|
||||
"""inset — доля стороны под плитку. У maskable она меньше: Android режет
|
||||
иконку по кругу, и плитка без запаса теряет углы."""
|
||||
big = size * SS
|
||||
img = Image.new("RGBA", (big, big), BG)
|
||||
img = Image.new("RGBA", (big, big), PAPER)
|
||||
draw = ImageDraw.Draw(img)
|
||||
x0, y0, x1, y1 = MARK
|
||||
scale = min(big * inset / (x1 - x0), big * inset / (y1 - y0))
|
||||
dx = (big - (x1 - x0) * scale) / 2 - x0 * scale
|
||||
dy = (big - (y1 - y0) * scale) / 2 - y0 * scale
|
||||
for pts, fill in paths():
|
||||
draw.polygon([(x * scale + dx, y * scale + dy) for x, y in pts], fill=fill)
|
||||
pad = big * (1 - inset) / 2
|
||||
box = (pad, pad, big - pad, big - pad)
|
||||
draw.rounded_rectangle(box, radius=(box[2] - box[0]) * radius_ratio, fill=ACCENT)
|
||||
|
||||
letter = "П"
|
||||
font = _font(int((box[3] - box[1]) * 0.72))
|
||||
l, t, r, b = draw.textbbox((0, 0), letter, font=font)
|
||||
draw.text(((box[0] + box[2] - (r - l)) / 2 - l,
|
||||
(box[1] + box[3] - (b - t)) / 2 - t), letter, font=font, fill=PAPER)
|
||||
return img.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for name, size, inset in (("icon-192.png", 192, 0.82),
|
||||
("icon-512.png", 512, 0.82),
|
||||
("icon-maskable-512.png", 512, 0.60),
|
||||
("apple-touch-icon.png", 180, 0.82)):
|
||||
for name, size, inset in (("icon-192.png", 192, 1.0),
|
||||
("icon-512.png", 512, 1.0),
|
||||
("icon-maskable-512.png", 512, 0.72),
|
||||
("apple-touch-icon.png", 180, 1.0)):
|
||||
compose(size, inset).save(OUT / name)
|
||||
print("собрано", name)
|
||||
|
||||
Reference in New Issue
Block a user