0.2.3: 重绘系列通用安装图标并记录功能审核决定(#413、#77/#408、#444、#407)

This commit is contained in:
sganggs committed 2026-10-10 02:11:23 +08:00
1 parent ba5b998a72
commit 9042125430
21 files changed
+249 -13

No files matched your search

+2 -2
View File
@@ -16,8 +16,8 @@
- 设置新增四档文字大小,支持长译文换行;干员调配可逐干员选择中文、日语或跟随全局语音(#435、#436,PR #446,感谢 @7eLaryn)。
- 补齐司霆惊蛰、赤刃明霄陈等干员技能期间的攻击与命中音,保留各技能音效自己的音量与播放概率(PR #410,感谢 @Convey123)。
- AI 购买高阶卡时适当降低与队友开局盟约抢卡的倾向,并在混合对局中发送有限的表情回应(PR #407、#444,感谢 @IceCodeNew、@DDDarkstar;可用 `SP_BOT_EMOTES=0` 关闭表情)。
- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带安装图标(PR #413,感谢 @Cromemadnd)。
- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(PR #408,感谢 @Siornya)。这是复刻版便捷功能。
- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带原创的系列通用要塞图标、maskable 安全区版本和 favicon(PR #413,感谢 @Cromemadnd)。
- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(#77、PR #408,感谢 @Siornya)。这是复刻版便捷功能。
- 修复模型加载较慢时丢失入场动画,按经过时间接续,攻击或死亡后不再补播过期入场(PR #416 提示的加载问题,感谢 @Cromemadnd)。
## 0.2.2 — 2026-10-09
+46 -2
View File
@@ -2,8 +2,52 @@
Owner: `tools/fetch-assets.mjs` and `tools/assets/*`. Research background: `docs/research/07-assets.md`.
All art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored.
Everything the client needs is listed in **`data/assets.json`**. The client should only request URLs that appear in that manifest.
All official game art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored.
All downloaded game assets the client needs are listed in **`data/assets.json`**. Game-art requests must use that manifest.
The original project icons below are shipped static files, not downloaded game assets.
## Original project application icons (#413)
`public/icons/app.svg` is the original vector master for the **卫戍协议 series**, independent of any season.
Three battlements lead into a shield-shaped wall around one square tactical cell: defending a position on a board.
The only colours are charcoal `#242424` and off-white `#f2f2f2`; the mark contains no text, gradients or fine texture.
It contains no 「盟约」 lettering, Alliance badge or season-exclusive colours / elements.
**Originality statement (2026-10-10):** the SVG geometry was drawn from basic rectangles and polygons for this
project. No official logo, artwork, icon or font was used, downloaded, embedded, traced or redrawn to make it.
The vector and exports are project-owned work under the repository's GPL-3.0-or-later licence, separate from the
Hypergryph / Yostar game assets below. The abstract fortification / tactical-board idea does not assert official branding.
Rebuild offline with Python 3 and Pillow (the same image library used by local-extract):
```bash
python3 tools/export-app-icons.py
# Optional review export without replacing shipped files:
python3 tools/export-app-icons.py --out /tmp/stronghold-icons
```
The script reads only the master SVG's flat rectangle / polygon geometry, renders at 8× and downsamples; unsupported
SVG elements fail rather than silently disappearing. It needs no game art, fonts, browser or network.
| Files in `public/icons/` | Use |
|---|---|
| `app-192.png`, `app-512.png` | Manifest `purpose: any`, 192 / 512 px |
| `app-maskable-192.png`, `app-maskable-512.png` | Manifest `purpose: maskable`, opaque full-bleed background |
| `favicon-16.png`, `favicon-32.png`, `favicon-48.png`, `favicon.ico` | Tab / legacy favicon; ICO contains 16 / 32 / 48 px frames |
| `apple-touch-icon.png` | 180 px home-screen icon |
| `app.svg` | Scalable favicon and editable source |
The maskable mark is scaled to 87.5% around the canvas centre. Its outermost vertex is about 34.8% of the canvas
width from that centre; even antialiased edge pixels stay inside the **40%-radius safe circle** specified by
[Web Application Manifest §2.3](https://www.w3.org/TR/appmanifest/#icon-masks).
The background remains opaque to every edge; launcher masks may remove the background outside the safe zone.
Normal icons and favicons retain the larger mark for readability. `public/index.html` links the SVG / ICO / PNG
favicons and Apple icon; `public/manifest.json` lists all four install icons. All these static files must be tracked
and included by the full and lite package allowlist; no changes to `data/assets.json` are needed.
`test/ui/pwa-install-023.test.js` checks decoded pixels, mask safety and references; `test/package.test.js` checks
packaging inclusion. The local handoff holds old/new light/dark, small-size and phone-layout previews with their
render script. These are offline review renders, not evidence of browser or native OS installation.
## Running
+7 -2
View File
@@ -115,8 +115,13 @@ refused recovery can return to the previous identity and nickname. It does not i
Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears
obsolete metadata on the next welcome. A spectator never registers a recoverable player seat.
`manifest.json` enables browser installation; its original fortress favicon is packaged as actual 192/512 px icons in
`public/icons/`, including lite packages. The install button appears only for a live browser install offer and hides
`manifest.json` enables browser installation. The original season-independent fortress / shield mark in
`public/icons/app.svg` uses neutral greys and no official art, logos, icons, fonts or season-specific insignia.
`tools/export-app-icons.py` exports 192/512 px PNGs for `any` and separate opaque `maskable` icons: the latter scale
only the mark to 87.5%, keeping every foreground pixel inside the central circle of radius 40% of the canvas.
The same master supplies 16/32/48 px PNG / ICO favicons and the 180 px Apple touch icon linked from `index.html`.
These project-owned assets live in `public/icons/` and ship in full and lite packages; ASSETS.md records their provenance.
The install button appears only for a live browser install offer and hides
in standalone/fullscreen windows or after installation. Every offer is consumed before awaiting the prompt, including
cancellation or failure. No service worker, offline cache or persistent server is introduced; HTTPS or localhost and
browser support still govern installation.
+4
View File
@@ -15,6 +15,10 @@ In scope:
- Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker.
- Reconnect, AI take-over of disconnected players, robust validation of every client intent.
New features require explicit maintainer approval before implementation. The 2026-10-10 review approved retaining
the existing PWA installation, unanimous opening reroll, teammate emotes and cooperative purchasing heuristic
(DESIGN §28.15, §28.16, §28.19, §28.21); this is not blanket approval for future additions.
Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the shared pool — 0.2.0 plays them as 自选编队: the picks of the 干员调配 overlay's 自选编队 tab, each sold only in its player's shop; DATA.md §18, shared/diy.js, server/match/player/diy.js), trophies/progression persistence, reporting.
---
+18 -2
View File
@@ -215,6 +215,8 @@ English overflow; the corrected browser suite passes. Current behavior is in des
### 28.15 Cooperative card purchasing — PR #407
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
Adapted from @IceCodeNew. [ASSUMED] A tier V/VI shared-pool card matching another living teammate's active opening
bond receives one 18-point buying-score penalty. It is a soft preference, not reserved stock; normal/elite and DIY
stock semantics remain unchanged. Humans and bots receive the same cooperation. This does not add persona presets.
@@ -225,6 +227,8 @@ measurement of the purchasing heuristic. Current rule: design/match.md §6.6 and
### 28.16 AI teammate emotes — PR #444
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
Event wiring from @DDDarkstar, with corrected dispatch semantics. [ASSUMED] Cosmetic reactions are enabled only in
mixed human/AI matches, under the existing emote whitelist and cooldown; `SP_BOT_EMOTES=0` disables all hooks.
Responses skip cooling bots and stop after one reply. MVP uses this round's kills and damage, with stable order for
@@ -265,20 +269,30 @@ a real phone-sized browser changes the control, reloads and resets it. Current c
### 28.19 Browser installation — PR #413
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
Based on @Cromemadnd's proposal, with a one-use install controller. Buttons appear only after a browser install offer;
standalone/fullscreen and installed sessions hide them. Accept, dismiss, failure and duplicate clicks all consume the
same offer once. See [the prompt API](https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/prompt)
and [installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable).
The existing original fortress favicon supplies correctly sized 192/512 px icons, shipped outside optional game art.
The 2026-10-10 icon revision replaces the previous mint rook with an original, season-independent fortress / shield
and defended square. Its neutral grey palette and geometry use no official logos, artwork, icons or fonts and no
Alliance lettering or seasonal insignia. `public/icons/app.svg` is the master; `tools/export-app-icons.py` exports
192/512 px `any` and separate opaque `maskable` PNGs (the mark stays inside the central 40%-radius safe circle),
16/32/48 px PNG / ICO favicons and a 180 px Apple touch icon. All ship outside optional game art, including lite
packages. See ASSETS.md for the source, reproduction command and originality statement.
No service worker or offline cache is installed; the server is still required.
The lobby keeps Settings immediately before How to Play even when the optional install/recovery buttons appear;
the full browser run caught and corrected an initial insertion between that established pair.
The settings-entry check tests the absent board-style/background-music option labels, allowing the text-size help
to explain that the board keeps its scale.
Tests: offer lifecycle/disposal, actual PNG dimensions, real Chrome manifest and installability checks, and a synthetic
Tests: offer lifecycle/disposal, decoded PNG dimensions / opacity / maskable safe zone, favicon references and packaging inclusion.
The initial feature also had real Chrome manifest and installability checks, and a synthetic
dismissed offer through the actual button. Initial incognito rejection was a test-environment limitation; the real
installability check runs in Chrome's ordinary temporary profile. Native OS installation itself is not performed.
The icon revision is verified offline; browser / OS validation of these replacement assets is deferred to the follow-up
review environment (this sandbox cannot listen on a port).
### 28.20 Recover a closed local match window — #431
@@ -294,6 +308,8 @@ the match tab. The existing identity/static suite also passes. Current contract:
### 28.21 Unanimous opening reroll — #77 / PR #408
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
Adapted from @Siornya, excluding the unrelated result-screen redesign. [ASSUMED] This remake feature is host-proposed,
requires every connected human's consent, and pauses confirmation while voting. Bots/spectators do not vote; autoplay
humans still do. Single-human matches refresh immediately. Refusal, cancellation, disconnect or leave restores the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 886 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+10 -1
View File
@@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" fill="#0c0f0e"/><path fill="#4ed8af" d="M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<title>Stronghold Protocol — fortress and defended square</title>
<desc>Original geometric project mark. No official artwork, lettering or seasonal insignia.</desc>
<rect width="512" height="512" fill="#242424"/>
<g id="mark">
<polygon fill="#f2f2f2" points="112,112 176,112 176,152 224,152 224,112 288,112 288,152 336,152 336,112 400,112 400,304 352,352 256,416 160,352 112,304"/>
<polygon fill="#242424" points="176,216 336,216 336,280 304,312 256,344 208,312 176,280"/>
<rect x="232" y="240" width="48" height="48" fill="#f2f2f2"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 193 B

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+5 -1
View File
@@ -16,7 +16,11 @@
<meta name="description" content="卫戍协议:盟约 · 网页联机复刻(非官方同人作品)" />
<title>卫戍协议:盟约 · STRONGHOLD PROTOCOL</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<link rel="icon" href="/icons/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" href="/icons/favicon-16.png" type="image/png" sizes="16x16" />
<link rel="icon" href="/icons/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="icon" href="/icons/app.svg" type="image/svg+xml" sizes="any" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180" />
<!-- Web fonts: never render-blocking (LAN/offline play must still start); system fallbacks in theme.css. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
+3 -1
View File
@@ -11,6 +11,8 @@
"theme_color": "#0c0f0e",
"icons": [
{ "src": "/icons/app-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
{ "src": "/icons/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/app-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/app-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+12
View File
@@ -20,6 +20,18 @@ const FAKE_HOME = ['', 'Users', 'someone'].join('/');
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const TOOL = path.join(ROOT, 'tools', 'package.mjs');
test('all install and favicon assets ship independently of optional game art', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/manifest.json'), 'utf8'));
const html = fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8');
const urls = [...manifest.icons.map((i) => i.src),
...[...html.matchAll(/<link rel="(?:icon|apple-touch-icon)"[^>]+href="([^"]+)"/g)].map((m) => m[1])];
const files = [...new Set(['public/manifest.json', ...urls.map((url) => `public${url}`),
...[16, 32, 48].map((size) => `public/icons/favicon-${size}.png`)])].sort();
for (const f of files) assert.ok(fs.statSync(path.join(ROOT, f)).size > 0, f);
assert.deepEqual(selectTracked([...files, 'tools/export-app-icons.py', 'handoff/claude-review-023/icon/light.png']).keep,
files, 'full and lite share the same tracked static-file allowlist');
});
// hermetic scans: not this machine's account name, but a planted one
const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' };
+14
View File
@@ -170,6 +170,20 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
const client = await page.createCDPSession();
const manifest = await client.send('Page.getAppManifest');
assert.deepEqual(manifest.errors, []);
const icons = await page.evaluate(async () => {
const m = await (await fetch('/manifest.json')).json();
const entries = [...m.icons, ...[...document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]')]
.map((link) => ({ src: link.getAttribute('href'), purpose: link.rel }))];
return Promise.all(entries.map(async (entry) => {
const response = await fetch(entry.src);
if (!response.ok) throw new Error(`${entry.src}: ${response.status}`);
const img = new Image(); img.src = entry.src; await img.decode();
return { purpose: entry.purpose, width: img.naturalWidth, height: img.naturalHeight };
}));
});
assert.deepEqual(icons.filter((i) => i.purpose === 'maskable').map((i) => i.width).sort((a, b) => a - b), [192, 512]);
assert.ok(icons.every((i) => i.width > 0 && i.width === i.height));
assert.ok(icons.some((i) => i.purpose === 'apple-touch-icon' && i.width === 180));
const install = await client.send('Page.getInstallabilityErrors');
assert.deepEqual(install.installabilityErrors, []);
await page.evaluate(() => {
+53 -2
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPwaInstall } from '../../public/js/pwa.js';
import { decodePng } from '../../tools/crop-board-atlas.mjs';
function rig(standalone = false) {
const win = new EventTarget(), display = new EventTarget();
display.matches = standalone;
@@ -42,13 +43,63 @@ test('installed / standalone hides stale offers; disposing removes listeners', a
assert.equal(offer().e.defaultPrevented, false);
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
});
test('manifest uses actual 192/512 icons shipped outside optional game assets', () => {
test('manifest provides opaque 192/512 icons for both purposes with a safe maskable mark', () => {
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
assert.deepEqual(m.icons.map((i) => `${i.purpose}:${i.sizes}`).sort(),
['any:192x192', 'any:512x512', 'maskable:192x192', 'maskable:512x512']);
for (const icon of m.icons) {
assert.ok(icon.src.startsWith('/icons/'));
assert.equal(icon.type, 'image/png');
const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url));
const size = Number(icon.sizes.split('x')[0]);
assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size);
const { w, h, rgba } = decodePng(bytes);
assert.equal(w, size); assert.equal(h, size);
let foreground = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
assert.equal(rgba[i + 3], 255, 'opaque edge-to-edge');
assert.equal(rgba[i], rgba[i + 1], 'season-independent neutral palette');
assert.equal(rgba[i], rgba[i + 2]);
if (rgba[i] === 36) continue; // #242424 background
foreground++;
if (icon.purpose === 'maskable') {
// W3C safe zone is a circle, not merely a centred 80% square.
const dx = x + 0.5 - w / 2, dy = y + 0.5 - h / 2;
assert.ok(dx * dx + dy * dy <= (size * 0.4) ** 2, `clipped mark at ${x},${y}`);
}
}
assert.ok(foreground > size * size * 0.12, 'the exported mark is not empty or tiny');
}
});
test('favicon and Apple links resolve to real sizes, including all legacy ICO frames', () => {
const html = readFileSync(new URL('../../public/index.html', import.meta.url), 'utf8');
const links = [...html.matchAll(/<link rel="(?:icon|apple-touch-icon)"[^>]+>/g)].map(([s]) => s);
assert.equal(links.length, 5);
for (const link of links) {
const href = link.match(/href="([^"]+)"/)[1];
assert.ok(href.startsWith('/icons/'), href);
const bytes = readFileSync(new URL('../../public' + href, import.meta.url));
if (href.endsWith('.png')) {
const { w, h } = decodePng(bytes);
assert.ok(link.includes(`sizes="${w}x${h}"`), href);
} else if (href.endsWith('.svg')) {
assert.match(bytes.toString(), /viewBox="0 0 512 512"/);
assert.doesNotMatch(bytes.toString(), /<(?:image|text|use|foreignObject)\b|(?:href|font-family)=/);
} else {
assert.equal(bytes.readUInt16LE(2), 1);
assert.equal(bytes.readUInt16LE(4), 3);
for (const [i, size] of [16, 32, 48].entries()) {
const entry = 6 + i * 16;
assert.equal(bytes[entry], size); assert.equal(bytes[entry + 1], size);
const length = bytes.readUInt32LE(entry + 8), offset = bytes.readUInt32LE(entry + 12);
assert.ok(length > 0 && offset >= 54 && offset + length <= bytes.length);
}
}
}
for (const size of [16, 32, 48]) {
const { w, h } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
assert.equal(w, size); assert.equal(h, size);
}
});
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Export the original app.svg offline: python3 tools/export-app-icons.py.
Requires Python 3 + Pillow (also used by local-extract). This deliberately renders
only this source's flat rect/polygon vocabulary; unsupported SVG fails loudly.
No fonts, game art, browser, network or platform image service is involved.
"""
import argparse
from pathlib import Path
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "public/icons/app.svg"
MASK_SCALE = 0.875
def render(size, maskable=False):
svg = ET.parse(SOURCE).getroot()
if svg.attrib != {"width": "512", "height": "512", "viewBox": "0 0 512 512"}:
raise ValueError("Expected the 512-square SVG master")
# Eight samples per output pixel, then area averaging; no ringing beyond the mark.
scale = size * 8 / 512
image = Image.new("RGB", (size * 8, size * 8))
draw = ImageDraw.Draw(image)
def paint(element, inset=1):
tag = element.tag.rsplit("}", 1)[-1]
attrs = element.attrib
if tag in ("title", "desc"):
if attrs or len(element):
raise ValueError("Unsupported metadata")
return
if tag == "g" and attrs == {"id": "mark"}:
for child in element:
paint(child, MASK_SCALE if maskable else 1)
return
if tag == "rect" and set(attrs) <= {"x", "y", "width", "height", "fill"}:
x, y = float(attrs.get("x", 0)), float(attrs.get("y", 0))
w, h = float(attrs["width"]), float(attrs["height"])
points = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
elif tag == "polygon" and set(attrs) == {"fill", "points"}:
points = [tuple(map(float, p.split(","))) for p in attrs["points"].split()]
else:
raise ValueError(f"Unsupported SVG element/attributes: {tag} {attrs}")
# Pillow addresses pixel centres; shift the vector edges by half a sample.
points = [((256 + (x - 256) * inset) * scale - 0.5,
(256 + (y - 256) * inset) * scale - 0.5) for x, y in points]
draw.polygon(points, fill=attrs["fill"])
for element in svg:
paint(element)
return image.resize((size, size), Image.Resampling.BOX)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=ROOT / "public/icons")
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
for size in (192, 512):
for maskable in (False, True):
name = f"app-{'maskable-' if maskable else ''}{size}.png"
render(size, maskable).save(args.out / name)
for size in (16, 32, 48):
render(size).save(args.out / f"favicon-{size}.png")
render(180).save(args.out / "apple-touch-icon.png")
# BMP-backed frames also work in older ICO readers; PNG favicons ship separately.
render(48).save(args.out / "favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)], bitmap_format="bmp")
print(f"Exported 9 icon files to {args.out}")
if __name__ == "__main__":
main()