Stickers: load every shipped pack and draw its art

The picker shows each pack under `stickers/` as its own section: the core
pack first, then FlameNet's 24 drawings (`flamenet.retro`). A sticker draws
its image when it has one, its glyph when it has none or the image will not
load, and its pack's fallback when this build does not know the id. A pack
this build does not ship draws the core fallback, as docs/STICKERS.md
describes.

`Resources/stickers` is now a folder in the bundle, not a group, so each
pack's PNGs sit in a folder named for the pack and two drawings with the same
file name cannot collide at the bundle root. The SVG sources and the render
script are in `Art/stickers/`; the PNGs are the bytes the Android app ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-03 22:46:01 -04:00
co-authored by Claude Opus 5.5
parent 3666a1b771
commit a13c7831f5
58 changed files with 1032 additions and 49 deletions
+3
View File
@@ -0,0 +1,3 @@
FlameNet sticker pack `flamenet.retro`: 24 original drawings in `svg/`, rendered to the 288x288 transparent PNGs the app ships in `Resources/stickers/flamenet.retro/`, in the picker order of `Resources/stickers/flamenet.retro.json`. The Android app ships the same PNG bytes.
To re-render after editing an SVG, run this from this folder with Node 22 or later: `MANIFEST=../../Resources/stickers/flamenet.retro.json OUT=../../Resources/stickers/flamenet.retro PW_DIR=<folder with node_modules/playwright-core> CHROME=<Chromium binary> node render.mjs`. It also writes two contact sheets here, which are not shipped.
+191
View File
@@ -0,0 +1,191 @@
// Renders the FlameNet sticker pack: svg/<id>.svg -> flamenet.retro/<id>.png
// (288x288, transparent, 8-bit palette PNG) and the two contact sheets.
// Picker order and names come from flamenet.retro.json.
//
// Needs Node 22 or later and playwright-core with a Chromium build:
// PW_DIR=<folder whose node_modules has playwright-core> \
// CHROME=<chromium or chrome-headless-shell binary> node render.mjs
// Without PW_DIR it imports playwright-core from here; without CHROME it
// uses the browser Playwright installed. MANIFEST and OUT point it at other
// copies of flamenet.retro.json and the PNG folder; both default to here.
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SIZE = 288;
const SHEET = 96;
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.PW_DIR
? require.resolve("playwright-core", { paths: [process.env.PW_DIR] })
: "playwright-core");
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST || path.join(HERE, "flamenet.retro.json"), "utf8"));
// The sources have to render the same everywhere: no ids, no references,
// no fonts, one canvas size.
function check(id, svg) {
const bad = [
[/\sid\s*=/, "an id"],
[/href|url\(/, "a reference"],
[/<(text|tspan|image|style|script|foreignObject)\b|font-/, "text, fonts or embedded content"],
].find(([re]) => re.test(svg));
if (bad) throw new Error(`${id}.svg has ${bad[1]}`);
if (!/^<svg [^>]*viewBox="0 0 200 200"/.test(svg)) throw new Error(`${id}.svg needs viewBox="0 0 200 200"`);
}
// 8-bit palette PNG.
// Colours compared premultiplied, so faint edge pixels count for little.
const unpack = k => [k >>> 24, (k >>> 16) & 255, (k >>> 8) & 255, k & 255];
const pm = k => { const [r, g, b, a] = unpack(k); return [r * a / 255, g * a / 255, b * a / 255, a]; };
const pack = (r, g, b, a) => ((r << 24) | (g << 16) | (b << 8) | a) >>> 0;
// Splits the leftover edge colours into n boxes, each time cutting the box
// with the largest squared error along its widest channel.
function medianCut(entries, n) {
const sse = box => {
let w = 0; const s = [0, 0, 0, 0], q = [0, 0, 0, 0];
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) { s[d] += p.v[d] * p.c; q[d] += p.v[d] * p.v[d] * p.c; } }
const per = q.map((x, d) => x - s[d] * s[d] / w);
return { total: per.reduce((a, b) => a + b, 0), dim: per.indexOf(Math.max(...per)), w };
};
let boxes = [entries.map(([k, c]) => ({ k, c, v: pm(k) }))].filter(b => b.length);
while (boxes.length && boxes.length < n) {
let best = -1, bestErr = 0, dim = 0;
boxes.forEach((b, i) => {
if (b.length < 2) return;
const e = sse(b);
if (e.total > bestErr) { bestErr = e.total; best = i; dim = e.dim; }
});
if (best < 0) break;
const box = boxes[best].slice().sort((a, b) => a.v[dim] - b.v[dim] || a.k - b.k);
const half = box.reduce((a, p) => a + p.c, 0) / 2;
let acc = 0, cut = 1;
for (let i = 0; i < box.length - 1; i++) { acc += box[i].c; if (acc >= half) { cut = i + 1; break; } }
boxes.splice(best, 1, box.slice(0, cut), box.slice(cut));
}
return boxes.map(box => {
let w = 0; const s = [0, 0, 0, 0];
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) s[d] += p.v[d] * p.c; }
const a = Math.round(s[3] / w);
if (a === 0) return 0;
const ch = d => Math.max(0, Math.min(255, Math.round((s[d] / w) * 255 / (s[3] / w))));
return pack(ch(0), ch(1), ch(2), a);
});
}
function chunk(type, data) {
const head = Buffer.alloc(4); head.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(body) >>> 0);
return Buffer.concat([head, body, crc]);
}
function encodePalettePNG(rgba, w, h) {
const n = w * h, keys = new Uint32Array(n), hist = new Map();
for (let i = 0; i < n; i++) {
const a = rgba[4 * i + 3];
const k = a === 0 ? 0 : pack(rgba[4 * i], rgba[4 * i + 1], rgba[4 * i + 2], a);
keys[i] = k;
hist.set(k, (hist.get(k) || 0) + 1);
}
const byCount = [...hist.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
let palette;
if (byCount.length <= 256) {
palette = byCount.map(e => e[0]);
} else {
// Flat fills keep their exact colour; only edge blends are merged.
const fixed = byCount.filter(e => e[1] >= 24).slice(0, 128).map(e => e[0]);
const taken = new Set(fixed);
palette = [...new Set(fixed.concat(medianCut(byCount.filter(e => !taken.has(e[0])), 256 - fixed.length)))];
}
// Translucent entries first, so tRNS can stop at the last one.
palette.sort((a, b) => ((a & 255) === 255) - ((b & 255) === 255) || (a & 255) - (b & 255) || a - b);
const pv = palette.map(pm);
const index = new Map();
for (const k of hist.keys()) {
const v = pm(k);
let best = 0, bd = Infinity;
for (let j = 0; j < pv.length; j++) {
const d = (v[0] - pv[j][0]) ** 2 + (v[1] - pv[j][1]) ** 2 + (v[2] - pv[j][2]) ** 2 + 2 * (v[3] - pv[j][3]) ** 2;
if (d < bd) { bd = d; best = j; }
}
index.set(k, best);
}
const raw = Buffer.alloc(h * (w + 1));
for (let y = 0; y < h; y++) {
raw[y * (w + 1)] = 0;
for (let x = 0; x < w; x++) raw[y * (w + 1) + 1 + x] = index.get(keys[y * w + x]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 3;
const plte = Buffer.alloc(palette.length * 3), alpha = [];
palette.forEach((k, j) => {
const [r, g, b, a] = unpack(k);
plte[3 * j] = r; plte[3 * j + 1] = g; plte[3 * j + 2] = b;
if (a < 255) alpha.push(a);
});
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", ihdr),
chunk("PLTE", plte),
...(alpha.length ? [chunk("tRNS", Buffer.from(alpha))] : []),
chunk("IDAT", zlib.deflateSync(raw, { level: 9, memLevel: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Render.
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
const page = await browser.newPage({ viewport: { width: SIZE, height: SIZE }, deviceScaleFactor: 1 });
const outDir = process.env.OUT || path.join(HERE, "flamenet.retro");
fs.mkdirSync(outDir, { recursive: true });
const pngs = {};
for (const s of manifest.stickers) {
const svg = fs.readFileSync(path.join(HERE, "svg", `${s.id}.svg`), "utf8").trim();
check(s.id, svg);
if (s.image !== `${s.id}.png`) throw new Error(`${s.id}: image should be ${s.id}.png`);
const sized = svg.replace("<svg ", `<svg width="${SIZE}" height="${SIZE}" `);
const b64 = await page.evaluate(async ({ sized, size }) => {
const img = new Image();
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(sized);
await img.decode();
const canvas = new OffscreenCanvas(size, size);
const g = canvas.getContext("2d");
g.drawImage(img, 0, 0, size, size);
const d = g.getImageData(0, 0, size, size).data;
let bin = "";
for (let i = 0; i < d.length; i += 0x8000) bin += String.fromCharCode.apply(null, d.subarray(i, i + 0x8000));
return btoa(bin);
}, { sized, size: SIZE });
const png = encodePalettePNG(Buffer.from(b64, "base64"), SIZE, SIZE);
fs.writeFileSync(path.join(outDir, s.image), png);
pngs[s.id] = png;
console.log(`${s.image.padEnd(22)} ${String(png.length).padStart(6)} bytes`);
}
// Contact sheets, 96 px on white and on black.
const esc = t => t.replace(/[&<>"]/g, c => `&#${c.charCodeAt(0)};`);
for (const [name, bg, fg] of [["sheet-light.png", "#ffffff", "#333333"], ["sheet-dark.png", "#000000", "#e8e8e8"]]) {
const cells = manifest.stickers.map(s =>
`<figure><img src="data:image/png;base64,${pngs[s.id].toString("base64")}" width="${SHEET}" height="${SHEET}" alt=""><figcaption>${esc(s.name)}</figcaption></figure>`).join("");
await page.setViewportSize({ width: 6 * 124 + 24, height: 200 });
await page.setContent(`<!doctype html><html><head><style>
body { margin: 0; padding: 12px; background: ${bg}; color: ${fg}; font: 12px -apple-system, Helvetica, Arial, sans-serif; }
main { display: grid; grid-template-columns: repeat(6, 124px); }
figure { margin: 0; padding: 8px 0 10px; text-align: center; }
img { display: block; margin: 0 auto 4px; }
</style></head><body><main>${cells}</main></body></html>`);
await page.screenshot({ path: path.join(HERE, name), fullPage: true });
console.log(`${name} written`);
}
await browser.close();
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-8 100 100)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z"/>
<path d="M92 150 L100 139 L108 150 Z"/>
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none"/>
</g>
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none" stroke="#333" stroke-width="2.5" stroke-linecap="round"/>
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z" fill="#FF3B3B" stroke="#333" stroke-width="2"/>
<path d="M92 150 L100 139 L108 150 Z" fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<ellipse cx="76" cy="50" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(20 76 50)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 938 B

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 108)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<circle cx="100" cy="24" r="7"/>
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke-width="17"/>
<rect x="24" y="64" width="152" height="88" rx="10"/>
</g>
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke="#6B3E1F" stroke-width="3" stroke-linecap="round"/>
<circle cx="100" cy="24" r="7" fill="#B8B8B8" stroke="#333" stroke-width="2"/>
<rect x="24" y="64" width="152" height="88" rx="10" fill="#FFCC00" stroke="#333" stroke-width="2"/>
<rect x="31" y="71" width="138" height="74" rx="6" fill="none" stroke="#E6A800" stroke-width="2"/>
<circle cx="44" cy="68" r="3" fill="#6B3E1F"/>
<circle cx="156" cy="68" r="3" fill="#6B3E1F"/>
<g fill="#003399" fill-rule="evenodd">
<path transform="translate(46.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
<path transform="translate(84.5 0)" d="M0 131 V85 H18 C26 85 30 90.5 30 98 C30 104 27 108 22.5 110 L31 131 H19.5 L12 112.5 H11 V131 Z M11 94 H17 C19.5 94 20 96 20 98.5 C20 101 19.5 103.5 17 103.5 H11 Z"/>
<path transform="translate(122.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<ellipse cx="100" cy="174" rx="80" ry="12"/>
<rect x="26" y="92" width="148" height="80" rx="10"/>
<rect x="57" y="56" width="10" height="40"/>
<rect x="95" y="56" width="10" height="40"/>
<rect x="133" y="56" width="10" height="40"/>
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<ellipse cx="100" cy="174" rx="80" ry="12" fill="#CCE6FF"/>
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="#FF99BB"/>
<rect x="30" y="146" width="140" height="8" fill="#FFFDF5" stroke-width="1.5"/>
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="none"/>
<path d="M26 102 Q26 92 36 92 H164 Q174 92 174 102 V112 C174 120 160 120 160 112 C160 108 152 108 150 112 V124 C150 132 136 132 136 124 V114 C136 108 124 108 120 114 C118 120 108 120 106 114 C104 108 96 108 94 114 V128 C94 136 80 136 80 128 V114 C80 108 70 108 66 114 C64 120 54 120 52 114 C50 108 42 108 40 114 V120 C40 128 26 128 26 120 Z" fill="#FFFDF5"/>
<rect x="57" y="62" width="10" height="30" fill="#3366FF"/>
<rect x="95" y="62" width="10" height="30" fill="#FFCC00"/>
<rect x="133" y="62" width="10" height="30" fill="#33AA33"/>
</g>
<g stroke="#333" stroke-width="2.5" stroke-linecap="round">
<path d="M62 62 V55 M100 62 V55 M138 62 V55"/>
</g>
<g fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
</g>
<g fill="#FFCC00">
<ellipse cx="62" cy="46" rx="3.5" ry="5"/>
<ellipse cx="100" cy="46" rx="3.5" ry="5"/>
<ellipse cx="138" cy="46" rx="3.5" ry="5"/>
</g>
<g>
<rect x="46" y="135" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(30 50 137)"/>
<rect x="112" y="136" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-25 116 138)"/>
<rect x="152" y="134" width="9" height="4" rx="2" fill="#FFFFFF" transform="rotate(40 156 136)"/>
<rect x="60" y="160" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-30 64 162)"/>
<rect x="96" y="161" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(15 100 163)"/>
<rect x="136" y="160" width="9" height="4" rx="2" fill="#33AA33" transform="rotate(-35 140 162)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z"/>
<path d="M100 60 V74"/>
<rect x="64" y="72" width="72" height="100" rx="5"/>
<ellipse cx="100" cy="176" rx="62" ry="14"/>
</g>
<path d="M100 60 V74" stroke="#333" stroke-width="3.5" stroke-linecap="round"/>
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z" fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M100 30 C109 43 109 54 100 60 C91 54 91 43 100 30 Z" fill="#FFCC00"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<ellipse cx="100" cy="177" rx="62" ry="13" fill="#E6A800"/>
<ellipse cx="100" cy="171" rx="62" ry="13" fill="#FFCC00"/>
<rect x="64" y="72" width="72" height="100" rx="5" fill="#FFF6CC"/>
<path d="M64 77 Q64 72 69 72 H131 Q136 72 136 77 V92 C136 98 127 98 127 92 C127 87 120 87 120 93 V104 C120 113 108 113 108 104 V93 C108 87 99 87 97 93 C95 99 86 99 86 93 C86 88 79 87 77 93 V98 C77 105 64 105 64 98 Z" fill="#FFFFFF"/>
</g>
<rect x="73" y="112" width="8" height="50" rx="4" fill="#fff" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z"/>
<ellipse cx="90" cy="66" rx="50" ry="10"/>
<path d="M140 86 C178 84 180 138 140 136" fill="none" stroke-width="35"/>
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" fill="none" stroke-width="19"/>
</g>
<g fill="none" stroke-linecap="round">
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" stroke="#8C9BB0" stroke-width="5"/>
<path d="M140 86 C178 84 180 138 140 136" stroke="#333" stroke-width="21"/>
<path d="M140 86 C178 84 180 138 140 136" stroke="#CC0000" stroke-width="17"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z" fill="#CC0000"/>
<ellipse cx="90" cy="66" rx="50" ry="10" fill="#CC0000"/>
<ellipse cx="90" cy="67" rx="43" ry="6.5" fill="#6B3E1F"/>
</g>
<rect x="40" y="88" width="100" height="10" fill="#fff"/>
<path d="M40 70 V146 Q40 176 70 176 H110 Q140 176 140 146 V70" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M40 88 H140 M40 98 H140" stroke="#333" stroke-width="1.5"/>
<rect x="50" y="106" width="8" height="52" rx="4" fill="#fff" opacity=".4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="24" y="20" width="152" height="126" rx="16"/>
<path d="M80 144 H120 L126 160 H74 Z"/>
<rect x="46" y="158" width="108" height="20" rx="9"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M80 144 H120 L126 160 H74 Z" fill="#D6CFB6"/>
<rect x="46" y="158" width="108" height="20" rx="9" fill="#E8E2CC"/>
<rect x="24" y="20" width="152" height="126" rx="16" fill="#E8E2CC"/>
<rect x="40" y="34" width="120" height="88" rx="14" fill="#003399"/>
<rect x="50" y="129" width="18" height="8" rx="3" fill="#D6CFB6" stroke-width="1.5"/>
<circle cx="148" cy="133" r="4.5" fill="#33AA33" stroke-width="1.5"/>
</g>
<rect x="50" y="42" width="28" height="6" rx="3" fill="#fff" opacity=".35"/>
<g fill="#FFCC00">
<rect x="73" y="56" width="12" height="21" rx="6"/>
<rect x="115" y="56" width="12" height="21" rx="6"/>
</g>
<path d="M72 90 Q100 114 128 90" fill="none" stroke="#FFCC00" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<circle cx="46" cy="38" r="13"/>
<circle cx="154" cy="38" r="13"/>
<rect x="40" y="46" width="12" height="130"/>
<rect x="148" y="46" width="12" height="130"/>
<rect x="28" y="168" width="36" height="12" rx="3"/>
<rect x="136" y="168" width="36" height="12" rx="3"/>
<rect x="24" y="66" width="152" height="80"/>
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22" stroke-width="17" stroke-linecap="round"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="40" y="50" width="12" height="122" fill="#E6E6E6"/>
<rect x="148" y="50" width="12" height="122" fill="#E6E6E6"/>
<rect x="28" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
<rect x="136" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
<rect x="24" y="66" width="152" height="32" fill="#FFCC00"/>
<rect x="24" y="116" width="152" height="30" fill="#FFCC00"/>
</g>
<g fill="#333">
<polygon points="24,88 24,72 30,66 46,66"/>
<polygon points="46,98 30,98 62,66 78,66"/>
<polygon points="78,98 62,98 94,66 110,66"/>
<polygon points="110,98 94,98 126,66 142,66"/>
<polygon points="142,98 126,98 158,66 174,66"/>
<polygon points="158,98 176,80 176,96 174,98"/>
<polygon points="24,138 24,122 30,116 46,116"/>
<polygon points="48,146 32,146 62,116 78,116"/>
<polygon points="80,146 64,146 94,116 110,116"/>
<polygon points="112,146 96,146 126,116 142,116"/>
<polygon points="144,146 128,146 158,116 174,116"/>
<polygon points="160,146 176,130 176,146"/>
</g>
<g fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="24" y="66" width="152" height="32"/>
<rect x="24" y="116" width="152" height="30"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="38" y="44" width="16" height="8" rx="2" fill="#555"/>
<rect x="146" y="44" width="16" height="8" rx="2" fill="#555"/>
<path d="M34 44 A12 12 0 0 1 58 44 Z" fill="#FF9900"/>
<path d="M142 44 A12 12 0 0 1 166 44 Z" fill="#FF9900"/>
</g>
<g fill="#fff" opacity=".7">
<ellipse cx="41" cy="37" rx="2.5" ry="4"/>
<ellipse cx="149" cy="37" rx="2.5" ry="4"/>
</g>
<g stroke="#FF9900" stroke-width="3" stroke-linecap="round">
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#FF5A1F" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M100 180 C76 180 62 162 62 140 C62 114 84 96 106 58 C102 84 138 108 138 140 C138 162 122 180 100 180 Z" fill="#FF9900"/>
<path d="M100 176 C86 176 78 166 78 152 C78 134 92 124 102 104 C110 124 122 136 122 152 C122 166 114 176 100 176 Z" fill="#FFCC00"/>
<ellipse cx="100" cy="160" rx="9" ry="11" fill="#FFF3B0"/>
</svg>

After

Width:  |  Height:  |  Size: 924 B

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 100)">
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#003399"/>
<rect x="58" y="28" width="80" height="48" fill="#C9D1DE"/>
<rect x="110" y="37" width="16" height="30" rx="2" fill="#003399"/>
<rect x="46" y="96" width="108" height="76" rx="4" fill="#FFFFFF"/>
<path d="M46 106 V100 Q46 96 50 96 H150 Q154 96 154 100 V106 Z" fill="#FF3B3B"/>
</g>
<g fill="#B8C8F0">
<rect x="56" y="120" width="88" height="5" rx="2.5"/>
<rect x="56" y="136" width="88" height="5" rx="2.5"/>
<rect x="56" y="152" width="58" height="5" rx="2.5"/>
</g>
<rect x="34" y="38" width="6" height="46" rx="3" fill="#fff" opacity=".35"/>
<rect x="64" y="34" width="5" height="36" rx="2.5" fill="#fff" opacity=".6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<circle cx="100" cy="48" r="30"/>
<circle cx="62" cy="70" r="30"/>
<circle cx="138" cy="70" r="30"/>
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)"/>
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)"/>
<path d="M62 90 L100 112 L138 90 Z"/>
<path d="M40 106 H160 L104 188 H96 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)" fill="#43A047"/>
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)" fill="#43A047"/>
</g>
<g stroke="#2E7D32" stroke-width="5" stroke-linecap="round">
<path d="M62 92 L88 110 M100 70 V110 M138 92 L112 110"/>
</g>
<g stroke="#333" stroke-width="2">
<g fill="#FFFFFF">
<circle cx="100" cy="31" r="13"/><circle cx="114.7" cy="39.5" r="13"/><circle cx="114.7" cy="56.5" r="13"/><circle cx="100" cy="65" r="13"/><circle cx="85.3" cy="56.5" r="13"/><circle cx="85.3" cy="39.5" r="13"/>
</g>
<circle cx="100" cy="48" r="9" fill="#FF9900"/>
<g fill="#FF7AA2">
<circle cx="62" cy="53" r="13"/><circle cx="76.7" cy="61.5" r="13"/><circle cx="76.7" cy="78.5" r="13"/><circle cx="62" cy="87" r="13"/><circle cx="47.3" cy="78.5" r="13"/><circle cx="47.3" cy="61.5" r="13"/>
</g>
<circle cx="62" cy="70" r="9" fill="#FFCC00"/>
<g fill="#B388FF">
<circle cx="138" cy="53" r="13"/><circle cx="152.7" cy="61.5" r="13"/><circle cx="152.7" cy="78.5" r="13"/><circle cx="138" cy="87" r="13"/><circle cx="123.3" cy="78.5" r="13"/><circle cx="123.3" cy="61.5" r="13"/>
</g>
<circle cx="138" cy="70" r="9" fill="#FFCC00"/>
</g>
<path d="M40 106 H160 L104 188 H96 Z" fill="#CCE6FF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M112 106 H160 L104 188 Z" fill="#B3D6FA"/>
<path d="M40 106 H160 L104 188 H96 Z" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<g fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M97 152 L87 173 L95 171 L100 157 Z"/>
<path d="M103 152 L113 173 L105 171 L100 157 Z"/>
<ellipse cx="86" cy="147" rx="14" ry="8" transform="rotate(-20 86 147)"/>
<ellipse cx="114" cy="147" rx="14" ry="8" transform="rotate(20 114 147)"/>
<circle cx="100" cy="150" r="6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="38" y="96" width="124" height="84" rx="4"/>
<rect x="30" y="72" width="140" height="28" rx="4"/>
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z"/>
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="38" y="96" width="124" height="84" rx="4" fill="#3366FF"/>
<g fill="#fff" stroke="none" opacity=".45">
<circle cx="58" cy="120" r="6"/><circle cx="70" cy="156" r="6"/><circle cx="132" cy="134" r="6"/><circle cx="148" cy="164" r="6"/>
</g>
<rect x="88" y="100" width="24" height="80" fill="#FFCC00"/>
<rect x="30" y="72" width="140" height="28" rx="4" fill="#5C85FF"/>
<rect x="86" y="72" width="28" height="28" fill="#FFCC00"/>
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z" fill="#FFCC00"/>
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z" fill="#FFCC00"/>
<path d="M96 69 C86 56 70 54 69 62 C69 68 84 70 96 69 Z" fill="#E6A800" stroke="none"/>
<path d="M104 69 C114 56 130 54 131 62 C131 68 116 70 104 69 Z" fill="#E6A800" stroke="none"/>
<ellipse cx="100" cy="70" rx="10" ry="9" fill="#FFCC00"/>
</g>
<rect x="36" y="76" width="40" height="5" rx="2.5" fill="#fff" opacity=".45"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 100)">
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#E0245E" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<ellipse cx="48" cy="52" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(-35 48 52)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 561 B

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(8 100 100)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="38" y="18" width="124" height="18" rx="6"/>
<rect x="38" y="164" width="124" height="18" rx="6"/>
<rect x="46" y="36" width="9" height="128"/>
<rect x="145" y="36" width="9" height="128"/>
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z"/>
</g>
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="#E6F2FF"/>
<path d="M76 72 H124 C116 82 106 88 104 98 H96 C94 88 84 82 76 72 Z" fill="#FFCC00"/>
<path d="M100 98 V150" stroke="#FFCC00" stroke-width="3"/>
<path d="M68 163 C70 147 88 139 100 137 C112 139 130 147 132 163 Z" fill="#FFCC00"/>
<path d="M73 44 C73 60 80 70 88 78" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="none"/>
<rect x="46" y="36" width="9" height="128" fill="#A0632F"/>
<rect x="145" y="36" width="9" height="128" fill="#A0632F"/>
<rect x="38" y="18" width="124" height="18" rx="6" fill="#C8874A"/>
<rect x="38" y="164" width="124" height="18" rx="6" fill="#C8874A"/>
</g>
<g fill="#fff" opacity=".35">
<rect x="46" y="22" width="40" height="4" rx="2"/>
<rect x="46" y="168" width="40" height="4" rx="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-5 100 100)">
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#FFFFFF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<g fill="#CC0000" fill-rule="evenodd">
<polygon transform="translate(44 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
<path transform="translate(79 0)" d="M18 59 H24 A18 18 0 0 1 42 77 V91 A18 18 0 0 1 24 109 H18 A18 18 0 0 1 0 91 V77 A18 18 0 0 1 18 59 Z M19 71 H23 A7 7 0 0 1 30 78 V90 A7 7 0 0 1 23 97 H19 A7 7 0 0 1 12 90 V78 A7 7 0 0 1 19 71 Z"/>
<polygon transform="translate(128 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 933 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-8 100 108)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="22" y="56" width="156" height="104" rx="8"/>
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="22" y="56" width="156" height="104" rx="8" fill="#FFE0EC"/>
<path d="M25 156 L84 112 M175 156 L116 112" fill="none" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22 64 Q22 56 30 56 H170 Q178 56 178 64 L100 120 Z" fill="#FFC9DC"/>
<path d="M79.6 103.8 C79.6 90.2 100 90.2 100 103.8 C100 90.2 120.4 90.2 120.4 103.8 C120.4 117.4 100 127.6 100 134.4 C100 127.6 79.6 117.4 79.6 103.8 Z" fill="#E0245E"/>
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z" fill="#FF99BB"/>
</g>
<ellipse cx="88" cy="100" rx="3" ry="5" fill="#fff" opacity=".6" transform="rotate(-30 88 100)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z"/>
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
</g>
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z" fill="#FFE680" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<ellipse cx="30" cy="100" rx="5" ry="20" fill="#fff" opacity=".6" transform="rotate(8 30 100)"/>
<g fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round">
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="translate(-15.3 -15.3) scale(1.15)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
<rect x="73" y="54" width="10" height="96"/>
<rect x="149" y="38" width="10" height="96"/>
<polygon points="73,54 159,38 159,62 73,78"/>
</g>
<g fill="#333" stroke="#333" stroke-width="3.5" stroke-linejoin="round">
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
<rect x="73" y="54" width="10" height="96"/>
<rect x="149" y="38" width="10" height="96"/>
<polygon points="73,54 159,38 159,62 73,78"/>
</g>
<g fill="#5B2C8F">
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
<rect x="73" y="54" width="10" height="96"/>
<rect x="149" y="38" width="10" height="96"/>
<polygon points="73,54 159,38 159,62 73,78"/>
</g>
<g fill="#fff" opacity=".55">
<ellipse cx="56" cy="145" rx="7" ry="3.5" transform="rotate(-22 56 145)"/>
<ellipse cx="132" cy="129" rx="7" ry="3.5" transform="rotate(-22 132 129)"/>
<polygon points="80,58 152,45 152,49 80,62"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-10 100 100)">
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<g fill="#CC0000">
<polygon transform="translate(37.5 0)" points="0,121 0,79 10.4,79 22,101.6 22,79 32,79 32,121 21.6,121 10,98.4 10,121"/>
<polygon transform="translate(73.5 0)" points="0,79 24,79 24,88 10,88 10,95.5 21,95.5 21,104.5 10,104.5 10,112 24,112 24,121 0,121"/>
<polygon transform="translate(101.5 0)" points="0,79 11,79 14.9,99.3 17.5,91 28.5,91 31.1,99.3 35,79 46,79 38,121 27,121 23,108.4 19,121 8,121"/>
<polygon transform="translate(151.5 0)" points="0,79 11,79 9,107 2,107"/>
<rect x="152" y="111" width="10" height="10" rx="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<polygon points="186,18 30,84 104,122"/>
<polygon points="186,18 104,122 130,110"/>
<polygon points="104,122 130,110 112,156"/>
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke-width="18.5"/>
</g>
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke="#3366FF" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="8 8"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<polygon points="104,122 130,110 112,156" fill="#8FA8E0"/>
<polygon points="186,18 104,122 130,110" fill="#B8C8F0"/>
<polygon points="186,18 30,84 104,122" fill="#EEF2FF"/>
</g>
<path d="M164 32 L56 80" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 914 B

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-10 100 112)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z"/>
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z" fill="#FFCC00"/>
<g fill="#FFE680" stroke="none">
<circle cx="56" cy="80" r="4.5"/><circle cx="146" cy="76" r="3.5"/><circle cx="108" cy="158" r="3.5"/><circle cx="98" cy="72" r="3"/>
</g>
<circle cx="72" cy="90" r="13" fill="#CC0000"/>
<circle cx="126" cy="92" r="13" fill="#CC0000"/>
<circle cx="99" cy="130" r="12" fill="#CC0000"/>
<g fill="#33AA33" stroke-width="1.5">
<ellipse cx="100" cy="98" rx="7" ry="3.5" transform="rotate(30 100 98)"/>
<ellipse cx="80" cy="120" rx="6" ry="3" transform="rotate(-40 80 120)"/>
<ellipse cx="116" cy="114" rx="6" ry="3" transform="rotate(50 116 114)"/>
</g>
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z" fill="#E0A050"/>
</g>
<g fill="#fff" opacity=".5">
<circle cx="67" cy="85" r="3.5"/><circle cx="121" cy="87" r="3.5"/><circle cx="94" cy="125" r="3"/>
</g>
<path d="M44 54 Q100 40 156 54" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 106)">
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<polygon points="100,60 112.9,86.2 141.8,90.4 120.9,110.8 125.9,139.6 100,126 74.1,139.6 79.1,110.8 58.2,90.4 87.1,86.2" fill="#FFE066"/>
<ellipse cx="93" cy="50" rx="4.5" ry="13" fill="#fff" opacity=".6" transform="rotate(25 93 50)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 730 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="none" stroke-linecap="round">
<circle cx="100" cy="100" r="62" fill="#fff" stroke="#fff" stroke-width="14"/>
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#fff" stroke-width="27"/>
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#333" stroke-width="13"/>
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#FF9900" stroke-width="9"/>
</g>
<circle cx="100" cy="100" r="52" fill="#FFCC00" stroke="#333" stroke-width="2"/>
<ellipse cx="74" cy="68" rx="11" ry="5.5" fill="#fff" opacity=".6" transform="rotate(-38 74 68)"/>
<circle cx="70" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
<circle cx="130" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
<ellipse cx="83" cy="92" rx="5" ry="8" fill="#333"/>
<ellipse cx="117" cy="92" rx="5" ry="8" fill="#333"/>
<path d="M78 110 Q100 130 122 110" fill="none" stroke="#333" stroke-width="4.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="16" stroke-linejoin="round">
<rect x="18" y="96" width="40" height="88" rx="6"/>
<rect x="50" y="92" width="70" height="88" rx="18"/>
<rect x="92" y="88" width="68" height="26" rx="13"/>
<rect x="92" y="112" width="74" height="26" rx="13"/>
<rect x="92" y="136" width="70" height="24" rx="12"/>
<rect x="92" y="158" width="62" height="22" rx="11"/>
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
</g>
<g fill="#333" stroke="#333" stroke-width="4" stroke-linejoin="round">
<rect x="50" y="92" width="70" height="88" rx="18"/>
<rect x="92" y="88" width="68" height="26" rx="13"/>
<rect x="92" y="112" width="74" height="26" rx="13"/>
<rect x="92" y="136" width="70" height="24" rx="12"/>
<rect x="92" y="158" width="62" height="22" rx="11"/>
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
</g>
<g fill="#FFCC00">
<rect x="50" y="92" width="70" height="88" rx="18"/>
<rect x="92" y="88" width="68" height="26" rx="13"/>
<rect x="92" y="112" width="74" height="26" rx="13"/>
<rect x="92" y="136" width="70" height="24" rx="12"/>
<rect x="92" y="158" width="62" height="22" rx="11"/>
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
</g>
<g fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
<path d="M108 113 H152 M108 137 H155 M108 159 H149"/>
<path d="M66 100 C76 104 90 103 98 96"/>
</g>
<ellipse cx="73" cy="52" rx="4" ry="13" fill="#fff" opacity=".55" transform="rotate(18 73 52)"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="18" y="96" width="40" height="88" rx="6" fill="#3366FF"/>
<path d="M46 96 H52 Q58 96 58 102 V178 Q58 184 52 184 H46 Z" fill="#003399"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" fill="none" stroke-width="29"/>
<rect x="44" y="24" width="112" height="12" rx="5"/>
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z"/>
<path d="M92 118 H108 L110 140 H90 Z"/>
<rect x="78" y="138" width="44" height="10" rx="4"/>
<rect x="56" y="148" width="88" height="34" rx="5"/>
</g>
<g fill="none" stroke-linecap="round">
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#333" stroke-width="15"/>
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#FFCC00" stroke-width="11"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M92 118 H108 L110 140 H90 Z" fill="#E6B800"/>
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z" fill="#FFCC00"/>
<rect x="44" y="24" width="112" height="12" rx="5" fill="#FFE066"/>
<rect x="78" y="138" width="44" height="10" rx="4" fill="#FFCC00"/>
<rect x="56" y="148" width="88" height="34" rx="5" fill="#003399"/>
<rect x="76" y="157" width="48" height="15" rx="3" fill="#FFCC00" stroke-width="1.5"/>
<polygon points="100,53 105,63.1 116.2,64.7 108.1,72.6 110,83.8 100,78.5 90,83.8 91.9,72.6 83.8,64.7 95,63.1" fill="#FFFFFF" stroke-width="1.5"/>
</g>
<ellipse cx="66" cy="66" rx="5.5" ry="20" fill="#fff" opacity=".5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"pack": "flamenet.retro",
"name": "FlameNet",
"fallback": "🙂",
"stickers": [
{ "id": "flame", "glyph": "🔥", "name": "Flame", "image": "flame.png" },
{ "id": "heart", "glyph": "❤️", "name": "Heart", "image": "heart.png" },
{ "id": "thumbs-up", "glyph": "👍", "name": "Thumbs up", "image": "thumbs-up.png" },
{ "id": "lol", "glyph": "😂", "name": "Laughing", "image": "lol.png" },
{ "id": "brb", "glyph": "🔙", "name": "Back soon", "image": "brb.png" },
{ "id": "new", "glyph": "🆕", "name": "New", "image": "new.png" },
{ "id": "star", "glyph": "⭐", "name": "Star", "image": "star.png" },
{ "id": "trophy", "glyph": "🏆", "name": "Trophy", "image": "trophy.png" },
{ "id": "cake", "glyph": "🎂", "name": "Birthday cake", "image": "cake.png" },
{ "id": "balloon", "glyph": "🎈", "name": "Balloon", "image": "balloon.png" },
{ "id": "gift", "glyph": "🎁", "name": "Gift", "image": "gift.png" },
{ "id": "flowers", "glyph": "💐", "name": "Flowers", "image": "flowers.png" },
{ "id": "love-letter", "glyph": "💌", "name": "Love letter", "image": "love-letter.png" },
{ "id": "music", "glyph": "🎶", "name": "Music", "image": "music.png" },
{ "id": "sun", "glyph": "☀️", "name": "Sun", "image": "sun.png" },
{ "id": "moon", "glyph": "🌙", "name": "Moon", "image": "moon.png" },
{ "id": "coffee", "glyph": "☕", "name": "Coffee", "image": "coffee.png" },
{ "id": "pizza", "glyph": "🍕", "name": "Pizza", "image": "pizza.png" },
{ "id": "candle", "glyph": "🕯️", "name": "Candle", "image": "candle.png" },
{ "id": "hourglass", "glyph": "⏳", "name": "Hourglass", "image": "hourglass.png" },
{ "id": "computer", "glyph": "🖥️", "name": "Computer", "image": "computer.png" },
{ "id": "floppy", "glyph": "💾", "name": "Floppy disk", "image": "floppy.png" },
{ "id": "construction", "glyph": "🚧", "name": "Under construction", "image": "construction.png" },
{ "id": "paper-plane", "glyph": "✈️", "name": "Paper plane", "image": "paper-plane.png" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+3 -3
View File
@@ -534,12 +534,12 @@ final class ChatViewModel: ObservableObject {
/// Whether this thread can carry reactions at all.
var canReact: Bool { inbox != nil }
/// Send a sticker into this conversation.
func sendSticker(_ stickerID: String) async {
/// Send a sticker into this conversation, from whichever pack it is in.
func sendSticker(_ sticker: StickerPack.Sticker) async {
guard let inbox else { return }
do {
try await inbox.sendSticker(
pack: StickerPack.core, id: stickerID,
pack: sticker.pack, id: sticker.id,
to: buddy.id)
} catch {
errorMessage =
+1 -1
View File
@@ -956,7 +956,7 @@ struct ChatView: View {
if showingStickers {
StickerPicker { sticker in
showingStickers = false
Task { await model.sendSticker(sticker.id) }
Task { await model.sendSticker(sticker) }
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
+2 -2
View File
@@ -145,9 +145,9 @@ struct MessageBubble: View {
}
// A sticker has no bubble.
if let stk = message.sticker {
Text(StickerPack.glyph(pack: stk.pack, id: stk.id))
.font(.system(size: 64))
StickerView(pack: stk.pack, id: stk.id)
.padding(.horizontal, 4)
.accessibilityElement(children: .ignore)
.accessibilityLabel(
"\(StickerPack.name(pack: stk.pack, id: stk.id)) sticker")
} else {
+26 -13
View File
@@ -1,6 +1,6 @@
import SwiftUI
/// Stickers, from the shipped pack.
/// Stickers, from the shipped packs, one section per pack in manifest order.
struct StickerPicker: View {
let onPick: (StickerPack.Sticker) -> Void
@@ -9,7 +9,7 @@ struct StickerPicker: View {
var body: some View {
Group {
if StickerPack.all.isEmpty {
// The manifest failed to load.
// The manifests failed to load.
Text("Stickers could not be loaded on this device.")
.font(.system(size: 14))
.foregroundStyle(Theme.secondaryText)
@@ -17,27 +17,40 @@ struct StickerPicker: View {
.frame(maxWidth: .infinity)
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 4) {
ForEach(StickerPack.all) { sticker in
Button {
onPick(sticker)
} label: {
Text(sticker.glyph)
.font(.system(size: 34))
.frame(width: 60, height: 60)
LazyVGrid(columns: columns, spacing: 4, pinnedViews: [.sectionHeaders]) {
ForEach(StickerPack.packs) { pack in
Section {
ForEach(pack.stickers) { sticker in
Button {
onPick(sticker)
} label: {
StickerView(pack: sticker.pack, id: sticker.id, size: 60)
}
.buttonStyle(.plain)
.accessibilityLabel(sticker.name)
.accessibilityIdentifier("sticker.\(sticker.pack).\(sticker.id)")
}
} header: {
Text(pack.name)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Theme.Space.xs)
.padding(.vertical, Theme.Space.xs)
.background(Theme.incomingBubble)
.accessibilityAddTraits(.isHeader)
}
.buttonStyle(.plain)
.accessibilityLabel(sticker.name)
}
}
.padding(Theme.Space.sm)
}
.frame(height: 200)
.frame(height: 220)
}
}
.background(Theme.incomingBubble)
.clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small, style: .continuous))
.padding(.horizontal, Theme.Kit.margin)
.accessibilityIdentifier("chat.stickerPicker")
}
}
+134 -30
View File
@@ -1,59 +1,163 @@
import Foundation
import SwiftUI
import UIKit
/// The shipped sticker set, read from the same manifest the Android app reads.
/// The shipped sticker packs, read from the same manifests the Android app
/// reads (docs/STICKERS.md).
///
/// Every manifest under `stickers/` in the bundle is a pack: the core pack
/// first, then the rest by id. A sticker draws its `image` when it has one,
/// its `glyph` when it has none or the image will not load, and the pack's
/// `fallback` when this build does not know the id. A pack this build does not
/// ship draws the core pack's fallback.
enum StickerPack {
struct Sticker: Identifiable, Hashable {
let pack: String
let id: String
let glyph: String
let name: String
/// The drawing, a file in the pack's own folder, when the pack has art.
let image: String?
}
struct Pack: Identifiable, Hashable {
let id: String
let name: String
let fallback: String
let stickers: [Sticker]
}
static let core = "flamenet.core"
/// What draws when nothing else can.
static let lastResort = "🙂"
private struct Manifest: Decodable {
let pack: String
let name: String
let fallback: String
let fallback: String?
let stickers: [Entry]
struct Entry: Decodable {
let id: String
let glyph: String
let glyph: String?
let name: String
let image: String?
}
}
private static let loaded: (order: [Sticker], byID: [String: Sticker], fallback: String) = {
// Failure is empty, not a crash.
guard
let url = Bundle.main.url(
forResource: core, withExtension: "json",
subdirectory: "stickers")
?? Bundle.main.url(forResource: core, withExtension: "json"),
let data = try? Data(contentsOf: url),
let manifest = try? JSONDecoder().decode(Manifest.self, from: data)
else { return ([], [:], "🙂") }
let order = manifest.stickers.map {
Sticker(id: $0.id, glyph: $0.glyph, name: $0.name)
/// Every pack whose manifest sits in `directory`, core first, then by id.
/// A manifest that will not read is skipped, never a crash.
static func load(from directory: URL) -> [Pack] {
let files =
(try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil)) ?? []
let packs = files.filter { $0.pathExtension == "json" }.compactMap { url -> Pack? in
guard let data = try? Data(contentsOf: url),
let manifest = try? JSONDecoder().decode(Manifest.self, from: data),
!manifest.pack.isEmpty
else { return nil }
let fallback = (manifest.fallback ?? "").isEmpty ? lastResort : manifest.fallback!
var seen = Set<String>()
let stickers = manifest.stickers.compactMap { entry -> Sticker? in
guard !entry.id.isEmpty, seen.insert(entry.id).inserted else { return nil }
return Sticker(
pack: manifest.pack, id: entry.id,
glyph: (entry.glyph ?? "").isEmpty ? fallback : entry.glyph!,
name: entry.name, image: safeFileName(entry.image))
}
return Pack(id: manifest.pack, name: manifest.name, fallback: fallback, stickers: stickers)
}
return (
order, Dictionary(uniqueKeysWithValues: order.map { ($0.id, $0) }),
manifest.fallback.isEmpty ? "🙂" : manifest.fallback
)
return packs.sorted { a, b in
if (a.id == core) != (b.id == core) { return a.id == core }
return a.id < b.id
}
}
/// A file name that stays inside the pack's folder, or nil.
private static func safeFileName(_ name: String?) -> String? {
guard let name, !name.isEmpty, !name.contains("/"), !name.contains(".."),
!name.hasPrefix(".")
else { return nil }
return name
}
/// Where the shipped manifests are: `stickers/` in the bundle, kept as a
/// folder so each pack's drawings sit in a folder named for the pack.
private static let shippedDirectory: URL? = {
guard let root = Bundle.main.resourceURL else { return nil }
let folder = root.appendingPathComponent("stickers", isDirectory: true)
return FileManager.default.fileExists(atPath: folder.path) ? folder : nil
}()
/// Every sticker in the shipped pack, in manifest order.
static var all: [Sticker] { loaded.order }
private static let shipped: [Pack] = shippedDirectory.map(load(from:)) ?? []
/// What to draw for a reference.
static func glyph(pack: String, id: String) -> String {
guard pack == core else { return loaded.fallback }
return loaded.byID[id]?.glyph ?? loaded.fallback
/// Every pack, in picker order.
static var packs: [Pack] { shipped }
/// Every sticker in every pack, in picker order.
static var all: [Sticker] { shipped.flatMap(\.stickers) }
/// The sticker a reference names, if this build ships it.
static func sticker(pack: String, id: String, in packs: [Pack] = shipped) -> Sticker? {
packs.first { $0.id == pack }?.stickers.first { $0.id == id }
}
static func name(pack: String, id: String) -> String {
guard pack == core, let s = loaded.byID[id] else { return "Sticker" }
return s.name
/// The glyph to draw when there is no drawing: the sticker's own, the
/// pack's fallback for an id this build does not know, and the core
/// fallback for a pack it does not ship.
static func glyph(pack: String, id: String, in packs: [Pack] = shipped) -> String {
if let s = sticker(pack: pack, id: id, in: packs) { return s.glyph }
if let known = packs.first(where: { $0.id == pack }) { return known.fallback }
return packs.first { $0.id == core }?.fallback ?? lastResort
}
static func name(pack: String, id: String, in packs: [Pack] = shipped) -> String {
sticker(pack: pack, id: id, in: packs)?.name ?? "Sticker"
}
/// The drawing's file, when the sticker has one and the file is there.
static func imageURL(
pack: String, id: String, in packs: [Pack] = shipped,
directory: URL? = shippedDirectory
) -> URL? {
guard let directory, let file = sticker(pack: pack, id: id, in: packs)?.image else {
return nil
}
let url = directory.appendingPathComponent(pack, isDirectory: true)
.appendingPathComponent(file)
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
private static let images = NSCache<NSString, UIImage>()
/// The drawing itself, or nil, in which case the glyph draws.
static func image(pack: String, id: String) -> UIImage? {
guard let url = imageURL(pack: pack, id: id) else { return nil }
if let cached = images.object(forKey: url.path as NSString) { return cached }
guard let image = UIImage(contentsOfFile: url.path) else { return nil }
images.setObject(image, forKey: url.path as NSString)
return image
}
}
/// One sticker, drawn: the picture when there is one, the glyph otherwise.
struct StickerView: View {
let pack: String
let id: String
/// The square it fills.
var size: CGFloat = 96
var body: some View {
if let image = StickerPack.image(pack: pack, id: id) {
Image(uiImage: image)
.resizable()
.interpolation(.high)
.scaledToFit()
.frame(width: size, height: size)
} else {
Text(StickerPack.glyph(pack: pack, id: id))
.font(.system(size: size * 0.66))
.frame(width: size, height: size)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import Testing
import UIKit
@testable import FlamenetMessenger
/// Every manifest under `stickers/` is a pack, core first; a
/// sticker draws its image, then its glyph, then the pack's fallback; a pack
/// this build does not ship draws the core fallback.
@Suite("sticker packs load in order and every reference draws something")
struct StickerPackTests {
/// A throwaway `stickers/` folder: a core pack, a pack with art, and a
/// file that is not a manifest at all.
private func folder() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("stickers-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(
at: dir.appendingPathComponent("test.art"), withIntermediateDirectories: true)
try Data(
#"{"pack":"flamenet.core","name":"Core","fallback":"C","stickers":[{"id":"wave","glyph":"W","name":"Wave"}]}"#
.utf8
).write(to: dir.appendingPathComponent("flamenet.core.json"))
try Data(
#"""
{"pack":"test.art","name":"Art","fallback":"A","stickers":[
{"id":"drawn","glyph":"D","name":"Drawn","image":"drawn.png"},
{"id":"lost","glyph":"L","name":"Lost","image":"lost.png"},
{"id":"escape","glyph":"E","name":"Escape","image":"../flamenet.core.json"},
{"id":"bare","name":"Bare"},
{"id":"drawn","glyph":"X","name":"Second drawn"}
]}
"""#.utf8
).write(to: dir.appendingPathComponent("test.art.json"))
try Data("not json".utf8).write(to: dir.appendingPathComponent("broken.json"))
let pixel = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { ctx in
UIColor.orange.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: 2, height: 2))
}
try pixel.write(to: dir.appendingPathComponent("test.art/drawn.png"))
return dir
}
@Test("core first, then the rest by id; a broken manifest is skipped")
func order() throws {
let packs = StickerPack.load(from: try folder())
#expect(packs.map(\.id) == ["flamenet.core", "test.art"])
#expect(packs[1].name == "Art")
}
@Test("a sticker's glyph, its pack's fallback, then the core fallback")
func glyphs() throws {
let packs = StickerPack.load(from: try folder())
#expect(StickerPack.glyph(pack: "test.art", id: "drawn", in: packs) == "D")
#expect(StickerPack.glyph(pack: "test.art", id: "unknown", in: packs) == "A")
#expect(StickerPack.glyph(pack: "some.future.pack", id: "x", in: packs) == "C")
// A sticker with no glyph of its own uses its pack's fallback.
#expect(StickerPack.glyph(pack: "test.art", id: "bare", in: packs) == "A")
// Nothing at all still draws something.
#expect(StickerPack.glyph(pack: "p", id: "x", in: []) == StickerPack.lastResort)
}
@Test("the first of two stickers with one id wins")
func duplicates() throws {
let packs = StickerPack.load(from: try folder())
#expect(StickerPack.name(pack: "test.art", id: "drawn", in: packs) == "Drawn")
}
@Test("an image is found only when its file is there and inside the pack's folder")
func images() throws {
let dir = try folder()
let packs = StickerPack.load(from: dir)
let drawn = try #require(
StickerPack.imageURL(pack: "test.art", id: "drawn", in: packs, directory: dir))
#expect(UIImage(contentsOfFile: drawn.path) != nil)
#expect(StickerPack.imageURL(pack: "test.art", id: "lost", in: packs, directory: dir) == nil)
#expect(
StickerPack.imageURL(pack: "test.art", id: "escape", in: packs, directory: dir) == nil,
"a manifest pointed outside its pack's folder")
#expect(StickerPack.imageURL(pack: "flamenet.core", id: "wave", in: packs, directory: dir) == nil)
}
// MARK: - What the app ships
@Test("the app ships the core pack first, then FlameNet's own, as sections")
func shipped() {
let packs = StickerPack.packs
#expect(packs.map(\.id) == ["flamenet.core", "flamenet.retro"])
#expect(packs.first?.name == "Flamenet")
#expect(packs.last?.name == "FlameNet")
#expect(packs.last?.stickers.count == 24)
}
@Test("every FlameNet sticker has its drawing in the app, 288 pixels square")
func shippedArt() throws {
let retro = try #require(StickerPack.packs.first { $0.id == "flamenet.retro" })
for sticker in retro.stickers {
let image = try #require(
StickerPack.image(pack: sticker.pack, id: sticker.id),
"\(sticker.id) has no drawing")
#expect(image.size.width * image.scale == 288, "\(sticker.id) is not 288 wide")
#expect(!sticker.glyph.isEmpty)
#expect(!sticker.name.isEmpty)
}
}
@Test("a core sticker still draws its glyph, as it did before art existed")
func coreStillGlyphs() {
#expect(StickerPack.glyph(pack: "flamenet.core", id: "wave") == "👋")
#expect(StickerPack.image(pack: "flamenet.core", id: "wave") == nil)
}
}
+8
View File
@@ -138,6 +138,14 @@ targets:
sources:
- path: Sources
- path: Resources
excludes:
- "stickers"
# A folder, not a group, so the bundle keeps the paths both apps name:
# `stickers/<pack>.json` and `stickers/<pack>/<image>`. A group copies
# every file to the bundle root, where two drawings with one name would
# collide.
- path: Resources/stickers
type: folder
dependencies:
- package: WebRTCBinary
product: WebRTC