/* ===================================================================== tools/abzeichen-bauen.mjs — das Abzeichen der Benachrichtigungen. WAS EIN ABZEICHEN IST, UND WAS NICHT Eine Benachrichtigung hat unter Android ZWEI Bilder: `icon` — das bunte Symbol im aufgeklappten Meldungskasten. `badge` — das winzige Zeichen oben in der Statusleiste, 24 dp. Vom `badge` benutzt das System AUSSCHLIESSLICH den Alphakanal und malt ihn einfarbig weiß. Alles Bunte daran geht verloren. Bis zum 29.09.2026 stand dort `workspace-192.png` — ein vollflächiges, quadratisches Bild OHNE Transparenz. Dessen Alphakanal ist überall voll. In der Statusleiste erschien deshalb ein ausgefüllter Klotz und kein Zeichen. Das fällt nicht als Fehler auf, weil nichts abstürzt; es sieht nur billig aus. --------------------------------------------------------------------- WARUM EINE FLÄCHE UND KEIN STRICHBILD Drei Fassungen gebaut und bei echter Größe verglichen, statt zu raten: A) die Tinte des Logos (Strichbild) — bei 24 px ein Gekrissel B) dieselbe, verdickt — besser, immer noch unruhig C) der geschlossene Umriss, gefüllt — trägt Die Ohrkerbe des Huskys ist bei 512 px zwei Prozent der Breite. Bei 24 px ist sie ein halber Pixel und wird zu Grau. Eine Fläche überlebt das Verkleinern, ein Strich nicht. --------------------------------------------------------------------- EIN ABZEICHEN FÜR BEIDE HÄUSER — UND ZWAR ABSICHTLICH Crew und Agentur sind getrennt, und das gilt auch hier: Das bunte `icon` ist je Haus ein anderes (crew-192 / workspace-192). Das Abzeichen ist es NICHT, und das ist keine vergessene Trennung. Es ist 24 Pixel groß und einfarbig. Der Unterschied zwischen den beiden Symbolen ist die Chili der Agentur — bei 24 px ein Fleck von zwei Pixeln, der nicht als Chili lesbar ist, sondern als Schmutz neben dem Kopf. Der Husky ist die Marke beider Häuser; ihn zu teilen verrät nichts über das jeweils andere. --------------------------------------------------------------------- WARUM DAS EIN SKRIPT IST UND KEIN EINMALIGER HANDGRIFF Ändert sich das Logo, wird das Abzeichen mit einem Befehl neu gebaut. Ein von Hand zugeschnittenes PNG wäre in dem Moment eine Kopie, die niemand mehr pflegt — dieselbe Falle wie eine abgeschriebene Liste. node tools/abzeichen-bauen.mjs ===================================================================== */ import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync, statSync } from "node:fs"; const HIER = dirname(fileURLToPath(import.meta.url)); const REPO = join(HIER, ".."); const QUELLE = join(REPO, "assets", "img", "marke", "logo-kopf-frei.png"); const ZIEL = join(REPO, "assets", "img", "abzeichen-96.png"); /* Die Rechnung steht in Python, weil dort Pillow liegt — Node hat im Haus keine Bildbibliothek, und eine dafür zu installieren wäre eine Abhängigkeit für 40 Zeilen. */ const RECHNUNG = String.raw` import sys from collections import deque from PIL import Image import numpy as np quelle, ziel = sys.argv[1], sys.argv[2] i = Image.open(quelle).convert("RGBA") # DIE ZEICHNUNG STECKT IM ALPHAKANAL. Gemessen: RGB ist in dieser # Datei ueberall schwarz, das weisse Fell ist DURCHSICHTIG, nicht # weiss. Wer hier auf Helligkeit schwellt, bekommt ein leeres Bild. tinte = np.array(i.split()[3]) > 90 h, w = tinte.shape if not tinte.any(): sys.exit("Die Quelle hat keine Zeichnung im Alphakanal.") # ---- Von aussen fluten: was der Rand erreicht, ist aussen ---------- aussen = np.zeros((h, w), bool) st = deque() for x in range(w): for y in (0, h - 1): if not tinte[y, x] and not aussen[y, x]: aussen[y, x] = True; st.append((y, x)) for y in range(h): for x in (0, w - 1): if not tinte[y, x] and not aussen[y, x]: aussen[y, x] = True; st.append((y, x)) while st: y, x = st.pop() for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): ny, nx = y + dy, x + dx if 0 <= ny < h and 0 <= nx < w and not tinte[ny, nx] and not aussen[ny, nx]: aussen[ny, nx] = True; st.append((ny, nx)) voll = ~aussen # ---- Nur die groesste zusammenhaengende Flaeche ------------------- # Im Logo liegen unter dem Kinn 18 lose Sprenkel. Bei 512 px sind sie # Beiwerk, bei 24 px sind sie Schmutz neben dem Zeichen. Gezaehlt # statt weggefiltert: Ein Filter muesste raten, wie dick ein Sprenkel # hoechstens ist. marke = np.zeros((h, w), np.int32) nr, gr = 0, {} for sy in range(h): for sx in range(w): if voll[sy, sx] and marke[sy, sx] == 0: nr += 1; n = 0 s2 = deque([(sy, sx)]); marke[sy, sx] = nr while s2: y, x = s2.pop(); n += 1 for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): ny, nx = y + dy, x + dx if 0 <= ny < h and 0 <= nx < w and voll[ny, nx] and marke[ny, nx] == 0: marke[ny, nx] = nr; s2.append((ny, nx)) gr[nr] = n best = max(gr, key=gr.get) kopf = Image.fromarray(((marke == best) * 255).astype(np.uint8), "L") # ---- Zuschneiden, mittig, etwas Luft ------------------------------ k = kopf.getbbox() aus = kopf.crop(k) s = max(aus.size); rand = int(s * 0.06) feld = Image.new("L", (s + 2 * rand, s + 2 * rand), 0) feld.paste(aus, (rand + (s - aus.width) // 2, rand + (s - aus.height) // 2)) # ---- 96 px, weiss, Form im Alphakanal ----------------------------- maske = feld.resize((96, 96), Image.LANCZOS) bild = Image.new("RGBA", (96, 96), (255, 255, 255, 255)) bild.putalpha(maske) bild.save(ziel, optimize=True) deckung = (np.array(maske) > 127).sum() / (96 * 96) print(" %d Flaechen, %d Sprenkel verworfen, Deckung %.0f %%" % (nr, nr - 1, 100 * deckung)) # DIE ZAHL GEHOERT IN DIE BEDINGUNG. Ein Abzeichen, das 2 % deckt, ist # ein Staubkorn; eines mit 90 % ist der Klotz, den wir gerade # abschaffen. Beides faellt hier auf, nicht erst auf dem Telefon. if not (0.15 <= deckung <= 0.75): sys.exit("Deckung %.0f %% liegt ausserhalb des Sinnvollen." % (100 * deckung)) `; if (!existsSync(QUELLE)) { console.error(`Die Quelle fehlt: ${QUELLE}`); process.exit(2); } const lauf = spawnSync("python", ["-c", RECHNUNG, QUELLE, ZIEL], { encoding: "utf8" }); if (lauf.error?.code === "ENOENT") { /* DER DRITTE AUSGANG. „Python ist nicht da" ist etwas anderes als „das Abzeichen ist falsch" — und darf nicht wie ein Befund aussehen. */ console.error("Python ist auf diesem Rechner nicht im Pfad — das Abzeichen " + "wurde NICHT neu gebaut. Die vorhandene Datei bleibt, wie sie ist."); process.exit(2); } if (lauf.stdout) process.stdout.write(lauf.stdout); if (lauf.status !== 0) { console.error(lauf.stderr?.trim() || "Der Bau ist gescheitert."); process.exit(1); } const gross = statSync(ZIEL).size; console.log(` ${ZIEL.replace(REPO, "").replace(/\\/g, "/")} — ${gross} Bytes`);