diff --git a/web/src/logos/prepare-logo.test.ts b/web/src/logos/prepare-logo.test.ts index adb15a7..cfe61a2 100644 --- a/web/src/logos/prepare-logo.test.ts +++ b/web/src/logos/prepare-logo.test.ts @@ -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 „ { + const ROOT_BEFORE = ''; + const ROOT_AFTER = ''; + const converted = (head: string) => ({ svg: `${head}${ROOT_AFTER}${BODY}`, w: 2048, h: 614 }); + + it('ein auskommentiertes vor dem Wurzelelement wird nicht umgeschrieben', () => { + const head = ''; + expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head)); + }); + + it('ein { + const head = ' ?>'; + expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head)); + }); + + it('ein “ und „]“ in Anführungszeichen) zählt nicht', () => { + const head = + '\n' + + ' ]>">\n' + + ' \n' + + ' ?>\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 = + '\n' + + '\n' + + '\n' + + '\t\n' + + '\t\n' + + ']>\n'; + const root = ''; + const result = sizeSvg(`${head}${root}${BODY}`); + expect(result.w).toBe(2048); + expect(result.h).toBe(614); + expect(result.svg).toBe( + `${head}${BODY}`, + ); + }); + + it('Byte-Order-Marke und Leerraum vor dem Prolog stören nicht', () => { + const head = '\uFEFF \n\t\n\n\r\n'; + expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head)); + }); + + it('mehrere Kommentare und Anweisungen hintereinander', () => { + const head = ' '; + expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head)); + }); + + it.each([ + ['mit Namensraum-Präfix', ''], + ['mit Bindestrich', ''], + ['mit Punkt', ''], + ['mit Ziffer', ''], + ])('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 „ { + const text = `${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}`, `${ROOT_BEFORE}${BODY}`, `${ROOT_BEFORE}${BODY}`]) { + expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 }); + } + }); + + it(' { + expect(sizeSvg('' + BODY)).toEqual({ svg: '' + BODY, w: 2048, h: 2048 }); + expect(sizeSvg('' + BODY).svg).toBe(ROOT_AFTER + BODY); + expect(sizeSvg('').svg).toBe(''); + }); + + it('ein Prolog, der nicht endet (Kommentar, Anweisung, DOCTYPE, Anführungszeichen), ergibt kein Wurzelelement: unverändert, quadratisch', () => { + for (const text of [ + `'.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).toBe(``); }); @@ -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( '', ); - 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( '', ); - 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('')); + 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'); diff --git a/web/src/logos/prepare-logo.ts b/web/src/logos/prepare-logo.ts index 7f245ba..1feec01 100644 --- a/web/src/logos/prepare-logo.ts +++ b/web/src/logos/prepare-logo.ts @@ -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: ``; `])/y; +/** Was in einem DOCTYPE ein „>“ oder „]“ enthalten darf, ohne ihn zu beenden: Anführungszeichen, Kommentare und Anweisungen (Anfang, Ende). */ +const QUOTED: ReadonlyArray = [['"', '"'], ["'", "'"], [''], ['']]; + +/** 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 „ 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 `` 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 `', i + 2); + else if (svg.startsWith('', i + 4); + else if (svg.startsWith(' "` * 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). + * 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(/“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem ersten „“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem Wurzelelement (`y`): so braucht // auch eine Datei voller offener „/y; + const tag = /])((?:[^>"']|"[^"]*"|'[^']*')*)>/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 { } } -/** 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: 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 { +async function decodeSvg(file: File): Promise { 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 { @@ -255,17 +325,12 @@ export async function prepareLogo(file: File): Promise { 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;