From 56d841b8e23ae1e002e85c16abf6a73a6d3abdf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:26:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(logos):=20SVG-Gr=C3=B6=C3=9Fe=20aus=20Breit?= =?UTF-8?q?e=20und=20H=C3=B6he=20mit=20erg=C3=A4nzter=20viewBox,=20Pixelgr?= =?UTF-8?q?enze=20vor=20dem=20Dekodieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 ); 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 --- web/src/card/marks.test.ts | 11 +- web/src/card/marks.ts | 12 +- web/src/logos/prepare-logo.test.ts | 353 +++++++++++++++++++++-------- web/src/logos/prepare-logo.ts | 111 ++++++--- 4 files changed, 354 insertions(+), 133 deletions(-) diff --git a/web/src/card/marks.test.ts b/web/src/card/marks.test.ts index 5c587d1..fbe5be8 100644 --- a/web/src/card/marks.test.ts +++ b/web/src/card/marks.test.ts @@ -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('')).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); diff --git a/web/src/card/marks.ts b/web/src/card/marks.ts index 20eb899..70be8d9 100644 --- a/web/src/card/marks.ts +++ b/web/src/card/marks.ts @@ -36,13 +36,17 @@ export function getBrandMark(id: Exclude): 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. */ diff --git a/web/src/logos/prepare-logo.test.ts b/web/src/logos/prepare-logo.test.ts index fd227fb..9afd898 100644 --- a/web/src/logos/prepare-logo.test.ts +++ b/web/src/logos/prepare-logo.test.ts @@ -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 = /^[^]*?(]*>)/.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 = ''; - it('schreibt Breite und Höhe ins Wurzelelement und lässt den Rest unverändert', () => { - const svg = ``; - expect(sizeSvgRoot(svg, 2048, 683)).toBe( - ``, - ); + // [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', + '', + '', + 2048, + 614, + ], + [ + 'Größe in px, auch mit Dezimalstellen', + '', + '', + 2048, + 614, + ], + [ + 'Größe in einfachen Anführungszeichen und mit Leerraum im Wert', + "", + '', + 2048, + 614, + ], + [ + 'hochkant: die Höhe ist die längste Seite', + '', + '', + 614, + 2048, + ], + [ + 'andere Attribute bleiben, in ihrer Reihenfolge', + ``, + ``, + 2048, + 614, + ], + [ + 'eine brauchbare viewBox wird nie überschrieben; Breite und Höhe werden ersetzt', + ``, + ``, + 2048, + 683, + ], + [ + 'eine quadratische viewBox gilt auch bei anderer Größe (die viewBox hat Vorrang)', + '', + '', + 2048, + 2048, + ], + [ + 'viewBox in einfachen Anführungszeichen und mit Kommas', + "", + "", + 2048, + 683, + ], + [ + 'eine unbrauchbare viewBox (Fläche 0) wird durch die aus Breite und Höhe ersetzt, nie doppelt geschrieben', + '', + '', + 2048, + 614, + ], + [ + 'dasselbe bei einer viewBox ohne Zahlen', + '', + '', + 2048, + 614, + ], + [ + 'unbrauchbare viewBox und keine brauchbare Größe: quadratisch, die viewBox bleibt unberührt', + '', + '', + 2048, + 2048, + ], + ['weder viewBox noch Größe: quadratisch, keine viewBox erfunden', ``, ``, 2048, 2048], + [ + 'Prozent: unbrauchbar, quadratisch ohne viewBox', + ``, + ``, + 2048, + 2048, + ], + ['Millimeter: unbrauchbar', '', '', 2048, 2048], + ['em: unbrauchbar', '', '', 2048, 2048], + ['pt: unbrauchbar', '', '', 2048, 2048], + ['auto: unbrauchbar', '', '', 2048, 2048], + ['nur die Breite: unbrauchbar', '', '', 2048, 2048], + ['nur die Höhe: unbrauchbar', '', '', 2048, 2048], + ['Breite 0: unbrauchbar', '', '', 2048, 2048], + ['negative Höhe: unbrauchbar', '', '', 2048, 2048], + ['Zahl mit Text dahinter: unbrauchbar', '', '', 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 = ``; - const sized = sizeSvgRoot(svg, 2048, 1024); - expect(sized).toBe(``); - 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 = ''; + expect(sizeSvg(`${inner}`)).toEqual({ + svg: `${inner}`, + w: 2048, + h: 614, + }); }); it('Attribute in Zeilen- und Tabulatorform und mit Leerzeichen um das Gleichheitszeichen werden ebenfalls ersetzt', () => { - const svg = ``; - const sized = sizeSvgRoot(svg, 1024, 2048); - expect(countIn(sized, 'width')).toBe(1); - expect(countIn(sized, 'height')).toBe(1); - expect(sized.startsWith(' { - const inner = ''; - const sized = sizeSvgRoot(`${inner}`, 2048, 2048); - expect(sized).toBe(`${inner}`); + const { svg, w, h } = sizeSvg(`${BODY}`); + expect([w, h]).toEqual([1024, 2048]); + const root = /]*>/.exec(svg)![0]; + expect(root.startsWith('“ in einem Attributwert beendet das Wurzelelement nicht', () => { - const svg = ``; - expect(sizeSvgRoot(svg, 2048, 1024)).toBe(``); + expect(sizeSvg(`${BODY}`)).toEqual({ + svg: `${BODY}`, + w: 2048, + h: 878, + }); + }); + + it('ein Attributwert mit „width="…"“ darin bleibt, nur das echte Attribut fällt weg', () => { + expect(sizeSvg(`${BODY}`).svg).toBe( + `${BODY}`, + ); }); it('XML-Kopf, Kommentar und Doctype vor dem Wurzelelement stören nicht', () => { const head = ''; - const sized = sizeSvgRoot(`${head}`, 2048, 2048); - expect(sized).toBe(`${head}`); + expect(sizeSvg(`${head}`)).toEqual({ + svg: `${head}`, + w: 2048, + h: 2048, + }); }); it('ein selbstschließendes Wurzelelement bleibt gültig', () => { - expect(sizeSvgRoot(``, 8, 8)).toBe(``); + expect(sizeSvg(``).svg).toBe(``); }); - it('ohne bleibt der Text unverändert (das Laden als Bild meldet den Fehler)', () => { - expect(sizeSvgRoot('kein Bild', 8, 8)).toBe('kein Bild'); + it('ohne 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(` { + for (const text of [` { + const hostile = [ + `${BODY}`, + `${BODY}`, + `${BODY}`, + `${BODY}`, + ]; + const start = performance.now(); + for (const text of hostile) expect(sizeSvg(text).svg.startsWith(' { +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 { + 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)); - stubCanvas(); - await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS); - expect(canvases).toHaveLength(0); - expect(close).toHaveBeenCalledOnce(); - }); + 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('genau 40 Megapixel sind noch erlaubt (das leere Bild scheitert erst als „leer“)', async () => { - vi.stubGlobal('createImageBitmap', bitmap(8000, 5000)); - stubCanvas({ blank: true }); - await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY); - expect(canvases[0]).toMatchObject({ width: 8000, height: 5000 }); + 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); + }); + + it('genau 40 Megapixel sind noch erlaubt (das leere Bild scheitert erst als „leer“)', async () => { + 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)', () => { ''; 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(' { 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(''), + ); + + expect(await blobs[0].text()).toBe( + '', + ); + 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('')); + await prepareLogo( + svgFile(''), + ); + expect(await blobs[0].text()).toBe( + '', + ); 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); }); }); }); diff --git a/web/src/logos/prepare-logo.ts b/web/src/logos/prepare-logo.ts index 6153228..801301f 100644 --- a/web/src/logos/prepare-logo.ts +++ b/web/src/logos/prepare-logo.ts @@ -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 `` 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 "` + * 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 sizeSvgRoot(svg: string, w: number, h: number): string { +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 svg; - const kept = hit[1].replace(/\s(?:width|height)\s*=\s*(?:"[^"]*"|'[^']*')/g, ''); - return `${svg.slice(0, start)}${svg.slice(start + hit[0].length)}`; + 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) -------------------------------------------- @@ -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 { - 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 { 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 { - const url = URL.createObjectURL(new Blob([svg], { type: SVG_TYPE })); - return new Promise((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 { + 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 { - 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 {