// 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, Transparenz und die Größe eines SVGs sind reine Helfer // (einzeln testbar); `prepareLogo` fügt sie mit dem Canvas des Browsers zusammen. Kein Netzwerk, kein Appwrite. import { viewBoxAspect } from '../card/marks'; import type { CardImage } from '../card/render'; import { MAX_IMAGE_PIXELS, PIXELS_MESSAGE, decodeImage } from '../editor/upload'; 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; /** 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; /** Dieselbe Grenze wie beim Foto im Editor (40 Megapixel): `decodeImage` prüft sie nach dem Laden der Maße, bevor es dekodiert. */ export const LOGO_SOURCE_MAX_PIXELS = MAX_IMAGE_PIXELS; // Die Meldungen (Spec § 4 und § 11); übersetzt wird beim Auslösen: `new LogoError(t(MELDUNG))`. 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 {} 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 new LogoError(t(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 new LogoError(t(LOGO_SMALL_MESSAGE)); return { box, size }; } /** Ein SVG-Text, bereit zum Rastern, samt der Größe in Pixeln, in der er gerastert wird. */ export interface SizedSvg { svg: string; w: number; h: number; } /** Ein Attribut im Wurzelelement: Name, dazu ein Wert in Anführungszeichen (`"…"` oder `'…'`); ein Leerzeichen davor gehört dazu. */ const ATTRIBUTE = /\s?([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g; /** Eine Länge in Pixeln: „200“ oder „200px“ (auch mit Dezimalstellen). Prozent, andere Einheiten, 0 und negative Werte: null. */ function pixels(value: string | undefined): number | null { const n = Number(/^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(?:px)?\s*$/i.exec(value ?? '')?.[1]); return n > 0 && Number.isFinite(n) ? n : null; } /** * Macht den Text eines SVGs rasterbar: Das Seitenverhältnis kommt aus der viewBox des Wurzelelements, wenn sie brauchbar ist, sonst * aus dessen Breite und Höhe in Pixeln (oder ohne Einheit), sonst ist es quadratisch (Prozent und andere Einheiten zählen nicht). * Daraus folgt die Rastergröße (`svgRasterSize`), die als `width`/`height` ins Wurzelelement geschrieben wird, damit alle Browser das * SVG scharf auf den Canvas zeichnen (wie `withIntrinsicSize` in card/marks.ts). Eine eigene Breite und Höhe fällt dabei weg: ein * doppeltes Attribut machte das SVG ungültig. Fehlt eine brauchbare viewBox, gibt es aber eine Größe, wird `viewBox="0 0 "` * ergänzt (eine unbrauchbare viewBox wird dabei ersetzt), sonst stünde der Inhalt in Originalgröße oben links im Raster. Eine * brauchbare viewBox bleibt immer unberührt. Nur das erste `` zählt; ohne eines (oder ohne dessen Ende) bleibt der Text * unverändert und das Raster quadratisch (das Laden als Bild meldet dann den Fehler). */ export function sizeSvg(svg: string): SizedSvg { const unchanged = { svg, ...svgRasterSize(NaN) }; 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 unchanged; const attributes = hit[1]; const values = new Map(); for (const attribute of attributes.matchAll(ATTRIBUTE)) values.set(attribute[1], attribute[2] ?? attribute[3] ?? ''); const dropped = new Set(['width', 'height']); let aspect = viewBoxAspect(values.get('viewBox') ?? ''); let viewBox = ''; if (aspect === null) { const width = pixels(values.get('width')); const height = pixels(values.get('height')); if (width !== null && height !== null) { aspect = width / height; viewBox = ` viewBox="0 0 ${width} ${height}"`; dropped.add('viewBox'); } } const { w, h } = svgRasterSize(aspect ?? NaN); const kept = attributes.replace(ATTRIBUTE, (attribute: string, name: string) => (dropped.has(name) ? '' : attribute)); return { svg: `${svg.slice(0, start)}${svg.slice(start + hit[0].length)}`, w, h }; } // ---- 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 new LogoError(t(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 }; } /** * Lädt eine Bilddatei als Bild (nie als Teil der Seite) mit `decodeImage` des Editors: erst werden die Maße gelesen und gegen die * Pixel-Obergrenze geprüft, erst danach wird dekodiert; so belegt ein kleines, stark komprimiertes Bild keinen Speicher. Den * Text für „zu groß“ bekommt das Logo in seiner Fassung, jeder andere Fehler heißt in `prepareLogo` „nicht lesbar“. */ async function decode(blob: Blob): Promise { try { return await decodeImage(blob); } catch (err) { if (err instanceof Error && err.message === PIXELS_MESSAGE) throw new LogoError(t(LOGO_PIXELS_MESSAGE)); throw err; } } /** PNG, JPG oder WebP: laden und in Originalgröße zeichnen. */ async function drawRaster(file: File): Promise { const { source, w, h } = await decode(file); return draw(source, w, h); } /** SVG: Größe aus dem Wurzelelement (siehe `sizeSvg`), nur als Bild geladen (so laufen Skripte darin nie), längste Seite 2048 px. */ async function drawSvg(file: File): Promise { const { svg, w, h } = sizeSvg(await file.text()); const { source } = await decode(new Blob([svg], { type: SVG_TYPE })); return draw(source, w, h); } function toPng(canvas: HTMLCanvasElement): Promise { return new Promise((resolve, reject) => { canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new LogoError(t(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) { if (err instanceof LogoError) throw err; // Alles andere (Bild nicht ladbar, Fehler des Browsers beim Zeichnen …) heißt für den Nutzer „nicht lesbar“; für die Fehlersuche bleibt // der eigentliche Anlass in der Konsole. console.warn('Logo konnte nicht vorbereitet werden', err); throw new LogoError(t(LOGO_READ_MESSAGE)); } }