// Erkennung von Buchstaben-Platzhaltern (eine dominante Hintergrundfarbe, Initialen darauf). // Nextcloud liefert solche Bilder teils als „eigene" Avatare aus; im Kalender sollen aber nur echte Fotos landen. import zlib from 'node:zlib'; import { imageMime } from './store.js'; const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** Anteil der häufigsten Farbe (0–1) und Zahl unterschiedlicher Farben, per Stichprobe. Nur PNG (8 Bit, RGB/RGBA/Grau). */ export function pngDominantShare(buffer, step = 4) { if (!buffer || buffer.length < 33 || !buffer.subarray(0, 8).equals(PNG_SIG)) return null; let pos = 8; let width = 0; let height = 0; let depth = 0; let colorType = 0; const idat = []; while (pos + 8 <= buffer.length) { const len = buffer.readUInt32BE(pos); const type = buffer.toString('ascii', pos + 4, pos + 8); const body = buffer.subarray(pos + 8, pos + 8 + len); if (type === 'IHDR') { width = body.readUInt32BE(0); height = body.readUInt32BE(4); depth = body[8]; colorType = body[9]; } else if (type === 'IDAT') idat.push(body); else if (type === 'IEND') break; pos += 12 + len; } const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType]; if (!channels || depth !== 8 || !width || !height) return null; let raw; try { raw = zlib.inflateSync(Buffer.concat(idat)); } catch { return null; } const bpp = channels; const stride = width * bpp; let prev = Buffer.alloc(stride); let offset = 0; const counts = new Map(); let total = 0; for (let y = 0; y < height; y += 1) { const filter = raw[offset]; const line = Buffer.from(raw.subarray(offset + 1, offset + 1 + stride)); offset += 1 + stride; for (let x = 0; x < stride; x += 1) { const a = x >= bpp ? line[x - bpp] : 0; const b = prev[x]; const c = x >= bpp ? prev[x - bpp] : 0; if (filter === 1) line[x] = (line[x] + a) & 255; else if (filter === 2) line[x] = (line[x] + b) & 255; else if (filter === 3) line[x] = (line[x] + ((a + b) >> 1)) & 255; else if (filter === 4) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); line[x] = (line[x] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255; } } if (y % step === 0) { for (let x = 0; x < stride; x += bpp * step) { const key = channels >= 3 ? (line[x] << 16) | (line[x + 1] << 8) | line[x + 2] : line[x] * 0x010101; counts.set(key, (counts.get(key) || 0) + 1); total += 1; } } prev = line; } if (total === 0) return null; let max = 0; for (const n of counts.values()) if (n > max) max = n; return { share: max / total, colors: counts.size, width, height }; } /** true, wenn das Bild wie ein generierter Initialen-Avatar aussieht: eine Farbe füllt den Großteil der Fläche. */ export function looksLikePlaceholder(buffer, threshold = 0.75) { if (imageMime(buffer) !== 'image/png') return false; // JPEG/WebP sind praktisch immer echte Fotos const info = pngDominantShare(buffer); return Boolean(info && info.share >= threshold); }