Schlussreview F6. prepareLogo machte aus jedem Fehler außer seinen eigenen („leer“, „zu klein“ …) die Meldung „Das Logo konnte nicht gelesen werden.“ und verlor dabei den Anlass (Fehler des Browsers beim Zeichnen, Dekodierfehler): bei einer Rückmeldung eines Nutzers war nichts mehr nachzuvollziehen. - Vor dem Umwandeln steht jetzt genau eine console.warn mit dem Originalfehler. Der Text für den Nutzer bleibt unverändert (in beiden Sprachen). Fehler mit eigener Meldung (LogoError: Typ, Größe, zu viele Pixel, leer, zu klein, kein Canvas, kein PNG) sind erwartet und warnen nicht. - Tests: Browserfehler beim Zeichnen (Raster und SVG), Dekodierfehler, Englisch, keine Warnung bei den erwarteten Fehlern und bei Erfolg. Die Tests der Gruppe „Bild und Canvas gestellt“ ersetzen console.warn durch eine Attrappe, damit die Ausgabe sauber bleibt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
13 KiB
TypeScript
278 lines
13 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, 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 <Breite> <Höhe>"`
|
||
* 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 `<svg …>` 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(/<svg\b/);
|
||
if (start < 0) return unchanged;
|
||
// 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 unchanged;
|
||
|
||
const attributes = hit[1];
|
||
const values = new Map<string, string>();
|
||
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 width="${w}" height="${h}"${viewBox}${kept}>${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<CardImage> {
|
||
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<Drawn> {
|
||
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<Drawn> {
|
||
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<Blob> {
|
||
return new Promise<Blob>((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<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) {
|
||
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));
|
||
}
|
||
}
|