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:
1 parent
0f66136d82
commit
56d841b8e2
4 files changed
+354
-133
No files matched your search
@@ -2,7 +2,7 @@ import { readdirSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||||
import favicon from '../../public/favicon.svg?raw';
|
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'];
|
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);
|
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', () => {
|
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
|
||||||
for (const m of BRAND_MARKS) {
|
for (const m of BRAND_MARKS) {
|
||||||
expect(m.tint, m.id).toBe(true);
|
expect(m.tint, m.id).toBe(true);
|
||||||
|
|||||||
@@ -36,13 +36,17 @@ export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
|
|||||||
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
|
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. */
|
/** Breite durch Höhe aus der viewBox; 1, wenn keine brauchbare viewBox da ist. */
|
||||||
export function svgAspect(svg: string): number {
|
export function svgAspect(svg: string): number {
|
||||||
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
|
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
|
||||||
if (!hit) return 1;
|
return (hit && viewBoxAspect(hit[1])) ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
|
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { setLang } from '../i18n/i18n';
|
import { setLang } from '../i18n/i18n';
|
||||||
import {
|
import {
|
||||||
|
ALPHA_EMPTY,
|
||||||
LOGO_ACCEPT,
|
LOGO_ACCEPT,
|
||||||
|
LOGO_MAX_H,
|
||||||
|
LOGO_MAX_W,
|
||||||
|
LOGO_MIN,
|
||||||
|
LOGO_SOURCE_MAX_BYTES,
|
||||||
|
LOGO_SOURCE_MAX_PIXELS,
|
||||||
|
SVG_RASTER_MAX,
|
||||||
fitWithin,
|
fitWithin,
|
||||||
hasTransparency,
|
hasTransparency,
|
||||||
planLogo,
|
planLogo,
|
||||||
prepareLogo,
|
prepareLogo,
|
||||||
sizeSvgRoot,
|
sizeSvg,
|
||||||
svgRasterSize,
|
svgRasterSize,
|
||||||
trimBox,
|
trimBox,
|
||||||
validateLogoFile,
|
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', () => {
|
describe('validateLogoFile', () => {
|
||||||
const check = (type: string, size = 1000, name = 'logo') => validateLogoFile({ type, size, name });
|
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"';
|
const NS = 'xmlns="http://www.w3.org/2000/svg"';
|
||||||
/** Wie oft das Attribut `name` im öffnenden Tag des Wurzelelements steht. */
|
/** Der Rest hinter dem Wurzelelement; er bleibt immer unverändert. */
|
||||||
const countIn = (svg: string, name: string): number => {
|
const BODY = '<rect width="1" height="1"/></svg>';
|
||||||
const root = /^[^]*?(<svg\b[^>]*>)/.exec(svg)?.[1] ?? '';
|
|
||||||
return (root.match(new RegExp(`\\s${name}\\s*=`, 'g')) ?? []).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('schreibt Breite und Höhe ins Wurzelelement und lässt den Rest unverändert', () => {
|
// [Fall, Wurzelelement vorher, Wurzelelement nachher, Rasterbreite, Rasterhöhe]
|
||||||
const svg = `<svg ${NS} viewBox="0 0 300 100"><rect width="300" height="100"/></svg>`;
|
it.each<[string, string, string, number, number]>([
|
||||||
expect(sizeSvgRoot(svg, 2048, 683)).toBe(
|
[
|
||||||
`<svg width="2048" height="683" ${NS} viewBox="0 0 300 100"><rect width="300" height="100"/></svg>`,
|
'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)', () => {
|
it('nur das Wurzelelement zählt: viewBox, Breite und Höhe innerer Elemente ändern weder Größe noch Text', () => {
|
||||||
const svg = `<svg ${NS} width="100px" height='50' viewBox="0 0 100 50"><g/></svg>`;
|
const inner = '<symbol viewBox="0 0 24 24"/><svg width="5" height="5" viewBox="0 0 1 1"/><rect width="1" height="1"/></svg>';
|
||||||
const sized = sizeSvgRoot(svg, 2048, 1024);
|
expect(sizeSvg(`<svg width="200" height="60">${inner}`)).toEqual({
|
||||||
expect(sized).toBe(`<svg width="2048" height="1024" ${NS} viewBox="0 0 100 50"><g/></svg>`);
|
svg: `<svg width="2048" height="614" viewBox="0 0 200 60">${inner}`,
|
||||||
expect(countIn(sized, 'width')).toBe(1);
|
w: 2048,
|
||||||
expect(countIn(sized, 'height')).toBe(1);
|
h: 614,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Attribute in Zeilen- und Tabulatorform und mit Leerzeichen um das Gleichheitszeichen werden ebenfalls ersetzt', () => {
|
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 { svg, w, h } = sizeSvg(`<svg\n ${NS}\n\twidth = "10"\n height\n="20"\n viewBox="0 0 1 2">${BODY}`);
|
||||||
const sized = sizeSvgRoot(svg, 1024, 2048);
|
expect([w, h]).toEqual([1024, 2048]);
|
||||||
expect(countIn(sized, 'width')).toBe(1);
|
const root = /<svg\b[^>]*>/.exec(svg)![0];
|
||||||
expect(countIn(sized, 'height')).toBe(1);
|
expect(root.startsWith('<svg width="1024" height="2048"')).toBe(true);
|
||||||
expect(sized.startsWith('<svg width="1024" height="2048"')).toBe(true);
|
expect(root.match(/\bwidth\s*=/g)).toHaveLength(1);
|
||||||
expect(sized).toContain('viewBox="0 0 1 2"');
|
expect(root.match(/\bheight\s*=/g)).toHaveLength(1);
|
||||||
});
|
expect(root.match(/\bviewBox\s*=/g)).toHaveLength(1);
|
||||||
|
expect(root).toContain('viewBox="0 0 1 2"');
|
||||||
it('Attribute mit ähnlichem Namen (stroke-width, data-height) und Größen innerer Elemente bleiben', () => {
|
expect(svg.endsWith(BODY)).toBe(true);
|
||||||
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}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein „>“ in einem Attributwert beendet das Wurzelelement nicht', () => {
|
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(sizeSvg(`<svg ${NS} id="a>b" width="7" height="3">${BODY}`)).toEqual({
|
||||||
expect(sizeSvgRoot(svg, 2048, 1024)).toBe(`<svg width="2048" height="1024" ${NS} id="a>b" viewBox="0 0 4 2"><g/></svg>`);
|
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', () => {
|
it('XML-Kopf, Kommentar und Doctype vor dem Wurzelelement stören nicht', () => {
|
||||||
const head =
|
const head =
|
||||||
'<?xml version="1.0" encoding="UTF-8"?><!-- Generator: Beispiel --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "x.dtd">';
|
'<?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(sizeSvg(`${head}<svg ${NS} width="1" height="1"/>`)).toEqual({
|
||||||
expect(sized).toBe(`${head}<svg width="2048" height="2048" ${NS}/>`);
|
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', () => {
|
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)', () => {
|
it('ohne <svg> bleibt der Text unverändert, quadratisch (das Laden als Bild meldet den Fehler)', () => {
|
||||||
expect(sizeSvgRoot('kein Bild', 8, 8)).toBe('kein Bild');
|
expect(sizeSvg('kein Bild')).toEqual({ svg: 'kein Bild', w: 2048, h: 2048 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein Wurzelelement ohne Ende bleibt unverändert', () => {
|
it('ein Wurzelelement ohne Ende bleibt unverändert, quadratisch', () => {
|
||||||
expect(sizeSvgRoot(`<svg ${NS} width="1`, 8, 8)).toBe(`<svg ${NS} width="1`);
|
for (const text of [`<svg ${NS} width="1`, `<svg ${NS} width="1"`]) {
|
||||||
expect(sizeSvgRoot(`<svg ${NS} width="1"`, 8, 8)).toBe(`<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)', () => {
|
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.
|
// 160 kB; ein Suchmuster, das an jedem „<svg“ neu ansetzt, bräuchte hier Sekunden.
|
||||||
const hostile = '<svg x="'.repeat(20_000);
|
const hostile = '<svg x="'.repeat(20_000);
|
||||||
const start = performance.now();
|
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);
|
expect(performance.now() - start).toBeLessThan(500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
|
describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||||
interface FakeCanvas {
|
interface FakeCanvas {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -328,17 +455,36 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
|
|||||||
let revoked: string[];
|
let revoked: string[];
|
||||||
let images: FakeImage[];
|
let images: FakeImage[];
|
||||||
let imageFails: boolean;
|
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 {
|
class FakeImage {
|
||||||
|
naturalWidth = 0;
|
||||||
|
naturalHeight = 0;
|
||||||
onload: (() => void) | null = null;
|
onload: (() => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
constructor() {
|
constructor() {
|
||||||
images.push(this);
|
images.push(this);
|
||||||
}
|
}
|
||||||
|
decode(): Promise<void> {
|
||||||
|
decodeCalls += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
set src(_value: string) {
|
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);
|
}) 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 rasterFile = () => new File(['x'], 'logo.png', { type: 'image/png' });
|
||||||
const svgFile = (text: string, name = 'logo.svg') => new File([text], name, { type: 'image/svg+xml' });
|
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 = [];
|
revoked = [];
|
||||||
images = [];
|
images = [];
|
||||||
imageFails = false;
|
imageFails = false;
|
||||||
close.mockReset();
|
natural = { w: 100, h: 40 };
|
||||||
|
decodeCalls = 0;
|
||||||
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
|
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
|
||||||
blobs.push(blob as 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.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => void revoked.push(url));
|
||||||
vi.stubGlobal('Image', FakeImage);
|
vi.stubGlobal('Image', FakeImage);
|
||||||
@@ -401,28 +547,28 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Rasterbild (PNG, JPG, WebP)', () => {
|
describe('Rasterbild (PNG, JPG, WebP)', () => {
|
||||||
it('zeichnet in Originalgröße, schneidet auf das Sichtbare zu und gibt ein PNG aus', async () => {
|
it('lädt die Datei als Bild, zeichnet in Originalgröße, schneidet auf das Sichtbare zu und gibt ein PNG aus', async () => {
|
||||||
const decode = bitmap(100, 40);
|
|
||||||
vi.stubGlobal('createImageBitmap', decode);
|
|
||||||
stubCanvas({ visible: only(10, 5, 20, 10) });
|
stubCanvas({ visible: only(10, 5, 20, 10) });
|
||||||
const file = rasterFile();
|
const file = rasterFile();
|
||||||
|
|
||||||
const prepared = await prepareLogo(file);
|
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).toHaveLength(2);
|
||||||
expect(canvases[0]).toMatchObject({ width: 100, height: 40 });
|
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).
|
// Ergebnis: genau der sichtbare Ausschnitt, 1:1 (nie vergrößert).
|
||||||
expect(canvases[1]).toMatchObject({ width: 20, height: 10, smoothing: 'high' });
|
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(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).toMatchObject({ width: 20, height: 10, hasTransparency: false });
|
||||||
expect(prepared.png.type).toBe('image/png');
|
expect(prepared.png.type).toBe('image/png');
|
||||||
expect(close).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verkleinert ein großes Logo auf höchstens 720 × 250', async () => {
|
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) });
|
stubCanvas({ visible: only(100, 200, 1440, 500) });
|
||||||
|
|
||||||
const prepared = await prepareLogo(rasterFile());
|
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 () => {
|
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 });
|
stubCanvas({ visible: only(10, 5, 20, 10), resultHole: true });
|
||||||
await expect(prepareLogo(rasterFile())).resolves.toMatchObject({ hasTransparency: true });
|
await expect(prepareLogo(rasterFile())).resolves.toMatchObject({ hasTransparency: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein Bild über 40 Megapixel wird abgelehnt, bevor ein Canvas entsteht', async () => {
|
describe('Pixel-Obergrenze: 40 Megapixel, geprüft nach den Maßen, bevor das Bild dekodiert wird', () => {
|
||||||
vi.stubGlobal('createImageBitmap', bitmap(8001, 5000));
|
it('ein Bild über 40 Megapixel wird nicht dekodiert, nicht gezeichnet und die Object-URL wird freigegeben', async () => {
|
||||||
stubCanvas();
|
natural = { w: 8001, h: 5000 };
|
||||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS);
|
stubCanvas();
|
||||||
expect(canvases).toHaveLength(0);
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS);
|
||||||
expect(close).toHaveBeenCalledOnce();
|
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 () => {
|
it('auch ein riesiges Bild (20 000 × 20 000) wird nur nach seinen Maßen beurteilt', async () => {
|
||||||
vi.stubGlobal('createImageBitmap', bitmap(8000, 5000));
|
natural = { w: 20_000, h: 20_000 };
|
||||||
stubCanvas({ blank: true });
|
stubCanvas();
|
||||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS);
|
||||||
expect(canvases[0]).toMatchObject({ width: 8000, height: 5000 });
|
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 () => {
|
it('nichts Sichtbares: „Das Logo ist leer.“', async () => {
|
||||||
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
|
|
||||||
stubCanvas({ visible: () => 0 });
|
stubCanvas({ visible: () => 0 });
|
||||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
|
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 () => {
|
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) });
|
stubCanvas({ visible: only(10, 5, 7, 7) });
|
||||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(SMALL);
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(SMALL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('der Dekoder lehnt die Datei ab: „Das Logo konnte nicht gelesen werden.“', async () => {
|
it('lässt sich das Bild nicht laden: „Das Logo konnte nicht gelesen werden.“, die Object-URL wird freigegeben', async () => {
|
||||||
vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new DOMException('kaputt', 'InvalidStateError')));
|
imageFails = true;
|
||||||
stubCanvas();
|
stubCanvas();
|
||||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||||
expect(canvases).toHaveLength(0);
|
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 () => {
|
it('ohne 2D-Canvas: „Das Logo konnte nicht gelesen werden.“', async () => {
|
||||||
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
|
|
||||||
stubCanvas({ noContext: true });
|
stubCanvas({ noContext: true });
|
||||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||||
expect(close).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erzeugt der Canvas kein PNG: „Das Logo konnte nicht gelesen werden.“', async () => {
|
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 });
|
stubCanvas({ visible: only(10, 5, 20, 10), noBlob: true });
|
||||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('der Fehlertext steht in der Sprache der Seite', async () => {
|
it('der Fehlertext steht in der Sprache der Seite', async () => {
|
||||||
setLang('en', { persist: false });
|
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).');
|
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.');
|
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>';
|
'<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 () => {
|
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) });
|
stubCanvas({ visible: only(0, 100, 2048, 483) });
|
||||||
|
|
||||||
const prepared = await prepareLogo(svgFile(SVG));
|
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(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(await blobs[0].text()).toBe(SVG.replace(' width="10" height="5"', '').replace('<svg', '<svg width="2048" height="683"'));
|
||||||
expect(images).toHaveLength(1);
|
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.
|
// Das SVG kommt nie in die Seite, Skripte laufen darum nie.
|
||||||
expect(document.querySelector('svg, script, img')).toBeNull();
|
expect(document.querySelector('svg, script, img')).toBeNull();
|
||||||
|
|
||||||
@@ -523,9 +676,31 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
|
|||||||
expect(prepared.png.type).toBe('image/png');
|
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) });
|
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]).toMatchObject({ width: 2048, height: 2048 });
|
||||||
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 2048]]);
|
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 2048]]);
|
||||||
});
|
});
|
||||||
@@ -540,7 +715,7 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
|
|||||||
imageFails = true;
|
imageFails = true;
|
||||||
stubCanvas();
|
stubCanvas();
|
||||||
await expect(prepareLogo(svgFile('kein Bild'))).rejects.toThrow(READ);
|
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);
|
expect(canvases).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,19 +726,15 @@ describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('vorab geprüft', () => {
|
describe('vorab geprüft', () => {
|
||||||
it('ein falscher Typ wird abgelehnt, ohne zu dekodieren', async () => {
|
it('ein falscher Typ wird abgelehnt, ohne etwas zu laden', async () => {
|
||||||
const decode = bitmap(10, 10);
|
|
||||||
vi.stubGlobal('createImageBitmap', decode);
|
|
||||||
await expect(prepareLogo(new File(['x'], 'a.gif', { type: 'image/gif' }))).rejects.toThrow(TYPE);
|
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 () => {
|
it('eine zu große Datei wird abgelehnt, ohne etwas zu laden', async () => {
|
||||||
const decode = bitmap(10, 10);
|
|
||||||
vi.stubGlobal('createImageBitmap', decode);
|
|
||||||
const big = new File([new Uint8Array(5_242_881)], 'a.png', { type: 'image/png' });
|
const big = new File([new Uint8Array(5_242_881)], 'a.png', { type: 'image/png' });
|
||||||
await expect(prepareLogo(big)).rejects.toThrow(SIZE);
|
await expect(prepareLogo(big)).rejects.toThrow(SIZE);
|
||||||
expect(decode).not.toHaveBeenCalled();
|
expect(blobs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
// 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
|
// 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);
|
// (deploy/api/logos.js), der trotzdem selbst prüft. Zuschnitt, Skalierung, Transparenz und die Größe eines SVGs sind reine Helfer
|
||||||
// `prepareLogo` fügt sie mit dem Canvas des Browsers zusammen. Kein Netzwerk, kein Appwrite.
|
// (einzeln testbar); `prepareLogo` fügt sie mit dem Canvas des Browsers zusammen. Kein Netzwerk, kein Appwrite.
|
||||||
import { svgAspect } from '../card/marks';
|
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';
|
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. */
|
/** 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;
|
export const LOGO_MIN = 8;
|
||||||
/** Pixel mit Alpha bis hierher gelten als leer (nicht sichtbar). */
|
/** Pixel mit Alpha bis hierher gelten als leer (nicht sichtbar). */
|
||||||
export const ALPHA_EMPTY = 8;
|
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). */
|
/** 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;
|
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). */
|
/** 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_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`.
|
// 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.');
|
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 };
|
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`
|
* Macht den Text eines SVGs rasterbar: Das Seitenverhältnis kommt aus der viewBox des Wurzelelements, wenn sie brauchbar ist, sonst
|
||||||
* in card/marks.ts). Eine eigene `width`/`height` des Wurzelelements fällt dabei weg: ein doppeltes Attribut machte das SVG ungültig.
|
* aus dessen Breite und Höhe in Pixeln (oder ohne Einheit), sonst ist es quadratisch (Prozent und andere Einheiten zählen nicht).
|
||||||
* Nur das erste `<svg …>` wird angefasst; ohne eines (oder ohne dessen Ende) bleibt der Text unverändert.
|
* 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/);
|
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
|
// 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.
|
// 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;
|
const tag = /<svg\b((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
|
||||||
tag.lastIndex = start;
|
tag.lastIndex = start;
|
||||||
const hit = tag.exec(svg);
|
const hit = tag.exec(svg);
|
||||||
if (!hit) return svg;
|
if (!hit) return unchanged;
|
||||||
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)}`;
|
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) --------------------------------------------
|
// ---- 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 };
|
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> {
|
* Lädt eine Bilddatei als Bild (nie als Teil der Seite) mit `decodeImage` des Editors: erst werden die Maße gelesen und gegen die
|
||||||
const bitmap = await createImageBitmap(file);
|
* 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 {
|
try {
|
||||||
if (bitmap.width * bitmap.height > LOGO_SOURCE_MAX_PIXELS) throw problem(LOGO_PIXELS_MESSAGE);
|
return await decodeImage(blob);
|
||||||
return draw(bitmap, bitmap.width, bitmap.height);
|
} catch (err) {
|
||||||
} finally {
|
if (err instanceof Error && err.message === PIXELS_MESSAGE) throw problem(LOGO_PIXELS_MESSAGE);
|
||||||
bitmap.close();
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt ein SVG als Bild, nie als Teil der Seite: so laufen Skripte darin nicht und es lädt nichts nach. */
|
/** PNG, JPG oder WebP: laden und in Originalgröße zeichnen. */
|
||||||
function loadSvgImage(svg: string): Promise<HTMLImageElement> {
|
async function drawRaster(file: File): Promise<Drawn> {
|
||||||
const url = URL.createObjectURL(new Blob([svg], { type: SVG_TYPE }));
|
const { source, w, h } = await decode(file);
|
||||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
return draw(source, w, h);
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => reject(problem(LOGO_READ_MESSAGE));
|
|
||||||
img.src = url;
|
|
||||||
}).finally(() => URL.revokeObjectURL(url));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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> {
|
async function drawSvg(file: File): Promise<Drawn> {
|
||||||
const text = await file.text();
|
const { svg, w, h } = sizeSvg(await file.text());
|
||||||
const { w, h } = svgRasterSize(svgAspect(text));
|
const { source } = await decode(new Blob([svg], { type: SVG_TYPE }));
|
||||||
return draw(await loadSvgImage(sizeSvgRoot(text, w, h)), w, h);
|
return draw(source, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
|
function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
|
|||||||
Reference in new issue
Block a user