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 {