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
+346 -125

No files matched your search

+10 -1
View File
@@ -2,7 +2,7 @@ import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import favicon from '../../public/favicon.svg?raw';
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg } from './marks';
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg, viewBoxAspect } from './marks';
const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
@@ -61,6 +61,15 @@ describe('SVG-Inhalt', () => {
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
});
it('viewBoxAspect: Breite durch Höhe aus dem Wert einer viewBox (Leerraum oder Kommas), sonst null', () => {
expect(viewBoxAspect('0 0 300 100')).toBeCloseTo(3, 5);
expect(viewBoxAspect(' 10,20,200,100 ')).toBeCloseTo(2, 5);
expect(viewBoxAspect('0 0 100 100')).toBe(1);
for (const unusable of ['', 'none', '0 0 0 10', '0 0 10 0', '0 0 -10 5', '0 0 10', '0 0 1 2 3', '0 0 a b', '0 0 Infinity 5']) {
expect(viewBoxAspect(unusable), JSON.stringify(unusable)).toBeNull();
}
});
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
for (const m of BRAND_MARKS) {
expect(m.tint, m.id).toBe(true);
+8 -4
View File
@@ -36,13 +36,17 @@ export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
}
/** Breite durch Höhe aus dem Wert einer viewBox („x y Breite Höhe“, mit Leerraum oder Kommas); null, wenn er nicht brauchbar ist. */
export function viewBoxAspect(value: string): number | null {
const parts = value.trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : null;
}
/** Breite durch Höhe aus der viewBox; 1, wenn keine brauchbare viewBox da ist. */
export function svgAspect(svg: string): number {
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
if (!hit) return 1;
const parts = hit[1].trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 1;
return (hit && viewBoxAspect(hit[1])) ?? 1;
}
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
+254 -83
View File
@@ -1,12 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setLang } from '../i18n/i18n';
import {
ALPHA_EMPTY,
LOGO_ACCEPT,
LOGO_MAX_H,
LOGO_MAX_W,
LOGO_MIN,
LOGO_SOURCE_MAX_BYTES,
LOGO_SOURCE_MAX_PIXELS,
SVG_RASTER_MAX,
fitWithin,
hasTransparency,
planLogo,
prepareLogo,
sizeSvgRoot,
sizeSvg,
svgRasterSize,
trimBox,
validateLogoFile,
@@ -39,6 +46,13 @@ describe('LOGO_ACCEPT', () => {
});
});
describe('Konstanten', () => {
it('die Werte der Spec', () => {
expect([LOGO_MAX_W, LOGO_MAX_H, LOGO_MIN, ALPHA_EMPTY, SVG_RASTER_MAX]).toEqual([720, 250, 8, 8, 2048]);
expect([LOGO_SOURCE_MAX_BYTES, LOGO_SOURCE_MAX_PIXELS]).toEqual([5_242_880, 40_000_000]);
});
});
describe('validateLogoFile', () => {
const check = (type: string, size = 1000, name = 'logo') => validateLogoFile({ type, size, name });
@@ -243,79 +257,192 @@ describe('planLogo', () => {
});
});
describe('sizeSvgRoot', () => {
describe('sizeSvg', () => {
const NS = 'xmlns="http://www.w3.org/2000/svg"';
/** Wie oft das Attribut `name` im öffnenden Tag des Wurzelelements steht. */
const countIn = (svg: string, name: string): number => {
const root = /^[^]*?(<svg\b[^>]*>)/.exec(svg)?.[1] ?? '';
return (root.match(new RegExp(`\\s${name}\\s*=`, 'g')) ?? []).length;
};
/** Der Rest hinter dem Wurzelelement; er bleibt immer unverändert. */
const BODY = '<rect width="1" height="1"/></svg>';
it('schreibt Breite und Höhe ins Wurzelelement und lässt den Rest unverändert', () => {
const svg = `<svg ${NS} viewBox="0 0 300 100"><rect width="300" height="100"/></svg>`;
expect(sizeSvgRoot(svg, 2048, 683)).toBe(
`<svg width="2048" height="683" ${NS} viewBox="0 0 300 100"><rect width="300" height="100"/></svg>`,
);
// [Fall, Wurzelelement vorher, Wurzelelement nachher, Rasterbreite, Rasterhöhe]
it.each<[string, string, string, number, number]>([
[
'Größe ohne Einheit und ohne viewBox: Seitenverhältnis aus Breite und Höhe, viewBox ergänzt',
'<svg width="200" height="60">',
'<svg width="2048" height="614" viewBox="0 0 200 60">',
2048,
614,
],
[
'Größe in px, auch mit Dezimalstellen',
'<svg width="200px" height="60.0px">',
'<svg width="2048" height="614" viewBox="0 0 200 60">',
2048,
614,
],
[
'Größe in einfachen Anführungszeichen und mit Leerraum im Wert',
"<svg width=' 200 ' height='60'>",
'<svg width="2048" height="614" viewBox="0 0 200 60">',
2048,
614,
],
[
'hochkant: die Höhe ist die längste Seite',
'<svg width="60" height="200">',
'<svg width="614" height="2048" viewBox="0 0 60 200">',
614,
2048,
],
[
'andere Attribute bleiben, in ihrer Reihenfolge',
`<svg ${NS} width="200" height="60" id="a" stroke-width="3" data-height="9">`,
`<svg width="2048" height="614" viewBox="0 0 200 60" ${NS} id="a" stroke-width="3" data-height="9">`,
2048,
614,
],
[
'eine brauchbare viewBox wird nie überschrieben; Breite und Höhe werden ersetzt',
`<svg ${NS} width="10" height="10" viewBox="0 0 300 100">`,
`<svg width="2048" height="683" ${NS} viewBox="0 0 300 100">`,
2048,
683,
],
[
'eine quadratische viewBox gilt auch bei anderer Größe (die viewBox hat Vorrang)',
'<svg width="200" height="60" viewBox="0 0 100 100">',
'<svg width="2048" height="2048" viewBox="0 0 100 100">',
2048,
2048,
],
[
'viewBox in einfachen Anführungszeichen und mit Kommas',
"<svg viewBox='0,0,300,100'>",
"<svg width=\"2048\" height=\"683\" viewBox='0,0,300,100'>",
2048,
683,
],
[
'eine unbrauchbare viewBox (Fläche 0) wird durch die aus Breite und Höhe ersetzt, nie doppelt geschrieben',
'<svg viewBox="0 0 0 0" width="200" height="60">',
'<svg width="2048" height="614" viewBox="0 0 200 60">',
2048,
614,
],
[
'dasselbe bei einer viewBox ohne Zahlen',
'<svg viewBox="none" width="200" height="60">',
'<svg width="2048" height="614" viewBox="0 0 200 60">',
2048,
614,
],
[
'unbrauchbare viewBox und keine brauchbare Größe: quadratisch, die viewBox bleibt unberührt',
'<svg viewBox="0 0 0 0">',
'<svg width="2048" height="2048" viewBox="0 0 0 0">',
2048,
2048,
],
['weder viewBox noch Größe: quadratisch, keine viewBox erfunden', `<svg ${NS}>`, `<svg width="2048" height="2048" ${NS}>`, 2048, 2048],
[
'Prozent: unbrauchbar, quadratisch ohne viewBox',
`<svg ${NS} width="100%" height="100%">`,
`<svg width="2048" height="2048" ${NS}>`,
2048,
2048,
],
['Millimeter: unbrauchbar', '<svg width="10mm" height="5mm">', '<svg width="2048" height="2048">', 2048, 2048],
['em: unbrauchbar', '<svg width="2em" height="1em">', '<svg width="2048" height="2048">', 2048, 2048],
['pt: unbrauchbar', '<svg width="10pt" height="5pt">', '<svg width="2048" height="2048">', 2048, 2048],
['auto: unbrauchbar', '<svg width="auto" height="auto">', '<svg width="2048" height="2048">', 2048, 2048],
['nur die Breite: unbrauchbar', '<svg width="200">', '<svg width="2048" height="2048">', 2048, 2048],
['nur die Höhe: unbrauchbar', '<svg height="60">', '<svg width="2048" height="2048">', 2048, 2048],
['Breite 0: unbrauchbar', '<svg width="0" height="60">', '<svg width="2048" height="2048">', 2048, 2048],
['negative Höhe: unbrauchbar', '<svg width="200" height="-60">', '<svg width="2048" height="2048">', 2048, 2048],
['Zahl mit Text dahinter: unbrauchbar', '<svg width="200abc" height="60">', '<svg width="2048" height="2048">', 2048, 2048],
])('%s', (_case, before, after, w, h) => {
expect(sizeSvg(before + BODY)).toEqual({ svg: after + BODY, w, h });
});
it('eine vorhandene eigene Größe wird ersetzt, nicht doppelt geschrieben (doppelte Attribute machen das SVG ungültig)', () => {
const svg = `<svg ${NS} width="100px" height='50' viewBox="0 0 100 50"><g/></svg>`;
const sized = sizeSvgRoot(svg, 2048, 1024);
expect(sized).toBe(`<svg width="2048" height="1024" ${NS} viewBox="0 0 100 50"><g/></svg>`);
expect(countIn(sized, 'width')).toBe(1);
expect(countIn(sized, 'height')).toBe(1);
it('nur das Wurzelelement zählt: viewBox, Breite und Höhe innerer Elemente ändern weder Größe noch Text', () => {
const inner = '<symbol viewBox="0 0 24 24"/><svg width="5" height="5" viewBox="0 0 1 1"/><rect width="1" height="1"/></svg>';
expect(sizeSvg(`<svg width="200" height="60">${inner}`)).toEqual({
svg: `<svg width="2048" height="614" viewBox="0 0 200 60">${inner}`,
w: 2048,
h: 614,
});
});
it('Attribute in Zeilen- und Tabulatorform und mit Leerzeichen um das Gleichheitszeichen werden ebenfalls ersetzt', () => {
const svg = `<svg\n ${NS}\n\twidth = "10"\n height\n="20"\n viewBox="0 0 1 2">`;
const sized = sizeSvgRoot(svg, 1024, 2048);
expect(countIn(sized, 'width')).toBe(1);
expect(countIn(sized, 'height')).toBe(1);
expect(sized.startsWith('<svg width="1024" height="2048"')).toBe(true);
expect(sized).toContain('viewBox="0 0 1 2"');
});
it('Attribute mit ähnlichem Namen (stroke-width, data-height) und Größen innerer Elemente bleiben', () => {
const inner = '<svg width="5" height="5"/><rect width="1" height="1"/></svg>';
const sized = sizeSvgRoot(`<svg ${NS} stroke-width="3" data-height="9" viewBox="0 0 1 1">${inner}`, 2048, 2048);
expect(sized).toBe(`<svg width="2048" height="2048" ${NS} stroke-width="3" data-height="9" viewBox="0 0 1 1">${inner}`);
const { svg, w, h } = sizeSvg(`<svg\n ${NS}\n\twidth = "10"\n height\n="20"\n viewBox="0 0 1 2">${BODY}`);
expect([w, h]).toEqual([1024, 2048]);
const root = /<svg\b[^>]*>/.exec(svg)![0];
expect(root.startsWith('<svg width="1024" height="2048"')).toBe(true);
expect(root.match(/\bwidth\s*=/g)).toHaveLength(1);
expect(root.match(/\bheight\s*=/g)).toHaveLength(1);
expect(root.match(/\bviewBox\s*=/g)).toHaveLength(1);
expect(root).toContain('viewBox="0 0 1 2"');
expect(svg.endsWith(BODY)).toBe(true);
});
it('ein „>“ in einem Attributwert beendet das Wurzelelement nicht', () => {
const svg = `<svg ${NS} id="a>b" width="7" viewBox="0 0 4 2"><g/></svg>`;
expect(sizeSvgRoot(svg, 2048, 1024)).toBe(`<svg width="2048" height="1024" ${NS} id="a>b" viewBox="0 0 4 2"><g/></svg>`);
expect(sizeSvg(`<svg ${NS} id="a>b" width="7" height="3">${BODY}`)).toEqual({
svg: `<svg width="2048" height="878" viewBox="0 0 7 3" ${NS} id="a>b">${BODY}`,
w: 2048,
h: 878,
});
});
it('ein Attributwert mit „width="…"“ darin bleibt, nur das echte Attribut fällt weg', () => {
expect(sizeSvg(`<svg title='so ist width="9" height="9"' width="200" height="60">${BODY}`).svg).toBe(
`<svg width="2048" height="614" viewBox="0 0 200 60" title='so ist width="9" height="9"'>${BODY}`,
);
});
it('XML-Kopf, Kommentar und Doctype vor dem Wurzelelement stören nicht', () => {
const head =
'<?xml version="1.0" encoding="UTF-8"?><!-- Generator: Beispiel --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "x.dtd">';
const sized = sizeSvgRoot(`${head}<svg ${NS} width="1" height="1"/>`, 2048, 2048);
expect(sized).toBe(`${head}<svg width="2048" height="2048" ${NS}/>`);
expect(sizeSvg(`${head}<svg ${NS} width="1" height="1"/>`)).toEqual({
svg: `${head}<svg width="2048" height="2048" viewBox="0 0 1 1" ${NS}/>`,
w: 2048,
h: 2048,
});
});
it('ein selbstschließendes Wurzelelement bleibt gültig', () => {
expect(sizeSvgRoot(`<svg ${NS}/>`, 8, 8)).toBe(`<svg width="8" height="8" ${NS}/>`);
expect(sizeSvg(`<svg ${NS}/>`).svg).toBe(`<svg width="2048" height="2048" ${NS}/>`);
});
it('ohne <svg> bleibt der Text unverändert (das Laden als Bild meldet den Fehler)', () => {
expect(sizeSvgRoot('kein Bild', 8, 8)).toBe('kein Bild');
it('ohne <svg> bleibt der Text unverändert, quadratisch (das Laden als Bild meldet den Fehler)', () => {
expect(sizeSvg('kein Bild')).toEqual({ svg: 'kein Bild', w: 2048, h: 2048 });
});
it('ein Wurzelelement ohne Ende bleibt unverändert', () => {
expect(sizeSvgRoot(`<svg ${NS} width="1`, 8, 8)).toBe(`<svg ${NS} width="1`);
expect(sizeSvgRoot(`<svg ${NS} width="1"`, 8, 8)).toBe(`<svg ${NS} width="1"`);
it('ein Wurzelelement ohne Ende bleibt unverändert, quadratisch', () => {
for (const text of [`<svg ${NS} width="1`, `<svg ${NS} width="1"`]) {
expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 });
}
});
it('ein Text voller offener „<svg x="“ braucht nur linear viel Zeit (kein Hängen im Tab)', () => {
// 160 kB; ein Suchmuster, das an jedem „<svg“ neu ansetzt, bräuchte hier Sekunden.
const hostile = '<svg x="'.repeat(20_000);
const start = performance.now();
expect(sizeSvgRoot(hostile, 2048, 2048)).toBe(hostile);
expect(sizeSvg(hostile).svg).toBe(hostile);
expect(performance.now() - start).toBeLessThan(500);
});
it('sehr viel Leerraum, sehr viele Attribute und sehr lange Zahlen im Wurzelelement brauchen ebenfalls nur linear viel Zeit', () => {
const hostile = [
`<svg${' '.repeat(200_000)}="x">${BODY}`,
`<svg${' a="1"'.repeat(50_000)}>${BODY}`,
`<svg width="${'1'.repeat(200_000)}x" height="1">${BODY}`,
`<svg viewBox="${'1 '.repeat(100_000)}" width="1" height="1">${BODY}`,
];
const start = performance.now();
for (const text of hostile) expect(sizeSvg(text).svg.startsWith('<svg width="2048" height="2048"')).toBe(true);
expect(performance.now() - start).toBeLessThan(500);
});
});
describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
describe('prepareLogo (Bild und Canvas gestellt)', () => {
interface FakeCanvas {
width: number;
height: number;
@@ -328,17 +455,36 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
let revoked: string[];
let images: FakeImage[];
let imageFails: boolean;
const close = vi.fn();
/** Die Maße, die ein geladenes Bild meldet (naturalWidth/naturalHeight). */
let natural: { w: number; h: number };
let decodeCalls: number;
/** Ersatz für `Image`: meldet „geladen“ (oder einen Fehler), sobald `src` gesetzt ist. */
/**
* Ersatz für `Image`, wie `decodeImage` (editor/upload.ts) es braucht: meldet nach dem Setzen von `src` „geladen“ mit den Maßen
* aus `natural` (oder einen Fehler); `decode()` wird gezählt, denn erst danach belegt ein Bild den Speicher.
*/
class FakeImage {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor() {
images.push(this);
}
decode(): Promise<void> {
decodeCalls += 1;
return Promise.resolve();
}
set src(_value: string) {
queueMicrotask(() => (imageFails ? this.onerror?.() : this.onload?.()));
queueMicrotask(() => {
if (imageFails) {
this.onerror?.();
return;
}
this.naturalWidth = natural.w;
this.naturalHeight = natural.h;
this.onload?.();
});
}
}
@@ -376,7 +522,6 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
}) as typeof document.createElement);
}
const bitmap = (width: number, height: number) => vi.fn(async () => ({ width, height, close }));
const rasterFile = () => new File(['x'], 'logo.png', { type: 'image/png' });
const svgFile = (text: string, name = 'logo.svg') => new File([text], name, { type: 'image/svg+xml' });
@@ -386,10 +531,11 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
revoked = [];
images = [];
imageFails = false;
close.mockReset();
natural = { w: 100, h: 40 };
decodeCalls = 0;
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
blobs.push(blob as Blob);
return `blob:svg-${blobs.length}`;
return `blob:test-${blobs.length}`;
});
vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => void revoked.push(url));
vi.stubGlobal('Image', FakeImage);
@@ -401,28 +547,28 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
});
describe('Rasterbild (PNG, JPG, WebP)', () => {
it('zeichnet in Originalgröße, schneidet auf das Sichtbare zu und gibt ein PNG aus', async () => {
const decode = bitmap(100, 40);
vi.stubGlobal('createImageBitmap', decode);
it('lädt die Datei als Bild, zeichnet in Originalgröße, schneidet auf das Sichtbare zu und gibt ein PNG aus', async () => {
stubCanvas({ visible: only(10, 5, 20, 10) });
const file = rasterFile();
const prepared = await prepareLogo(file);
expect(decode).toHaveBeenCalledWith(file);
expect(blobs).toHaveLength(1);
expect(blobs[0]).toBe(file);
expect(decodeCalls).toBe(1);
expect(revoked).toEqual(['blob:test-1']);
expect(canvases).toHaveLength(2);
expect(canvases[0]).toMatchObject({ width: 100, height: 40 });
expect(canvases[0].draws).toEqual([[expect.objectContaining({ width: 100, height: 40 }), 0, 0, 100, 40]]);
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 100, 40]]);
// Ergebnis: genau der sichtbare Ausschnitt, 1:1 (nie vergrößert).
expect(canvases[1]).toMatchObject({ width: 20, height: 10, smoothing: 'high' });
expect(canvases[1].draws).toEqual([[canvases[0], 10, 5, 20, 10, 0, 0, 20, 10]]);
expect(prepared).toMatchObject({ width: 20, height: 10, hasTransparency: false });
expect(prepared.png.type).toBe('image/png');
expect(close).toHaveBeenCalledOnce();
});
it('verkleinert ein großes Logo auf höchstens 720 × 250', async () => {
vi.stubGlobal('createImageBitmap', bitmap(2000, 1000));
natural = { w: 2000, h: 1000 };
stubCanvas({ visible: only(100, 200, 1440, 500) });
const prepared = await prepareLogo(rasterFile());
@@ -433,64 +579,70 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
});
it('hasTransparency kommt aus den Pixeln des fertigen Logos', async () => {
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
stubCanvas({ visible: only(10, 5, 20, 10), resultHole: true });
await expect(prepareLogo(rasterFile())).resolves.toMatchObject({ hasTransparency: true });
});
it('ein Bild über 40 Megapixel wird abgelehnt, bevor ein Canvas entsteht', async () => {
vi.stubGlobal('createImageBitmap', bitmap(8001, 5000));
describe('Pixel-Obergrenze: 40 Megapixel, geprüft nach den Maßen, bevor das Bild dekodiert wird', () => {
it('ein Bild über 40 Megapixel wird nicht dekodiert, nicht gezeichnet und die Object-URL wird freigegeben', async () => {
natural = { w: 8001, h: 5000 };
stubCanvas();
await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS);
expect(decodeCalls).toBe(0);
expect(canvases).toHaveLength(0);
expect(revoked).toEqual(['blob:test-1']);
});
it('auch ein riesiges Bild (20 000 × 20 000) wird nur nach seinen Maßen beurteilt', async () => {
natural = { w: 20_000, h: 20_000 };
stubCanvas();
await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS);
expect(decodeCalls).toBe(0);
expect(canvases).toHaveLength(0);
expect(close).toHaveBeenCalledOnce();
});
it('genau 40 Megapixel sind noch erlaubt (das leere Bild scheitert erst als „leer“)', async () => {
vi.stubGlobal('createImageBitmap', bitmap(8000, 5000));
natural = { w: 8000, h: 5000 };
stubCanvas({ blank: true });
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
expect(decodeCalls).toBe(1);
expect(canvases[0]).toMatchObject({ width: 8000, height: 5000 });
});
});
it('nichts Sichtbares: „Das Logo ist leer.“', async () => {
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
stubCanvas({ visible: () => 0 });
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
expect(close).toHaveBeenCalledOnce();
});
it('unter 8 × 8 Pixel: „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“', async () => {
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
stubCanvas({ visible: only(10, 5, 7, 7) });
await expect(prepareLogo(rasterFile())).rejects.toThrow(SMALL);
});
it('der Dekoder lehnt die Datei ab: „Das Logo konnte nicht gelesen werden.“', async () => {
vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new DOMException('kaputt', 'InvalidStateError')));
it('lässt sich das Bild nicht laden: „Das Logo konnte nicht gelesen werden.“, die Object-URL wird freigegeben', async () => {
imageFails = true;
stubCanvas();
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
expect(canvases).toHaveLength(0);
expect(revoked).toEqual(['blob:test-1']);
});
it('ohne 2D-Canvas: „Das Logo konnte nicht gelesen werden.“, das Bild wird trotzdem freigegeben', async () => {
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
it('ohne 2D-Canvas: „Das Logo konnte nicht gelesen werden.“', async () => {
stubCanvas({ noContext: true });
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
expect(close).toHaveBeenCalledOnce();
});
it('erzeugt der Canvas kein PNG: „Das Logo konnte nicht gelesen werden.“', async () => {
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
stubCanvas({ visible: only(10, 5, 20, 10), noBlob: true });
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
});
it('der Fehlertext steht in der Sprache der Seite', async () => {
setLang('en', { persist: false });
vi.stubGlobal('createImageBitmap', bitmap(8001, 5000));
natural = { w: 8001, h: 5000 };
await expect(prepareLogo(rasterFile())).rejects.toThrow('The image is too large (max. 40 megapixels).');
vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new Error('kaputt')));
imageFails = true;
await expect(prepareLogo(rasterFile())).rejects.toThrow('The logo couldn’t be read.');
});
});
@@ -501,6 +653,7 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
'<script>alert(1)</script><rect width="300" height="100"/></svg>';
it('schreibt die Rastergröße ins Wurzelelement, lädt es nur als Bild und rastert mit 2048 px an der längsten Seite', async () => {
natural = { w: 2048, h: 683 };
stubCanvas({ visible: only(0, 100, 2048, 483) });
const prepared = await prepareLogo(svgFile(SVG));
@@ -510,7 +663,7 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
expect(blobs[0].type).toBe('image/svg+xml');
expect(await blobs[0].text()).toBe(SVG.replace(' width="10" height="5"', '').replace('<svg', '<svg width="2048" height="683"'));
expect(images).toHaveLength(1);
expect(revoked).toEqual(['blob:svg-1']);
expect(revoked).toEqual(['blob:test-1']);
// Das SVG kommt nie in die Seite, Skripte laufen darum nie.
expect(document.querySelector('svg, script, img')).toBeNull();
@@ -523,9 +676,31 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
expect(prepared.png.type).toBe('image/png');
});
it('ohne brauchbare viewBox wird quadratisch mit 2048 × 2048 gerastert', async () => {
it('ohne viewBox, mit Breite und Höhe: das Seitenverhältnis kommt daher, eine viewBox wird ergänzt (der Inhalt skaliert)', async () => {
natural = { w: 2048, h: 614 };
stubCanvas({ visible: only(0, 0, 2048, 614) });
const prepared = await prepareLogo(
svgFile('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><rect width="200" height="60"/></svg>'),
);
expect(await blobs[0].text()).toBe(
'<svg width="2048" height="614" viewBox="0 0 200 60" xmlns="http://www.w3.org/2000/svg"><rect width="200" height="60"/></svg>',
);
expect(canvases[0]).toMatchObject({ width: 2048, height: 614 });
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 614]]);
expect(prepared).toMatchObject({ width: 720, height: 216 });
});
it('ohne viewBox und ohne brauchbare Größe wird quadratisch mit 2048 × 2048 gerastert, ohne erfundene viewBox', async () => {
natural = { w: 2048, h: 2048 };
stubCanvas({ visible: only(0, 0, 200, 100) });
await prepareLogo(svgFile('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100"/></svg>'));
await prepareLogo(
svgFile('<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><rect width="200" height="100"/></svg>'),
);
expect(await blobs[0].text()).toBe(
'<svg width="2048" height="2048" xmlns="http://www.w3.org/2000/svg"><rect width="200" height="100"/></svg>',
);
expect(canvases[0]).toMatchObject({ width: 2048, height: 2048 });
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 2048]]);
});
@@ -540,7 +715,7 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
imageFails = true;
stubCanvas();
await expect(prepareLogo(svgFile('kein Bild'))).rejects.toThrow(READ);
expect(revoked).toEqual(['blob:svg-1']);
expect(revoked).toEqual(['blob:test-1']);
expect(canvases).toHaveLength(0);
});
@@ -551,19 +726,15 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
});
describe('vorab geprüft', () => {
it('ein falscher Typ wird abgelehnt, ohne zu dekodieren', async () => {
const decode = bitmap(10, 10);
vi.stubGlobal('createImageBitmap', decode);
it('ein falscher Typ wird abgelehnt, ohne etwas zu laden', async () => {
await expect(prepareLogo(new File(['x'], 'a.gif', { type: 'image/gif' }))).rejects.toThrow(TYPE);
expect(decode).not.toHaveBeenCalled();
expect(blobs).toHaveLength(0);
});
it('eine zu große Datei wird abgelehnt, ohne zu dekodieren', async () => {
const decode = bitmap(10, 10);
vi.stubGlobal('createImageBitmap', decode);
it('eine zu große Datei wird abgelehnt, ohne etwas zu laden', async () => {
const big = new File([new Uint8Array(5_242_881)], 'a.png', { type: 'image/png' });
await expect(prepareLogo(big)).rejects.toThrow(SIZE);
expect(decode).not.toHaveBeenCalled();
expect(blobs).toHaveLength(0);
});
});
});
+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> {