Task 6: Verarbeitung und Hochladen eines Logos als Ablauf ohne Oberfläche (der Dialog folgt in Task 7).
- logos/prepare-logo.ts (neu): validateLogoFile (PNG, JPG, WebP, SVG; ohne Typ zählt die Endung .svg; höchstens 5 MB), die reinen
Helfer trimBox (Alpha ≤ 8 gilt als leer), fitWithin (nie größer, gerundet, mindestens 1), svgRasterSize (längste Seite genau 2048,
ungültiges Seitenverhältnis quadratisch), hasTransparency und planLogo (Ausschnitt und Zielgröße, „leer“ und „zu klein“ nach der
fertigen Größe) sowie sizeSvgRoot. prepareLogo fügt sie zusammen: Raster über createImageBitmap (Breite × Höhe wird vor dem
Zeichnen gegen 40 Megapixel geprüft, das Bild danach freigegeben), SVG nur als Bild aus einer Object-URL (Seitenverhältnis aus
der viewBox, Größe ins Wurzelelement geschrieben), Zuschnitt, Verkleinern auf höchstens 720 × 250, PNG, hasTransparency auf den
Endpixeln. Jeder Fehler beim Dekodieren oder Zeichnen heißt „Das Logo konnte nicht gelesen werden.“; alle Meldungen sind schon
übersetzt (t() beim Auslösen), die deutschen Konstanten LOGO_*_MESSAGE stehen über msg().
- sizeSvgRoot ersetzt eine eigene width/height des Wurzelelements, statt ein zweites Attribut anzuhängen (doppelte Attribute machen
ein SVG ungültig, so scheiterten viele echte SVGs). Es macht genau einen Versuch ab dem ersten „<svg“: ein Suchmuster, das an
jedem „<svg“ neu ansetzt, brauchte bei einer Datei voller offener „<svg x="“ quadratisch viel Zeit (160 kB: 6 s).
- logos/upload-logo.ts (neu): uploadPreparedLogo legt logo.png mit read("any") und delete("user:<ich>") in den Bucket logo-images,
ruft uploadLogo und liefert das neue CommunityLogo (ID = Datei-ID, likeCount 0). mono gilt nur bei durchsichtigen Stellen. Wie
beim Veröffentlichen wird nur bei eindeutigem Scheitern aufgeräumt (genau die Datei dieses Versuchs, ein Fehler dabei überdeckt
nie den eigentlichen), bei offenem Ausgang bleibt die Datei. Ausnahme: 409 mit serverCode logoTaken löscht nichts, ein anderes
Logo trägt diese Datei-ID.
- i18n/en.ts: englische Texte zu den sechs neuen Meldungen.
- Tests: prepare-logo.test.ts (Helfer einzeln, dazu prepareLogo mit gestelltem Dekoder und Canvas: Zuschnitt, Skalierung, Rasterung,
Fehlertexte, 40-Megapixel-Grenze, SVG nie in der Seite) und upload-logo.test.ts (Reihenfolge, Rechte, Felder, mono, Aufräumen je
Status, logoTaken, Standard-Abhängigkeiten gegen den Bucket logo-images). Vitest 47 Dateien, 1452 Tests grün, tsc sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
// 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 `<svg …>` 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(/<svg\b/);
|
||
if (start < 0) return svg;
|
||
// Das öffnende Tag endet am ersten „>“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem ersten „<svg“ (`y`): so braucht
|
||
// auch eine Datei voller offener „<svg x="“ nur linear viel Zeit, ein Suchmuster, das an jedem „<svg“ neu ansetzt, hinge im Tab.
|
||
const tag = /<svg\b((?:[^>"']|"[^"]*"|'[^']*')*)>/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 width="${w}" height="${h}"${kept}>${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<Drawn> {
|
||
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<HTMLImageElement> {
|
||
const url = URL.createObjectURL(new Blob([svg], { type: SVG_TYPE }));
|
||
return new Promise<HTMLImageElement>((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<Drawn> {
|
||
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<Blob> {
|
||
return new Promise<Blob>((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<PreparedLogo> {
|
||
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);
|
||
}
|
||
}
|