fix(logos): SVG-Größe aus Breite und Höhe mit ergänzter viewBox, Pixelgrenze vor dem Dekodieren

Entscheidungen des Controllers zu Task 6 (Bedenken 2 bis 4).

- logos/prepare-logo.ts: sizeSvg(svg) ersetzt sizeSvgRoot und liefert { svg, w, h }. Das Seitenverhältnis kommt aus der brauchbaren
  viewBox des Wurzelelements, sonst aus dessen Breite und Höhe (Pixel oder ohne Einheit), sonst ist es quadratisch; Prozent und
  andere Einheiten, 0 und negative Werte zählen als unbrauchbar. Ohne brauchbare viewBox, aber mit Größe, wird viewBox="0 0 <Breite>
  <Höhe>" ergänzt (eine unbrauchbare viewBox wird dabei ersetzt, nie doppelt geschrieben), damit der Inhalt auf das Raster skaliert
  statt in Originalgröße oben links zu stehen. Eine brauchbare viewBox bleibt immer unberührt. Gelesen werden nur die Attribute des
  Wurzelelements (vorher die erste viewBox irgendwo im Text, etwa die eines inneren <symbol>); Anführungszeichen " und ' gehen
  beide. Tokenizer und Längenparser laufen linear (zwei quadratische Entwürfe fielen im Zeittest durch).
- logos/prepare-logo.ts: Raster und SVG werden über decodeImage aus editor/upload.ts geladen: erst die Maße lesen und gegen
  40 Megapixel prüfen, erst dann dekodieren (vorher dekodierte createImageBitmap zuerst). decodeImages Text für „zu groß“ wird zum
  Text des Logos, jeder andere Fehler bleibt „Das Logo konnte nicht gelesen werden.“ Die eigene Ladefunktion für SVG und
  createImageBitmap entfallen. LOGO_SOURCE_MAX_PIXELS ist jetzt MAX_IMAGE_PIXELS (eine Quelle, Wert 40 000 000 per Test festgehalten).
- logos/prepare-logo.ts: SVG_FALLBACK_SIZE entfernt (nichts benutzte es).
- card/marks.ts: neu viewBoxAspect(value) mit den Regeln aus svgAspect; svgAspect ruft es auf (Verhalten unverändert), so stehen die
  viewBox-Regeln nur einmal da.
- Tests: sizeSvg als Tabelle (Größe ohne Einheit, px, hochkant, Prozent, mm/em/pt/auto, nur Breite oder Höhe, 0 und negativ,
  vorhandene viewBox bleibt, unbrauchbare viewBox wird ersetzt, innere Elemente zählen nicht, XML-Kopf, „>“ im Wert, Zeitgrenze),
  prepareLogo mit gestelltem Image: Zahl der Pixel wird vor decode() geprüft (8001 x 5000 und 20 000 x 20 000 ohne decode, genau
  8000 x 5000 mit), SVG nur mit Breite und Höhe wird 2048 x 614 gerastert und bekommt viewBox="0 0 200 60"; marks.test.ts für
  viewBoxAspect. Vitest 47 Dateien, 1478 Tests grün, tsc sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 21:26:30 +00:00
1 parent 0f66136d82
commit 56d841b8e2
4 files changed
+354 -133

No files matched your search

+74 -37
View File
@@ -1,8 +1,10 @@
// 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';
// (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. */
@@ -14,16 +16,12 @@ 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;
/** 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, siehe `problem`.
export const LOGO_TYPE_MESSAGE = msg('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.');
@@ -135,22 +133,63 @@ export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box:
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;
}
/**
* 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.
* 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 sizeSvgRoot(svg: string, w: number, h: number): string {
export function sizeSvg(svg: string): SizedSvg {
const unchanged = { svg, ...svgRasterSize(NaN) };
const start = svg.search(/<svg\b/);
if (start < 0) return svg;
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 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)}`;
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) --------------------------------------------
@@ -180,33 +219,31 @@ function draw(source: CanvasImageSource, w: number, h: number): Drawn {
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);
/**
* 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 {
if (bitmap.width * bitmap.height > LOGO_SOURCE_MAX_PIXELS) throw problem(LOGO_PIXELS_MESSAGE);
return draw(bitmap, bitmap.width, bitmap.height);
} finally {
bitmap.close();
return await decodeImage(blob);
} catch (err) {
if (err instanceof Error && err.message === PIXELS_MESSAGE) throw problem(LOGO_PIXELS_MESSAGE);
throw err;
}
}
/** 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));
/** 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: Seitenverhältnis aus der viewBox (ohne: quadratisch), gerastert mit 2048 px an der längsten Seite. */
/** 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 text = await file.text();
const { w, h } = svgRasterSize(svgAspect(text));
return draw(await loadSvgImage(sizeSvgRoot(text, w, h)), w, h);
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> {