/* ===================================================================== helfer-png-alpha.mjs — wie viel von einem PNG ist überhaupt da? --------------------------------------------------------------------- WOFÜR Das Abzeichen einer Benachrichtigung (`badge`) wird von Android AUSSCHLIESSLICH über seinen Alphakanal gelesen und einfarbig weiß gemalt. Alles Bunte daran geht verloren. Bis zum 29.09.2026 stand dort ein vollflächiges App-Symbol ohne Transparenz. Dessen Alphakanal ist überall voll — in der Statusleiste erschien deshalb ein ausgefüllter Klotz. Es stürzt nichts ab, es sieht nur billig aus, und genau deshalb fällt so etwas niemandem als Fehler auf. Eine Prüfung, die nur nachsieht, ob die DATEI existiert, hätte den Klotz nie gefunden — sie existierte ja. Gefragt ist der Inhalt: Deckt das Bild 12 Prozent seiner Fläche (ein Zeichen) oder 100 (ein Klotz)? --------------------------------------------------------------------- WARUM SELBST GERECHNET Im Haus gibt es keine Bildbibliothek für Node, und eine dafür zu installieren wäre eine Abhängigkeit für vierzig Zeilen. PNG ist an dieser Stelle einfach: Kopf lesen, die Datenblöcke zusammenhängen, auspacken, die Zeilenfilter rückgängig machen. `zlib` liegt in Node bei. --------------------------------------------------------------------- DREI AUSGÄNGE, NICHT ZWEI Diese Datei kann ein PNG auch NICHT lesen können — ein anderes Farbformat, 16 Bit je Kanal, ein verschachteltes Bild. Dann sagt sie das (`lesbar: false, grund: "…"`) und behauptet nicht, die Deckung sei null. Eine Null sähe aus wie ein Befund und wäre eine Ausrede. ===================================================================== */ import { readFileSync } from "node:fs"; import { inflateSync } from "node:zlib"; const MAGIE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** Wie viel eines PNG ist sichtbar? * * @returns {{lesbar: boolean, grund?: string, breite?: number, * hoch?: number, deckung?: number, hatAlpha?: boolean}} * `deckung` ist der Anteil der Bildpunkte mit Alpha über 127. */ export function pngDeckung(pfad) { let roh; try { roh = readFileSync(pfad); } catch (f) { return { lesbar: false, grund: `nicht zu öffnen: ${f?.code || f?.message}` }; } if (roh.length < 8 || !roh.subarray(0, 8).equals(MAGIE)) { return { lesbar: false, grund: "kein PNG" }; } /* ---- Die Blöcke durchgehen ---- */ let stelle = 8; let kopf = null; const daten = []; while (stelle + 8 <= roh.length) { const laenge = roh.readUInt32BE(stelle); const art = roh.toString("ascii", stelle + 4, stelle + 8); const inhalt = roh.subarray(stelle + 8, stelle + 8 + laenge); if (art === "IHDR") { kopf = { breite: inhalt.readUInt32BE(0), hoch: inhalt.readUInt32BE(4), tiefe: inhalt[8], farbe: inhalt[9], verschachtelt: inhalt[12], }; } else if (art === "IDAT") { daten.push(inhalt); } else if (art === "IEND") { break; } stelle += 12 + laenge; // Länge + Art + Inhalt + Prüfsumme } if (!kopf) return { lesbar: false, grund: "kein Kopf (IHDR)" }; /* ---- Was diese Datei kann, und was nicht ---- */ const hatAlpha = kopf.farbe === 6 || kopf.farbe === 4; if (!hatAlpha) { /* KEIN ALPHAKANAL heißt: das Bild ist überall voll. Das ist eine Antwort und kein Unvermögen -- genau dieser Fall war der Klotz in der Statusleiste. */ return { lesbar: true, hatAlpha: false, deckung: 1, breite: kopf.breite, hoch: kopf.hoch }; } if (kopf.tiefe !== 8) { return { lesbar: false, grund: `${kopf.tiefe} Bit je Kanal, ich kann nur 8` }; } if (kopf.verschachtelt) { return { lesbar: false, grund: "verschachtelt (Adam7)" }; } if (!daten.length) return { lesbar: false, grund: "keine Bilddaten" }; let aus; try { aus = inflateSync(Buffer.concat(daten)); } catch (f) { return { lesbar: false, grund: `nicht auszupacken: ${f?.message}` }; } /* ---- Die Zeilenfilter rückgängig machen ---- PNG speichert jede Zeile mit einem Filterbyte davor. Ohne das Rückrechnen stünden dort Differenzen und keine Farbwerte -- und eine Deckung, die aus Differenzen gerechnet ist, wäre eine Zahl ohne Bedeutung, die trotzdem plausibel aussieht. */ const kanaele = kopf.farbe === 6 ? 4 : 2; // RGBA oder Grau+Alpha const zeileBytes = kopf.breite * kanaele; const noetig = (zeileBytes + 1) * kopf.hoch; if (aus.length < noetig) { return { lesbar: false, grund: `zu wenig Daten (${aus.length} statt ${noetig})` }; } let voll = 0; let vorige = Buffer.alloc(zeileBytes); let p = 0; for (let y = 0; y < kopf.hoch; y++) { const filter = aus[p++]; const zeile = Buffer.from(aus.subarray(p, p + zeileBytes)); p += zeileBytes; for (let i = 0; i < zeileBytes; i++) { const a = i >= kanaele ? zeile[i - kanaele] : 0; // links const b = vorige[i]; // oben const c = i >= kanaele ? vorige[i - kanaele] : 0; // oben links let w = zeile[i]; if (filter === 1) w += a; else if (filter === 2) w += b; else if (filter === 3) w += (a + b) >> 1; else if (filter === 4) { const s = a + b - c; const da = Math.abs(s - a), dbb = Math.abs(s - b), dc = Math.abs(s - c); w += (da <= dbb && da <= dc) ? a : (dbb <= dc ? b : c); } zeile[i] = w & 0xff; } for (let x = kanaele - 1; x < zeileBytes; x += kanaele) { if (zeile[x] > 127) voll++; } vorige = zeile; } return { lesbar: true, hatAlpha: true, breite: kopf.breite, hoch: kopf.hoch, deckung: voll / (kopf.breite * kopf.hoch), }; }