// Ein ausgewähltes Logo für den Upload vorbereiten: Datei prüfen, dekodieren (SVG nur als Bild, also ohne Skripte), transparente // Ränder abschneiden, auf höchstens 720 × 250 px verkleinern (nie vergrößern) und als PNG ausgeben. Die Maße sind die des Dienstes // (deploy/api/logos.js), der trotzdem selbst prüft. Zuschnitt, Skalierung und Transparenz sind reine Helfer (einzeln testbar); // `prepareLogo` fügt sie mit dem Canvas des Browsers zusammen. Kein Netzwerk, kein Appwrite. import { svgAspect } from '../card/marks'; import { msg, t } from '../i18n/i18n'; /** Für das `accept`-Attribut des Datei-Felds: die erlaubten Typen, dazu `.svg` für Systeme, die den Typ nicht kennen. */ export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg'; /** Höchstmaße und Mindestmaß des fertigen Logos in Pixeln (wie der Dienst sie prüft). */ export const LOGO_MAX_W = 720; export const LOGO_MAX_H = 250; export const LOGO_MIN = 8; /** Pixel mit Alpha bis hierher gelten als leer (nicht sichtbar). */ export const ALPHA_EMPTY = 8; /** * Spec § 4: ein SVG ohne eigene Größe gilt als 1000 × 1000, also quadratisch. Gerastert wird immer mit SVG_RASTER_MAX; aus dem * Fallback bleibt nur das quadratische Seitenverhältnis (siehe svgRasterSize). */ export const SVG_FALLBACK_SIZE = 1000; /** Ein SVG wird mit genau so vielen Pixeln an der längsten Seite gerastert (scharf genug fürs Verkleinern auf 720 × 250). */ export const SVG_RASTER_MAX = 2048; /** Grenzen der gewählten Datei: Dateigröße und Breite × Höhe (ein kleines, stark komprimiertes Bild kann sonst Gigabytes belegen). */ export const LOGO_SOURCE_MAX_BYTES = 5_242_880; export const LOGO_SOURCE_MAX_PIXELS = 40_000_000; // Die Meldungen (Spec § 4 und § 11); übersetzt wird beim Auslösen, siehe `problem`. export const LOGO_TYPE_MESSAGE = msg('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.'); export const LOGO_SIZE_MESSAGE = msg('Die Datei ist größer als 5 MB.'); export const LOGO_PIXELS_MESSAGE = msg('Das Bild ist zu groß (max. 40 Megapixel).'); export const LOGO_READ_MESSAGE = msg('Das Logo konnte nicht gelesen werden.'); export const LOGO_EMPTY_MESSAGE = msg('Das Logo ist leer.'); export const LOGO_SMALL_MESSAGE = msg('Das Logo ist zu klein (mindestens 8 × 8 Pixel).'); /** Das fertige Logo: PNG samt Maßen; `hasTransparency` entscheidet, ob „Einfarbig“ wählbar ist. */ export interface PreparedLogo { png: Blob; width: number; height: number; hasTransparency: boolean; } export interface Box { x: number; y: number; w: number; h: number; } export interface Size { w: number; h: number; } /** Ein Fehler mit fertigem, übersetztem Text für die Oberfläche; jeder andere Fehler beim Verarbeiten gilt als „nicht lesbar“. */ class LogoError extends Error {} /** Ein `LogoError` mit der Meldung in der Sprache der Seite. */ function problem(text: string): LogoError { return new LogoError(t(text)); } const RASTER_TYPES = ['image/png', 'image/jpeg', 'image/webp']; const SVG_TYPE = 'image/svg+xml'; /** SVG: am Typ erkannt, oder am Namen, wenn der Browser keinen Typ liefert (manche Systeme kennen .svg nicht). */ function isSvgFile(f: { type: string; name: string }): boolean { return f.type === SVG_TYPE || (f.type === '' && /\.svg$/i.test(f.name)); } /** Prüft Typ und Größe, bevor die Datei dekodiert wird. Der Typ hat Vorrang vor der Größe; die Meldung ist schon übersetzt. */ export function validateLogoFile(f: { type: string; size: number; name: string }): { ok: true } | { ok: false; message: string } { if (!RASTER_TYPES.includes(f.type) && !isSvgFile(f)) return { ok: false, message: t(LOGO_TYPE_MESSAGE) }; if (f.size > LOGO_SOURCE_MAX_BYTES) return { ok: false, message: t(LOGO_SIZE_MESSAGE) }; if (f.size <= 0) return { ok: false, message: t(LOGO_READ_MESSAGE) }; return { ok: true }; } /** * Der kleinste Rahmen um alle sichtbaren Pixel (Alpha größer als `threshold`) eines RGBA-Bildes; null, wenn nichts sichtbar ist. * Nur die Deckkraft zählt, die Farbe nicht. */ export function trimBox(data: Uint8ClampedArray, w: number, h: number, threshold = ALPHA_EMPTY): Box | null { let minX = w; let minY = h; let maxX = -1; let maxY = -1; for (let y = 0; y < h; y++) { const row = y * w * 4 + 3; for (let x = 0; x < w; x++) { if (data[row + x * 4] > threshold) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; maxY = y; } } } return maxX < 0 ? null : { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }; } /** Verkleinert w × h so, dass es in maxW × maxH passt (Seitenverhältnis bleibt): nie größer als das Original, gerundet, mindestens 1. */ export function fitWithin(w: number, h: number, maxW: number, maxH: number): Size { const scale = Math.min(1, maxW / w, maxH / h); return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) }; } /** * Die Rastergröße eines SVGs bei diesem Seitenverhältnis (Breite durch Höhe): die längste Seite hat genau 2048 px, nie mehr, die * kürzere mindestens 1. Ohne brauchbares Seitenverhältnis (nicht endlich oder nicht größer als 0) ist es quadratisch. */ export function svgRasterSize(aspect: number): Size { if (!Number.isFinite(aspect) || aspect <= 0) return { w: SVG_RASTER_MAX, h: SVG_RASTER_MAX }; if (aspect >= 1) return { w: SVG_RASTER_MAX, h: Math.max(1, Math.round(SVG_RASTER_MAX / aspect)) }; return { w: Math.max(1, Math.round(SVG_RASTER_MAX * aspect)), h: SVG_RASTER_MAX }; } /** Hat das Bild (RGBA) durchsichtige Stellen, also irgendein Pixel mit Alpha unter 255? */ export function hasTransparency(data: Uint8ClampedArray): boolean { for (let i = 3; i < data.length; i += 4) if (data[i] < 255) return true; return false; } /** * Was aus den Pixeln eines Bildes wird: der sichtbare Ausschnitt und seine Zielgröße (höchstens 720 × 250, nie größer als der * Ausschnitt). Wirft „Das Logo ist leer.“, wenn nichts Sichtbares bleibt, und „Das Logo ist zu klein …“, wenn die fertige Größe * unter 8 × 8 Pixel läge (die Größe, die der Dienst prüft; ein sehr breites Logo kann dabei zu niedrig werden). */ export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box: Box; size: Size } { const box = trimBox(data, w, h); if (!box) throw problem(LOGO_EMPTY_MESSAGE); const size = fitWithin(box.w, box.h, LOGO_MAX_W, LOGO_MAX_H); if (size.w < LOGO_MIN || size.h < LOGO_MIN) throw problem(LOGO_SMALL_MESSAGE); return { box, size }; } /** * Gibt dem Wurzelelement eines SVGs die Größe `w` × `h` (damit alle Browser es scharf auf den Canvas zeichnen, wie `withIntrinsicSize` * in card/marks.ts). Eine eigene `width`/`height` des Wurzelelements fällt dabei weg: ein doppeltes Attribut machte das SVG ungültig. * Nur das erste `` wird angefasst; ohne eines (oder ohne dessen Ende) bleibt der Text unverändert. */ export function sizeSvgRoot(svg: string, w: number, h: number): string { const start = svg.search(/“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem ersten „/y; tag.lastIndex = start; const hit = tag.exec(svg); if (!hit) return svg; const kept = hit[1].replace(/\s(?:width|height)\s*=\s*(?:"[^"]*"|'[^']*')/g, ''); return `${svg.slice(0, start)}${svg.slice(start + hit[0].length)}`; } // ---- Dekodieren und Zeichnen (braucht einen echten Canvas) -------------------------------------------- /** Das Quellbild in Originalgröße auf einem Canvas samt seinen Pixeln (RGBA). */ interface Drawn { canvas: HTMLCanvasElement; data: Uint8ClampedArray; w: number; h: number; } /** Ein Canvas der Größe w × h mit 2D-Kontext. Es werden Pixel gelesen, daher `willReadFrequently`. */ function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) throw problem(LOGO_READ_MESSAGE); return { canvas, ctx }; } /** Zeichnet die Quelle in Größe w × h auf einen neuen Canvas und liest dessen Pixel. */ function draw(source: CanvasImageSource, w: number, h: number): Drawn { const { canvas, ctx } = newCanvas(w, h); ctx.drawImage(source, 0, 0, w, h); return { canvas, data: ctx.getImageData(0, 0, w, h).data, w, h }; } /** PNG, JPG oder WebP: dekodieren, Breite × Höhe prüfen (bevor etwas gezeichnet wird) und in Originalgröße zeichnen. */ async function drawRaster(file: File): Promise { const bitmap = await createImageBitmap(file); try { if (bitmap.width * bitmap.height > LOGO_SOURCE_MAX_PIXELS) throw problem(LOGO_PIXELS_MESSAGE); return draw(bitmap, bitmap.width, bitmap.height); } finally { bitmap.close(); } } /** Lädt ein SVG als Bild, nie als Teil der Seite: so laufen Skripte darin nicht und es lädt nichts nach. */ function loadSvgImage(svg: string): Promise { const url = URL.createObjectURL(new Blob([svg], { type: SVG_TYPE })); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(problem(LOGO_READ_MESSAGE)); img.src = url; }).finally(() => URL.revokeObjectURL(url)); } /** SVG: Seitenverhältnis aus der viewBox (ohne: quadratisch), gerastert mit 2048 px an der längsten Seite. */ async function drawSvg(file: File): Promise { const text = await file.text(); const { w, h } = svgRasterSize(svgAspect(text)); return draw(await loadSvgImage(sizeSvgRoot(text, w, h)), w, h); } function toPng(canvas: HTMLCanvasElement): Promise { return new Promise((resolve, reject) => { canvas.toBlob((blob) => (blob ? resolve(blob) : reject(problem(LOGO_READ_MESSAGE))), 'image/png'); }); } /** * Macht aus der gewählten Datei das fertige Logo (siehe Kopf der Datei). Wirft einen Fehler mit der Meldung in der Sprache der * Seite: Typ, Größe, zu viele Pixel, leer, zu klein oder nicht lesbar (alles, was beim Dekodieren oder Zeichnen schiefgeht). */ export async function prepareLogo(file: File): Promise { const check = validateLogoFile(file); if (!check.ok) throw new LogoError(check.message); try { const { canvas, data, w, h } = isSvgFile(file) ? await drawSvg(file) : await drawRaster(file); const { box, size } = planLogo(data, w, h); const out = newCanvas(size.w, size.h); // Beim Verkleinern ist die Standardqualität (low) grob. out.ctx.imageSmoothingQuality = 'high'; out.ctx.drawImage(canvas, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h); return { png: await toPng(out.canvas), width: size.w, height: size.h, hasTransparency: hasTransparency(out.ctx.getImageData(0, 0, size.w, size.h).data), }; } catch (err) { throw err instanceof LogoError ? err : problem(LOGO_READ_MESSAGE); } }