0.2.3: 重绘系列通用安装图标并记录功能审核决定(#413、#77/#408、#444、#407)
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 886 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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 |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 308 B |
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 12 KiB |
@@ -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" />
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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' };
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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()
|
||||