fix(logos): SVG-Wurzelelement richtig finden, Quell-Canvas und Pixelpuffer früh freigeben, Konstanten umbenannt

- prepare-logo.ts, sizeSvg: Das Wurzelelement wird nicht mehr mit der ersten Fundstelle von „<svg“ gesucht. rootStart überspringt Byte-Order-Marke,
  Leerraum, XML-Deklaration und Anweisungen (<?…?>), Kommentare und den DOCTYPE (auch mit eigener DTD in […] samt Zeichenketten, Kommentaren und
  Anweisungen darin) und verlangt dahinter `<svg` mit Leerraum, „/“ oder „>“. Ein auskommentiertes <svg …> oder eines im DOCTYPE wurde sonst
  umgeschrieben, ein `<svg:svg …>` (Präfix) oder `<svg-x` zerstörte das Tag. Beides bleibt jetzt unverändert (quadratisch), wie ein Text ohne
  Wurzelelement. Ein Durchlauf ohne Zurücksetzen, auch ein feindlicher Prolog braucht nur linear viel Zeit.
- prepare-logo.ts, prepareLogo: renderLogo zeichnet das Quellbild auf den Quell-Canvas, liest den Pixelpuffer einmal als Argument von planLogo
  (keine Referenz danach) und setzt den Quell-Canvas im finally auf 0 × 0, noch vor dem PNG und auch bei Fehlern. Das Ergebnis ist gleich.
- prepare-logo.ts: LOGO_MAX_W/LOGO_MAX_H (720/250, die Dateigrenze) heißen LOGO_FILE_MAX_W/LOGO_FILE_MAX_H und kollidieren nicht mehr im Namen mit
  LOGO_MAX_H aus card/geometry.ts (125, Höhe auf der Karte). Einziger Verwender ist der Test.
- Tests: Prolog-Fälle (Kommentar, Anweisung, DOCTYPE mit DTD, BOM, mehrere, Präfix, Bindestrich, nicht endender Prolog, feindliche Eingaben mit
  Zeitgrenze); Quell-Canvas ist frei (auch vor toBlob, bei leer, zu klein, Zeichenfehler, fehlendem Ergebnis-Canvas, fehlendem PNG), der Pixelpuffer
  wird einmal gelesen. Die vorhandenen Größenprüfungen lesen die Größe beim Zeichnen (`used`), weil der Quell-Canvas am Ende 0 × 0 ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:49:04 +00:00
1 parent 76c3c3aea2
commit 29c9b2efaf
2 files changed
+303 -49

No files matched your search

+200 -11
View File
@@ -3,8 +3,8 @@ import { setLang } from '../i18n/i18n';
import {
ALPHA_EMPTY,
LOGO_ACCEPT,
LOGO_MAX_H,
LOGO_MAX_W,
LOGO_FILE_MAX_H,
LOGO_FILE_MAX_W,
LOGO_MIN,
LOGO_SOURCE_MAX_BYTES,
LOGO_SOURCE_MAX_PIXELS,
@@ -48,7 +48,7 @@ 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_FILE_MAX_W, LOGO_FILE_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]);
});
});
@@ -407,6 +407,116 @@ describe('sizeSvg', () => {
});
});
describe('das echte Wurzelelement: Prolog (Deklaration, Anweisungen, Kommentare, DOCTYPE) wird übersprungen, ein „<svg“ darin zählt nicht', () => {
const ROOT_BEFORE = '<svg width="200" height="60">';
const ROOT_AFTER = '<svg width="2048" height="614" viewBox="0 0 200 60">';
const converted = (head: string) => ({ svg: `${head}${ROOT_AFTER}${BODY}`, w: 2048, h: 614 });
it('ein auskommentiertes <svg …> vor dem Wurzelelement wird nicht umgeschrieben', () => {
const head = '<!-- Vorlage: <svg width="9" height="9"> bitte nicht ändern -->';
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
});
it('ein <svg in einer Verarbeitungsanweisung zählt nicht', () => {
const head = '<?xml version="1.0"?><?editor <svg width="9" height="9"> ?>';
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
});
it('ein <svg im DOCTYPE (in einer Entität der eigenen DTD, samt „>“ und „]“ in Anführungszeichen) zählt nicht', () => {
const head =
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\n' +
' <!ENTITY ns_svg "http://www.w3.org/2000/svg">\n' +
' <!ENTITY stuck "<svg width=\'9\' height=\'9\'>]>">\n' +
' <!-- ein Kommentar mit ]> und <svg width="9" height="9"> -->\n' +
' <?pi ]> <svg width="9" height="9"> ?>\n' +
']>\n';
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
});
it('ein typischer Kopf aus Adobe Illustrator (Deklaration, Kommentar, DOCTYPE mit Entitäten, Namensräume über &ns_svg;) wird übersprungen', () => {
const head =
'<?xml version="1.0" encoding="utf-8"?>\n' +
'<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->\n' +
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\n' +
'\t<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">\n' +
'\t<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">\n' +
'\t<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">\n' +
']>\n';
const root = '<svg version="1.1" id="Layer_1" xmlns="&ns_svg;" x="0px" y="0px" width="200px" height="60px" viewBox="0 0 200 60">';
const result = sizeSvg(`${head}${root}${BODY}`);
expect(result.w).toBe(2048);
expect(result.h).toBe(614);
expect(result.svg).toBe(
`${head}<svg width="2048" height="614" version="1.1" id="Layer_1" xmlns="&ns_svg;" x="0px" y="0px" viewBox="0 0 200 60">${BODY}`,
);
});
it('Byte-Order-Marke und Leerraum vor dem Prolog stören nicht', () => {
const head = '\uFEFF \n\t<?xml version="1.0"?>\n\n<!-- x -->\r\n';
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
});
it('mehrere Kommentare und Anweisungen hintereinander', () => {
const head = '<!--a--><?b?><!--c--> <?d e?><!---->';
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
});
it.each([
['mit Namensraum-Präfix', '<svg:svg xmlns:svg="http://www.w3.org/2000/svg" width="200" height="60">'],
['mit Bindestrich', '<svg-icon width="200" height="60">'],
['mit Punkt', '<svg.x width="200" height="60">'],
['mit Ziffer', '<svg2 width="200" height="60">'],
])('ein Wurzelelement, das nur mit „svg“ beginnt (%s), ist keins: der Text bleibt unverändert, quadratisch', (_name, root) => {
expect(sizeSvg(`${root}${BODY}`)).toEqual({ svg: `${root}${BODY}`, w: 2048, h: 2048 });
});
it('ein „<svg:svg“ im Inneren oder in einem Kommentar macht daraus ebenfalls kein Wurzelelement', () => {
const text = `<!-- <svg width="9" height="9"> --><svg:svg width="200" height="60">${BODY}`;
expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 });
});
it('vor dem Wurzelelement steht etwas anderes (Text, ein anderes Element): es ist kein SVG, der Text bleibt unverändert', () => {
for (const text of [`Hallo ${ROOT_BEFORE}${BODY}`, `<html><body>${ROOT_BEFORE}${BODY}</body></html>`, `<g/>${ROOT_BEFORE}${BODY}`]) {
expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 });
}
});
it('<svg ohne Attribute, mit Zeilenumbruch oder mit „/“ direkt dahinter ist ein Wurzelelement', () => {
expect(sizeSvg('<svg>' + BODY)).toEqual({ svg: '<svg width="2048" height="2048">' + BODY, w: 2048, h: 2048 });
expect(sizeSvg('<svg\nwidth="200"\nheight="60">' + BODY).svg).toBe(ROOT_AFTER + BODY);
expect(sizeSvg('<svg/>').svg).toBe('<svg width="2048" height="2048"/>');
});
it('ein Prolog, der nicht endet (Kommentar, Anweisung, DOCTYPE, Anführungszeichen), ergibt kein Wurzelelement: unverändert, quadratisch', () => {
for (const text of [
`<!-- offen ${ROOT_BEFORE}${BODY}`,
`<?xml version="1.0" ${ROOT_BEFORE}${BODY}`,
`<!DOCTYPE svg [ ${ROOT_BEFORE}${BODY}`,
`<!DOCTYPE svg PUBLIC "offen ${ROOT_BEFORE}${BODY}`,
'<!DOCTYPE svg',
]) {
expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 });
}
});
it('ein feindlicher Prolog braucht nur linear viel Zeit (kein Hängen im Tab)', () => {
const hostile = [
'<!--'.repeat(100_000),
'<?'.repeat(100_000),
`<!DOCTYPE x [${'<!-- '.repeat(50_000)}`,
`<!DOCTYPE x ${'" '.repeat(100_000)}`,
`<!DOCTYPE x [${"'".repeat(100_000)}`,
`<!DOCTYPE x [${'<?a '.repeat(50_000)}`,
`${' '.repeat(200_000)}<!--`,
'<!---->'.repeat(30_000) + `${ROOT_BEFORE}${BODY}`,
];
const start = performance.now();
for (const text of hostile) sizeSvg(text);
expect(performance.now() - start).toBeLessThan(500);
expect(sizeSvg(hostile.at(-1)!).svg.endsWith(`${ROOT_AFTER}${BODY}`)).toBe(true);
});
});
it('ein selbstschließendes Wurzelelement bleibt gültig', () => {
expect(sizeSvg(`<svg ${NS}/>`).svg).toBe(`<svg width="2048" height="2048" ${NS}/>`);
});
@@ -449,8 +559,14 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
/** Argumente aller `drawImage`-Aufrufe. */
draws: unknown[][];
smoothing: string;
/** Die Größe, als zum ersten Mal darauf gezeichnet oder von ihm gelesen wurde (danach kann der Canvas freigegeben, also 0 × 0, sein). */
used: [number, number] | null;
/** Wie oft seine Pixel gelesen wurden (`getImageData`). */
reads: number;
}
let canvases: FakeCanvas[];
/** Die Größen aller Canvas im Moment, in dem das PNG erzeugt wird (`toBlob`). */
let sizesAtBlob: Array<[number, number]> | null;
let blobs: Blob[];
let revoked: string[];
let images: FakeImage[];
@@ -502,6 +618,8 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
resultHole?: boolean;
noBlob?: boolean;
noContext?: boolean;
/** Nur der Ergebnis-Canvas (der zweite) hat keinen 2D-Kontext. */
resultNoContext?: boolean;
/** `drawImage` wirft diesen Fehler. */
drawError?: unknown;
} = {},
@@ -510,16 +628,18 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
if (tag !== 'canvas') return create(tag);
const isSource = canvases.length === 0;
const canvas: FakeCanvas = { width: 0, height: 0, draws: [], smoothing: '' };
const canvas: FakeCanvas = { width: 0, height: 0, draws: [], smoothing: '', used: null, reads: 0 };
const ctx = {
set imageSmoothingQuality(value: string) {
canvas.smoothing = value;
},
drawImage: (...args: unknown[]) => {
canvas.used ??= [canvas.width, canvas.height];
if (opts.drawError) throw opts.drawError;
canvas.draws.push(args);
},
getImageData: (_x: number, _y: number, w: number, h: number) => ({
...((canvas.used ??= [canvas.width, canvas.height]), (canvas.reads += 1), {}),
data: isSource
? opts.blank
? new Uint8ClampedArray(w * h * 4)
@@ -529,8 +649,11 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
};
canvases.push(canvas);
return Object.assign(canvas, {
getContext: () => (opts.noContext ? null : ctx),
toBlob: (done: BlobCallback, type?: string) => done(opts.noBlob ? null : new Blob(['png'], { type })),
getContext: () => (opts.noContext || (opts.resultNoContext && !isSource) ? null : ctx),
toBlob: (done: BlobCallback, type?: string) => {
sizesAtBlob = canvases.map((c) => [c.width, c.height]);
done(opts.noBlob ? null : new Blob(['png'], { type }));
},
}) as unknown as HTMLElement;
}) as typeof document.createElement);
}
@@ -540,6 +663,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
beforeEach(() => {
canvases = [];
sizesAtBlob = null;
blobs = [];
revoked = [];
images = [];
@@ -572,7 +696,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
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].used).toEqual([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' });
@@ -620,7 +744,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
stubCanvas({ blank: true });
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
expect(decodeCalls).toBe(1);
expect(canvases[0]).toMatchObject({ width: 8000, height: 5000 });
expect(canvases[0].used).toEqual([8000, 5000]);
});
});
@@ -681,7 +805,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
// Das SVG kommt nie in die Seite, Skripte laufen darum nie.
expect(document.querySelector('svg, script, img')).toBeNull();
expect(canvases[0]).toMatchObject({ width: 2048, height: 683 });
expect(canvases[0].used).toEqual([2048, 683]);
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 683]]);
// Zuschnitt auf das Sichtbare (2048 × 483), dann auf die Zielgröße verkleinert.
expect(canvases[1]).toMatchObject({ width: 720, height: 170 });
@@ -701,7 +825,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
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].used).toEqual([2048, 614]);
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 614]]);
expect(prepared).toMatchObject({ width: 720, height: 216 });
});
@@ -715,7 +839,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
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].used).toEqual([2048, 2048]);
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 2048]]);
});
@@ -739,6 +863,71 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
});
});
describe('Speicher: der Quell-Canvas wird frühzeitig freigegeben (0 × 0), der Pixelpuffer nur einmal gelesen', () => {
const released = (canvas: FakeCanvas) => [canvas.width, canvas.height];
it('Rasterbild: nach dem Zuschnitt ist der Quell-Canvas frei, schon bevor das PNG entsteht; das Ergebnis behält seine Größe', async () => {
stubCanvas({ visible: only(10, 5, 20, 10) });
await prepareLogo(rasterFile());
expect(canvases[0].used).toEqual([100, 40]);
expect(released(canvases[0])).toEqual([0, 0]);
expect(sizesAtBlob).toEqual([
[0, 0],
[20, 10],
]);
expect(canvases[1]).toMatchObject({ width: 20, height: 10 });
});
it('SVG: ebenso', async () => {
natural = { w: 2048, h: 614 };
stubCanvas({ visible: only(0, 0, 2048, 614) });
await prepareLogo(svgFile('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"/>'));
expect(canvases[0].used).toEqual([2048, 614]);
expect(sizesAtBlob?.[0]).toEqual([0, 0]);
});
it('der Pixelpuffer des Quellbildes wird genau einmal gelesen (für den Zuschnitt), nicht aufgehoben', async () => {
stubCanvas({ visible: only(10, 5, 20, 10) });
await prepareLogo(rasterFile());
expect(canvases[0].reads).toBe(1);
expect(canvases[1].reads).toBe(1); // das Ergebnis: nur für „hat Transparenz“
});
it.each([
['leer', { visible: () => 0 }, EMPTY],
['zu klein', { visible: only(10, 5, 7, 7) }, SMALL],
])('auch wenn das Logo %s ist: der Quell-Canvas ist freigegeben', async (_name, opts, message) => {
stubCanvas(opts);
await expect(prepareLogo(rasterFile())).rejects.toThrow(message);
expect(canvases).toHaveLength(1); // ein Ergebnis-Canvas wird gar nicht erst angelegt
expect(released(canvases[0])).toEqual([0, 0]);
});
it('auch wenn das Zeichnen des Quellbildes scheitert', async () => {
stubCanvas({ drawError: new DOMException('kaputt', 'InvalidStateError') });
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
expect(released(canvases[0])).toEqual([0, 0]);
});
it('auch wenn der Ergebnis-Canvas keinen 2D-Kontext hat', async () => {
stubCanvas({ visible: only(10, 5, 20, 10), resultNoContext: true });
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
expect(canvases).toHaveLength(2);
expect(released(canvases[0])).toEqual([0, 0]);
});
it('auch wenn das PNG nicht entsteht', async () => {
stubCanvas({ visible: only(10, 5, 20, 10), noBlob: true });
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
expect(released(canvases[0])).toEqual([0, 0]);
});
it('ohne 2D-Canvas für das Quellbild bleibt es bei der Meldung (es gibt nichts freizugeben)', async () => {
stubCanvas({ noContext: true });
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
});
});
describe('unerwartete Fehler: eine Warnung mit dem Originalfehler in der Konsole, der Nutzer liest weiter „nicht gelesen“', () => {
it('der Browser wirft beim Zeichnen: genau eine Warnung mit genau diesem Fehler, die Meldung bleibt unverändert', async () => {
const boom = new DOMException('The source image could not be decoded.', 'InvalidStateError');
+103 -38
View File
@@ -2,6 +2,7 @@
// 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, 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.
// Das Quellbild belegt bis zu 40 Megapixel: sein Canvas und der Pixelpuffer sind nach dem Zuschnitt sofort wieder frei.
import { viewBoxAspect } from '../card/marks';
import type { CardImage } from '../card/render';
import { MAX_IMAGE_PIXELS, PIXELS_MESSAGE, decodeImage } from '../editor/upload';
@@ -10,9 +11,12 @@ 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. */
export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg';
/** Höchstmaße und Mindestmaß des fertigen Logos in Pixeln (wie der Dienst sie prüft). */
export const LOGO_MAX_W = 720;
export const LOGO_MAX_H = 250;
/**
* Höchstmaße und Mindestmaß der fertigen Logo-Datei in Pixeln (wie der Dienst sie prüft). Nicht zu verwechseln mit `LOGO_MAX_H`
* in card/geometry.ts: das ist die Höhe, bis zu der das Logo auf der Karte wächst.
*/
export const LOGO_FILE_MAX_W = 720;
export const LOGO_FILE_MAX_H = 250;
export const LOGO_MIN = 8;
/** Pixel mit Alpha bis hierher gelten als leer (nicht sichtbar). */
export const ALPHA_EMPTY = 8;
@@ -123,7 +127,7 @@ export function hasTransparency(data: Uint8ClampedArray): boolean {
export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box: Box; size: Size } {
const box = trimBox(data, w, h);
if (!box) throw new LogoError(t(LOGO_EMPTY_MESSAGE));
const size = fitWithin(box.w, box.h, LOGO_MAX_W, LOGO_MAX_H);
const size = fitWithin(box.w, box.h, LOGO_FILE_MAX_W, LOGO_FILE_MAX_H);
if (size.w < LOGO_MIN || size.h < LOGO_MIN) throw new LogoError(t(LOGO_SMALL_MESSAGE));
return { box, size };
}
@@ -144,6 +148,65 @@ function pixels(value: string | undefined): number | null {
return n > 0 && Number.isFinite(n) ? n : null;
}
/** Leerraum samt Byte-Order-Marke (U+FEFF zählt in `\s` mit). */
const BLANK = /\s*/y;
/** Der Anfang des Wurzelelements: `<svg`, dahinter Leerraum, `/` oder `>`; `<svg:svg` (mit Präfix) und `<svg-x` sind andere Elemente. */
const ROOT = /<svg(?=[\s/>])/y;
/** Was in einem DOCTYPE ein „>“ oder „]“ enthalten darf, ohne ihn zu beenden: Anführungszeichen, Kommentare und Anweisungen (Anfang, Ende). */
const QUOTED: ReadonlyArray<readonly [string, string]> = [['"', '"'], ["'", "'"], ['<!--', '-->'], ['<?', '?>']];
/** Die Stelle hinter dem ersten `marker` ab `from`; -1, wenn er nicht mehr vorkommt. */
function after(text: string, marker: string, from: number): number {
const at = text.indexOf(marker, from);
return at < 0 ? -1 : at + marker.length;
}
/**
* Das Ende eines DOCTYPE, der bei `start` beginnt (die Stelle hinter dem schließenden „>“); -1, wenn er nicht endet. Zeichenketten,
* Kommentare und Anweisungen darin (auch in der eigenen DTD in `[…]`) können „>“, „]“ und „<svg“ enthalten und zählen nicht als Ende.
*/
function doctypeEnd(text: string, start: number): number {
let inSubset = false;
for (let i = start + '<!DOCTYPE'.length; i < text.length; i++) {
const quoted = QUOTED.find(([open]) => text.startsWith(open, i));
if (quoted) {
const end = after(text, quoted[1], i + quoted[0].length);
if (end < 0) return -1;
i = end - 1;
} else if (text[i] === '[') {
inSubset = true;
} else if (text[i] === ']') {
inSubset = false;
} else if (text[i] === '>' && !inSubset) {
return i + 1;
}
}
return -1;
}
/**
* Wo das Wurzelelement `<svg …>` beginnt; -1, wenn der Text nicht damit anfängt. Davor dürfen nur Leerraum (auch eine Byte-Order-Marke),
* die XML-Deklaration und andere Anweisungen (`<?…?>`), Kommentare (`<!--…-->`) und ein DOCTYPE stehen; ein `<svg` darin (etwa in einem
* auskommentierten Beispiel) ist kein Wurzelelement. Ein Durchlauf von vorn nach hinten ohne Zurücksetzen: auch eine feindliche Datei
* braucht nur linear viel Zeit.
*/
function rootStart(svg: string): number {
let i = 0;
for (;;) {
BLANK.lastIndex = i;
BLANK.test(svg);
i = BLANK.lastIndex;
if (svg.startsWith('<?', i)) i = after(svg, '?>', i + 2);
else if (svg.startsWith('<!--', i)) i = after(svg, '-->', i + 4);
else if (svg.startsWith('<!DOCTYPE', i)) i = doctypeEnd(svg, i);
else {
ROOT.lastIndex = i;
return ROOT.test(svg) ? i : -1;
}
if (i < 0) return -1;
}
}
/**
* 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).
@@ -151,16 +214,16 @@ function pixels(value: string | undefined): number | null {
* 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).
* brauchbare viewBox bleibt immer unberührt. Nur das Wurzelelement zählt (siehe `rootStart`); 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 sizeSvg(svg: string): SizedSvg {
const unchanged = { svg, ...svgRasterSize(NaN) };
const start = svg.search(/<svg\b/);
const start = rootStart(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 Wurzelelement (`y`): so braucht
// auch eine Datei voller offener „<svg x="“ nur linear viel Zeit, ein Suchmuster, das an jedem „<svg“ neu ansetzt, hinge im Tab.
const tag = /<svg\b((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
const tag = /<svg(?=[\s/>])((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
tag.lastIndex = start;
const hit = tag.exec(svg);
if (!hit) return unchanged;
@@ -189,14 +252,6 @@ export function sizeSvg(svg: string): SizedSvg {
// ---- Dekodieren und Zeichnen (braucht einen echten Canvas) --------------------------------------------
/** Das Quellbild in Originalgröße auf einem Canvas samt seinen Pixeln (RGBA). */
interface Drawn {
canvas: HTMLCanvasElement;
data: Uint8ClampedArray;
w: number;
h: number;
}
/** Ein Canvas der Größe w × h mit 2D-Kontext. Es werden Pixel gelesen, daher `willReadFrequently`. */
function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
const canvas = document.createElement('canvas');
@@ -207,11 +262,10 @@ function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: Canv
return { canvas, ctx };
}
/** Zeichnet die Quelle in Größe w × h auf einen neuen Canvas und liest dessen Pixel. */
function draw(source: CanvasImageSource, w: number, h: number): Drawn {
const { canvas, ctx } = newCanvas(w, h);
ctx.drawImage(source, 0, 0, w, h);
return { canvas, data: ctx.getImageData(0, 0, w, h).data, w, h };
/** Gibt den Speicher eines Canvas frei, den niemand mehr braucht (manche Browser behalten ihn sonst bis zur nächsten Speicherbereinigung). */
function releaseCanvas(canvas: HTMLCanvasElement): void {
canvas.width = 0;
canvas.height = 0;
}
/**
@@ -228,17 +282,33 @@ async function decode(blob: Blob): Promise<CardImage> {
}
}
/** PNG, JPG oder WebP: laden und in Originalgröße zeichnen. */
async function drawRaster(file: File): Promise<Drawn> {
const { source, w, h } = await decode(file);
return draw(source, w, h);
}
/** SVG: 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 decodeSvg(file: File): Promise<CardImage> {
const { svg, w, h } = sizeSvg(await file.text());
const { source } = await decode(new Blob([svg], { type: SVG_TYPE }));
return draw(source, w, h);
return { source, w, h };
}
/**
* Zeichnet die Datei in Originalgröße (ein SVG in seiner Rastergröße) auf einen Quell-Canvas, schneidet auf das Sichtbare zu und
* verkleinert auf die Zielgröße. Der Quell-Canvas (bis zu 40 Megapixel) und der Pixelpuffer, aus dem der Zuschnitt folgt, sind danach
* schon wieder frei, noch bevor das PNG entsteht; das gilt auch, wenn etwas scheitert.
*/
async function renderLogo(file: File): Promise<{ canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; size: Size }> {
const { source, w, h } = isSvgFile(file) ? await decodeSvg(file) : await decode(file);
const from = newCanvas(w, h);
try {
from.ctx.drawImage(source, 0, 0, w, h);
// Der Pixelpuffer (4 Byte je Pixel) steht nur in diesem Aufruf: nach dem Zuschnitt hält ihn niemand mehr.
const { box, size } = planLogo(from.ctx.getImageData(0, 0, w, h).data, w, h);
const out = newCanvas(size.w, size.h);
// Beim Verkleinern ist die Standardqualität (low) grob.
out.ctx.imageSmoothingQuality = 'high';
out.ctx.drawImage(from.canvas, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h);
return { ...out, size };
} finally {
releaseCanvas(from.canvas);
}
}
function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
@@ -255,17 +325,12 @@ export async function prepareLogo(file: File): Promise<PreparedLogo> {
const check = validateLogoFile(file);
if (!check.ok) throw new LogoError(check.message);
try {
const { canvas, data, w, h } = isSvgFile(file) ? await drawSvg(file) : await drawRaster(file);
const { box, size } = planLogo(data, w, h);
const out = newCanvas(size.w, size.h);
// Beim Verkleinern ist die Standardqualität (low) grob.
out.ctx.imageSmoothingQuality = 'high';
out.ctx.drawImage(canvas, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h);
const { canvas, ctx, size } = await renderLogo(file);
return {
png: await toPng(out.canvas),
png: await toPng(canvas),
width: size.w,
height: size.h,
hasTransparency: hasTransparency(out.ctx.getImageData(0, 0, size.w, size.h).data),
hasTransparency: hasTransparency(ctx.getImageData(0, 0, size.w, size.h).data),
};
} catch (err) {
if (err instanceof LogoError) throw err;