/* ===================================================================== EINEN BILDSCHIRMFOTO-PIXEL LESEN (17.09.2026) ANLASS: Der Kachelgrund hat ein Universum bekommen -- Sterne, drei Nebel, eine Vignette, dazu der Glanz und das durchscheinende Buehnenbild. Die Frage "ist der Text darauf noch lesbar?" laesst sich aus dem CSS nicht mehr beantworten: Zwischen Textfarbe und `--flaeche` liegen sieben Ebenen. WAS ZAEHLT, IST DER PUNKT AUF DEM SCHIRM. Und den liefert nur ein Bildschirmfoto. WARUM SELBST GESCHRIEBEN: Das Projekt hat kein Bildmodul, und fuer drei Pixel eines nachzuinstallieren waere eine Abhaengigkeit, die jemand pflegen muss. Node bringt `zlib` mit; ein PNG ist danach nur noch Zeilenfilter, und davon gibt es fuenf. ES LIEST NUR, WAS PLAYWRIGHT SCHREIBT: 8 Bit je Kanal, RGB oder RGBA, nicht verschraenkt. Alles andere meldet es als Fehler, statt zu raten -- ein Bildleser, der bei unbekanntem Format irgendetwas zurueckgibt, faelscht Messwerte. ===================================================================== */ import { inflateSync } from "node:zlib"; /** Liest ein PNG aus einem Puffer. * @returns {{breite:number, hoehe:number, kanaele:number, daten:Buffer}} */ export function pngLesen(puffer) { if (puffer.length < 8 || puffer.readUInt32BE(0) !== 0x89504e47) { throw new Error("Kein PNG"); } let pos = 8; let breite = 0, hoehe = 0, tiefe = 0, art = 0; const teile = []; while (pos < puffer.length) { const laenge = puffer.readUInt32BE(pos); const name = puffer.toString("ascii", pos + 4, pos + 8); const inhalt = puffer.subarray(pos + 8, pos + 8 + laenge); if (name === "IHDR") { breite = inhalt.readUInt32BE(0); hoehe = inhalt.readUInt32BE(4); tiefe = inhalt[8]; art = inhalt[9]; if (tiefe !== 8) throw new Error(`Nur 8 Bit je Kanal, nicht ${tiefe}`); if (art !== 2 && art !== 6) throw new Error(`Nur RGB/RGBA, nicht Typ ${art}`); if (inhalt[12] !== 0) throw new Error("Verschraenkte PNG werden nicht gelesen"); } else if (name === "IDAT") { teile.push(inhalt); } else if (name === "IEND") { break; } pos += 12 + laenge; // Laenge + Name + Inhalt + Pruefsumme } const kanaele = art === 6 ? 4 : 3; const roh = inflateSync(Buffer.concat(teile)); /* Die Filter rueckgaengig machen. Jede Zeile beginnt mit einem Byte, das sagt, wie sie kodiert wurde -- PNG-Spezifikation, Abschnitt 9. */ const zeile = breite * kanaele; const daten = Buffer.alloc(zeile * hoehe); for (let y = 0; y < hoehe; y++) { const filter = roh[y * (zeile + 1)]; const quelle = roh.subarray(y * (zeile + 1) + 1, y * (zeile + 1) + 1 + zeile); const ziel = daten.subarray(y * zeile, (y + 1) * zeile); const oben = y > 0 ? daten.subarray((y - 1) * zeile, y * zeile) : null; for (let x = 0; x < zeile; x++) { const a = x >= kanaele ? ziel[x - kanaele] : 0; // links const b = oben ? oben[x] : 0; // oben const c = oben && x >= kanaele ? oben[x - kanaele] : 0; // links oben let wert = quelle[x]; if (filter === 1) wert += a; else if (filter === 2) wert += b; else if (filter === 3) wert += (a + b) >> 1; else if (filter === 4) { /* Paeth: der Nachbar, der der Vorhersage am naechsten liegt. */ const p = a + b - c; const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); wert += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c); } else if (filter !== 0) { throw new Error(`Unbekannter Zeilenfilter ${filter}`); } ziel[x] = wert & 0xff; } } return { breite, hoehe, kanaele, daten }; } /** Die Farbe an einer Stelle -- [r, g, b]. */ export function pixel(bild, x, y) { const i = (Math.round(y) * bild.breite + Math.round(x)) * bild.kanaele; return [bild.daten[i], bild.daten[i + 1], bild.daten[i + 2]]; } const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); /** Relative Helligkeit nach WCAG. */ export const helligkeit = ([r, g, b]) => 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255); /** Kontrast zweier Farben nach WCAG -- immer >= 1. */ export function kontrast(a, b) { const x = helligkeit(a), y = helligkeit(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }