/* =================================================================== Stellt die Siegel der Rabattcode-Partner frei. ANLASS (08.09.2026). Auf rabattcodes.html steht ab jetzt der erste echte Partnercode (DOGI10 fuer Van's DIY & Bastelbedarf). Filipe hat das Logo als Bildschirmfoto aus TikTok geliefert: ein rundes Siegel auf schwarzem Grund, 1080x1047, JPEG. WARUM DAS SO NICHT AUF DIE SEITE KANN. Der Grund ist NICHT dieselbe Farbe wie die Karte -- rgb(1,1,1) gegen die Kartenflaeche. Ungestellt liegt das Siegel also in einem sichtbaren schwarzen KASTEN mitten in einer runden Fassung. Genau der Fehler, der im Workspace schon einmal passiert ist (siehe marken-freistellen.mjs) -- dort mit einer Datei, deren Zahlen tadellos aussahen. WARUM EIN EIGENES WERKZEUG UND NICHT marken-freistellen.mjs. Jenes liefert nach workspace/assets/img/ und traegt zwei Sonderdurchgaenge, die hier beide falsch waeren: `kopfTrennen` (schneidet unter dem Kinn ab) und `nurFarbmotiv` (behaelt nur Buntes). Das Siegel ist rund, vollstaendig gewollt und vollstaendig GRAU -- `nurFarbmotiv` haette ein leeres Bild ergeben, `kopfTrennen` die untere Haelfte gekappt. Gemeinsam ist nur die Flutfuellung; die steht hier deshalb noch einmal, dafuer ohne jede Verzweigung. WARUM FLUTFUELLUNG UND KEINE KREISMASKE. Ein Kreis waere hier sogar ausrechenbar (Mitte 539,5/526,5, Radius 495 -- nachgemessen). Er waere aber an genau dieses eine Bild gebunden: Der naechste Partner liefert sein Logo in einer anderen Groesse, vielleicht nicht mittig, vielleicht gar nicht rund. Die Fuellung laeuft vom Bildrand nach innen und bleibt an der ersten Kontur stehen -- sie misst die Form, statt sie vorauszusetzen. Ein neuer Partner ist damit ein Eintrag in AUFTRAEGE und sonst nichts. DIE QUELLEN LIEGEN MIT IM REPO (assets/img/marke/quelle-*.jpg), nicht nur in Filipes Bilderordner. Ein Werkzeug, dessen Eingangsdatei auf einem fremden Rechner liegt, laesst sich genau einmal ausfuehren -- danach ist es Dekoration. WEBP STATT PNG. Gemessen an derselben Vorlage, 384 px Kante: PNG (RGBA) 222 KB, PNG als Graustufen+Alpha 83 KB, WebP 0.94 rund 45 KB -- bei einem Bild, das auf der Seite hoechstens 148 px breit erscheint. WebP steckt im Repo bereits in (workspace/index.html, die Buehnenbilder) und in CSS (cryo-*.webp), ist also nichts Neues. Aufrufen mit: node tools/partner-siegel-freistellen.mjs =================================================================== */ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; const WURZEL = join(dirname(fileURLToPath(import.meta.url)), ".."); const AUFTRAEGE = [ { name: "Van's DIY & Bastelbedarf", quelle: join(WURZEL, "assets", "img", "marke", "quelle-vandiy.jpg"), ziel: join(WURZEL, "assets", "img", "partner-vandiy.webp"), }, ]; /* 384 px Kante. Das Medaillon auf rabattcodes.html misst hoechstens 148 CSS-px (Desktop) bzw. 116 (Handy) -- 384 deckt damit auch ein Handy mit dreifacher Pixeldichte noch scharf ab, ohne dass die Datei in die Naehe der 120-KB-Schwelle aus pruef-bildgroessen.mjs kommt. */ const KANTE = 384; for (const a of AUFTRAEGE) { if (!existsSync(a.quelle)) { console.error(`Quelle fehlt: ${a.quelle}`); process.exit(1); } } const browser = await chromium.launch(); const seite = await (await browser.newContext()).newPage(); const arbeiten = async (auftrag) => await seite.evaluate(async ({ quelle, KANTE }) => { const bild = new Image(); bild.src = quelle; await bild.decode(); const b = bild.naturalWidth, h = bild.naturalHeight; const leinwand = document.createElement("canvas"); leinwand.width = b; leinwand.height = h; const stift = leinwand.getContext("2d", { willReadFrequently: true }); stift.drawImage(bild, 0, 0); const daten = stift.getImageData(0, 0, b, h); const p = daten.data; /* GRUNDTON AUS DEM HAEUFIGSTEN RANDTON, nicht aus den vier Ecken. Uebernommen aus logo-freistellen.mjs, wo die Eckenmittelung an abgerundeten Ecken gescheitert ist. Der Weg ueber den ganzen Rand ueberlebt Rahmen, runde Ecken und einzelne Ausreisser. */ const haufen = new Map(); const merke = (x, y) => { const i = (y * b + x) * 4; if (p[i + 3] < 200) return; const s = `${p[i] >> 3},${p[i + 1] >> 3},${p[i + 2] >> 3}`; const e = haufen.get(s) || { n: 0, r: 0, g: 0, bl: 0 }; e.n++; e.r += p[i]; e.g += p[i + 1]; e.bl += p[i + 2]; haufen.set(s, e); }; for (let x = 0; x < b; x++) { merke(x, 0); merke(x, h - 1); } for (let y = 0; y < h; y++) { merke(0, y); merke(b - 1, y); } if (!haufen.size) return { fehler: "Der ganze Rand ist durchsichtig." }; const groesster = [...haufen.values()].sort((a, c) => c.n - a.n)[0]; const randPunkte = [...haufen.values()].reduce((s, e) => s + e.n, 0); const randAnteil = groesster.n / randPunkte; if (randAnteil < 0.5) { return { fehler: `Der Rand ist nicht einfarbig genug ` + `(haeufigster Ton nur ${Math.round(randAnteil * 100)} % des Randes).` }; } const grund = [ Math.round(groesster.r / groesster.n), Math.round(groesster.g / groesster.n), Math.round(groesster.bl / groesster.n), ]; const abstand = (i) => Math.sqrt( (p[i] - grund[0]) ** 2 + (p[i + 1] - grund[1]) ** 2 + (p[i + 2] - grund[2]) ** 2); /* NAH bis hierher gilt ein Punkt als Hintergrund WEIT ab hier gehoert er zum Motiv; dazwischen wird weich abgestuft. Dieselben Werte wie bei den Marken: Auch diese Quelle ist ein JPEG, und um den weissen Ring liegt ein Saum aus Kompressionsartefakten, der weder Grund noch Motiv ist. Wer ihn stehen laesst, bekommt einen schmutzigen Rand -- auf einer dunklen Seite faellt das als grauer Hof um das Siegel auf. */ const NAH = 58, WEIT = 118; const besucht = new Uint8Array(b * h); const liste = []; for (let x = 0; x < b; x++) { liste.push(x, x + (h - 1) * b); } for (let y = 0; y < h; y++) { liste.push(y * b, b - 1 + y * b); } let entfernt = 0, weich = 0; while (liste.length) { const feld = liste.pop(); if (besucht[feld]) continue; besucht[feld] = 1; const i = feld * 4; const a = abstand(i); if (a > WEIT) continue; // Motivkante erreicht, hier ist Schluss if (a <= NAH) { p[i + 3] = 0; entfernt++; } else { p[i + 3] = Math.round(255 * ((a - NAH) / (WEIT - NAH))); weich++; } const x = feld % b, y = (feld - x) / b; if (x > 0) liste.push(feld - 1); if (x < b - 1) liste.push(feld + 1); if (y > 0) liste.push(feld - b); if (y < h - 1) liste.push(feld + b); } /* ---- Wo steht das Motiv? ---------------------------------------- */ let x0 = b, y0 = h, x1 = -1, y1 = -1, sichtbar = 0; for (let y = 0; y < h; y++) for (let x = 0; x < b; x++) { if (p[(y * b + x) * 4 + 3] > 40) { sichtbar++; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } } if (x1 < 0) return { fehler: "Nach dem Freistellen ist nichts uebrig." }; stift.putImageData(daten, 0, 0); /* ---- Quadratisch und zentriert ausgeben -------------------------- Das Medaillon in der Seite ist ein Kreis. Ein nicht quadratisch ausgegebenes Bild sitzt darin aus der Mitte gerueckt -- und bei einem runden Siegel sieht man einen Versatz von wenigen Punkten sofort, weil zwei Kreise nebeneinander liegen. 6 % Luft, damit die weiche Kante nicht am Bildrand abgeschnitten wird. */ const mb = x1 - x0 + 1, mh = y1 - y0 + 1; const seiteQ = Math.round(Math.max(mb, mh) * 1.06); const mx = x0 + mb / 2, my = y0 + mh / 2; const ziel = document.createElement("canvas"); ziel.width = KANTE; ziel.height = KANTE; const zs = ziel.getContext("2d"); zs.imageSmoothingQuality = "high"; zs.drawImage(leinwand, mx - seiteQ / 2, my - seiteQ / 2, seiteQ, seiteQ, 0, 0, KANTE, KANTE); const zp = zs.getImageData(0, 0, KANTE, KANTE).data; let deckend = 0, halb = 0; for (let i = 3; i < zp.length; i += 4) { if (zp[i] > 200) deckend++; else if (zp[i] > 10) halb++; } const eckenAlpha = [[0, 0], [KANTE - 1, 0], [0, KANTE - 1], [KANTE - 1, KANTE - 1]] .map(([x, y]) => zp[(y * KANTE + x) * 4 + 3]); const mitteAlpha = zp[((KANTE >> 1) * KANTE + (KANTE >> 1)) * 4 + 3]; return { webp: ziel.toDataURL("image/webp", 0.94), grund, randAnteil: Math.round(randAnteil * 100), entfernt, weich, quelle: `${b}x${h}`, rahmen: `${mb}x${mh}`, seitenverhaeltnis: mb / mh, deckend, halb, eckenAlpha, mitteAlpha, anteil: deckend / (KANTE * KANTE), }; }, { quelle: "data:image/jpeg;base64," + readFileSync(auftrag.quelle).toString("base64"), KANTE }); let fehlt = 0; for (const auftrag of AUFTRAEGE) { console.log(`\n=== ${auftrag.name} ===`); const e = await arbeiten(auftrag); if (e.fehler) { console.error(` Abgebrochen: ${e.fehler}`); fehlt++; continue; } console.log(` Quelle: ${e.quelle}, Grundton rgb(${e.grund.join(",")}) ` + `auf ${e.randAnteil} % des Randes`); console.log(` Freigestellt: ${e.entfernt} Punkte ganz, ${e.weich} weich`); console.log(` Motivrahmen: ${e.rahmen} (Seitenverhaeltnis ${e.seitenverhaeltnis.toFixed(3)})`); console.log(` Ergebnis: ${e.deckend} deckende Punkte (${(e.anteil * 100).toFixed(1)} %), ` + `${e.halb} weiche Kante, Ecken-Alpha ${e.eckenAlpha.join("/")}, Mitte ${e.mitteAlpha}`); /* VIER BEDINGUNGEN, und alle vier muessen halten. Die Anzahl steht in der Bedingung, nicht nur im Meldetext -- eine Pruefung, die immer bestaetigt, bestaetigt nichts. */ /* Ein Kreis fuellt pi/4 seines Quadrats, hier zusaetzlich geteilt durch die 6 % Luft: 0,785 / 1,06^2 = 0,70. Der Rahmen 0,55..0,85 laesst Siegel mit Ecken oder mit Rand zu und faengt trotzdem beide Ausrutscher: Fuellung hat das Motiv gefressen / es wurde nichts entfernt. */ if (e.anteil < 0.55 || e.anteil > 0.85) { console.error(` FEHLER: ${(e.anteil * 100).toFixed(1)} % Flaeche uebrig, erwartet 55-85 % ` + `(ein Kreis mit 6 % Luft ergibt rund 70 %). Nichts gespeichert.`); fehlt++; continue; } /* Ecken durchsichtig -- genau daran ist die alte Workspace-Datei gescheitert, und man sieht es einer Zahl wie "60,8 % deckend" nicht an. */ if (e.eckenAlpha.some((a) => a > 20)) { console.error(` FEHLER: eine Ecke ist noch deckend (${e.eckenAlpha.join("/")}) -- ` + `der Kasten steht noch. Nichts gespeichert.`); fehlt++; continue; } /* ... und die Mitte NICHT. Ohne diese Gegenprobe waere ein Bild, aus dem die Fuellung den Kern herausgeloest hat, durch die Eckenpruefung glatt durchgelaufen. */ if (e.mitteAlpha < 250) { console.error(` FEHLER: die Mitte ist durchsichtig (Alpha ${e.mitteAlpha}) -- die Fuellung ` + `ist ins Motiv gelaufen. Nichts gespeichert.`); fehlt++; continue; } /* Rund heisst quadratischer Rahmen. Ein schiefes Verhaeltnis bedeutet, dass die Fuellung an einer Seite weitergelaufen ist als an der anderen -- im Medaillon saehe man das als aus der Mitte geruecktes Siegel. */ if (Math.abs(e.seitenverhaeltnis - 1) > 0.04) { console.error(` FEHLER: Motivrahmen ${e.rahmen} ist nicht rund ` + `(Verhaeltnis ${e.seitenverhaeltnis.toFixed(3)}). Nichts gespeichert.`); fehlt++; continue; } writeFileSync(auftrag.ziel, Buffer.from(e.webp.split(",")[1], "base64")); const kb = (readFileSync(auftrag.ziel).length / 1024).toFixed(1); console.log(` Gespeichert: ${auftrag.ziel.replace(WURZEL, ".")} (${KANTE}x${KANTE}, ${kb} KB)`); } await browser.close(); if (fehlt) { console.error(`\n${fehlt} von ${AUFTRAEGE.length} nicht gespeichert.`); process.exit(1); } console.log(`\n${AUFTRAEGE.length} Partnersiegel freigestellt.`);