diff --git a/web/src/card/render.test.ts b/web/src/card/render.test.ts
new file mode 100644
index 0000000..c877ddc
--- /dev/null
+++ b/web/src/card/render.test.ts
@@ -0,0 +1,472 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { DEFAULT_CONFIG, type CardConfig } from './config';
+import { loadMarkImage } from './marks';
+import {
+ drawCard,
+ exportCard,
+ loadCardAssets,
+ renderCard,
+ type CardAssets,
+ type CardImage,
+ type Ctx,
+} from './render';
+
+// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
+vi.mock('./marks', () => {
+ const mark = (id: string, viewBox: string, maxH: number) => ({
+ id,
+ group: 'Test',
+ label: id,
+ svg: ``,
+ tint: true,
+ maxH,
+ });
+ return {
+ getNetworkMark: (id: string) => mark(id, '0 0 330 109', 109),
+ getBrandMark: (id: string) => mark(id, '0 0 100 100', 100),
+ svgAspect: (svg: string) => {
+ const p = /viewBox="([^"]+)"/.exec(svg)![1].split(' ').map(Number);
+ return p[2] / p[3];
+ },
+ loadMarkImage: vi.fn(),
+ };
+});
+
+interface Call {
+ name: string;
+ args: unknown[];
+ fillStyle: unknown;
+ strokeStyle: unknown;
+ lineWidth: number;
+ lineCap: string;
+ font: string;
+ textAlign: string;
+}
+
+interface Gradient {
+ kind: 'gradient';
+ args: number[];
+ stops: [number, string][];
+ addColorStop(offset: number, color: string): void;
+}
+
+const METHODS = [
+ 'save', 'restore', 'scale', 'setTransform', 'fillRect', 'drawImage', 'fillText', 'beginPath',
+ 'arc', 'fill', 'stroke', 'roundRect', 'clip', 'moveTo', 'lineTo',
+] as const;
+
+/** Zeichnender Fake: protokolliert jeden Aufruf samt den Stilwerten im Moment des Aufrufs. */
+function fakeCtx() {
+ const calls: Call[] = [];
+ const ctx: Record = {
+ fillStyle: '#000000',
+ strokeStyle: '#000000',
+ lineWidth: 1,
+ lineCap: 'butt',
+ font: '10px sans-serif',
+ textAlign: 'start',
+ textBaseline: 'alphabetic',
+ };
+ const record = (name: string, args: unknown[]) =>
+ calls.push({
+ name,
+ args,
+ fillStyle: ctx.fillStyle,
+ strokeStyle: ctx.strokeStyle,
+ lineWidth: ctx.lineWidth as number,
+ lineCap: ctx.lineCap as string,
+ font: ctx.font as string,
+ textAlign: ctx.textAlign as string,
+ });
+ for (const name of METHODS) ctx[name] = (...args: unknown[]) => void record(name, args);
+ ctx.measureText = (text: string) => ({ width: text.length * 20 });
+ ctx.createLinearGradient = (...args: number[]) => {
+ record('createLinearGradient', args);
+ const g: Gradient = {
+ kind: 'gradient',
+ args,
+ stops: [],
+ addColorStop(offset, color) {
+ this.stops.push([offset, color]);
+ },
+ };
+ return g;
+ };
+ const strokePath = (c: Ctx, d: string) => {
+ void c;
+ record('strokePath', [d]);
+ };
+ return { ctx: ctx as unknown as Ctx, calls, strokePath };
+}
+
+const NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
+const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
+const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
+
+const assets = (over: Partial = {}): CardAssets => ({
+ image: null,
+ network: NETWORK,
+ brand: BRAND,
+ ...over,
+});
+
+const photo = (w: number, h: number): CardImage => ({ source: PHOTO, w, h });
+
+const cfg = (over: Partial = {}): CardConfig => ({
+ ...DEFAULT_CONFIG,
+ crop: { ...DEFAULT_CONFIG.crop },
+ ...over,
+});
+
+function render(config: CardConfig, a: CardAssets) {
+ const f = fakeCtx();
+ renderCard(f.ctx, config, a, f.strokePath);
+ return f.calls;
+}
+
+const named = (calls: Call[], name: string) => calls.filter((c) => c.name === name);
+const drawsOf = (calls: Call[], source: unknown) =>
+ named(calls, 'drawImage').filter((c) => c.args[0] === source);
+const letters = (calls: Call[]) => named(calls, 'fillText').filter((c) => /[A-Z]/.test(c.args[0] as string));
+
+describe('renderCard: Grund und Bild', () => {
+ it('beginnt mit dem Kartengrund, auch mit Bild', () => {
+ for (const a of [assets(), assets({ image: photo(1536, 969) })]) {
+ const first = render(cfg({ template: 'image' }), a)[0];
+ expect(first.name).toBe('fillRect');
+ expect(first.args).toEqual([0, 0, 1536, 969]);
+ expect(first.fillStyle).toBe('#1B2738');
+ }
+ });
+
+ it('Vorlage image: genau ein drawImage (das Bild), kein Text', () => {
+ const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
+ expect(named(calls, 'drawImage')).toHaveLength(1);
+ expect(drawsOf(calls, PHOTO)).toHaveLength(1);
+ expect(named(calls, 'fillText')).toHaveLength(0);
+ });
+
+ it('ohne Bild: kein Bild-drawImage, Grund gefüllt', () => {
+ const calls = render(cfg({ template: 'classic' }), assets());
+ expect(drawsOf(calls, PHOTO)).toHaveLength(0);
+ expect(calls[0]).toMatchObject({ name: 'fillRect', fillStyle: '#1B2738' });
+ });
+
+ it('Bild ohne Zuschnitt füllt die Karte genau', () => {
+ const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
+ expect(drawsOf(calls, PHOTO)[0].args).toEqual([PHOTO, 0, 0, 1536, 969]);
+ });
+
+ it('Bild 3000x1000 mit crop.x = 9999: Rechteck deckt die ganze Karte', () => {
+ const config = cfg({ template: 'image', crop: { x: 9999, y: 0, zoom: 1 } });
+ const [, x, y, w, h] = drawsOf(render(config, assets({ image: photo(3000, 1000) })), PHOTO)[0].args as number[];
+ expect(x).toBeCloseTo(0, 1);
+ expect(y).toBeCloseTo(0, 1);
+ expect(x + w).toBeGreaterThanOrEqual(1536 - 0.01);
+ expect(y + h).toBeGreaterThanOrEqual(969 - 0.01);
+ });
+
+ it('kaputte oder zu große Ausschnitte (anderes Bild, NaN, Zoom 10) lassen keine Streifen', () => {
+ const crops = [
+ { x: 9999, y: -9999, zoom: 1 },
+ { x: -500, y: 400, zoom: 10 },
+ { x: Number.NaN, y: Number.POSITIVE_INFINITY, zoom: Number.NaN },
+ { x: 120, y: 80, zoom: 0.2 },
+ ];
+ for (const img of [photo(3000, 1000), photo(800, 2400), photo(1536, 969)]) {
+ for (const crop of crops) {
+ const calls = render(cfg({ template: 'image', crop }), assets({ image: img }));
+ const [, x, y, w, h] = drawsOf(calls, PHOTO)[0].args as number[];
+ expect(x).toBeLessThanOrEqual(0.01);
+ expect(y).toBeLessThanOrEqual(0.01);
+ expect(x + w).toBeGreaterThanOrEqual(1536 - 0.01);
+ expect(y + h).toBeGreaterThanOrEqual(969 - 0.01);
+ }
+ }
+ });
+});
+
+describe('renderCard: Vorlagen', () => {
+ it('minimal: nur das Netzwerk-Logo unten rechts', () => {
+ const calls = render(cfg({ template: 'minimal' }), assets());
+ expect(named(calls, 'drawImage')).toHaveLength(1);
+ const [src, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
+ expect(src).toBe(NETWORK);
+ expect(x).toBeCloseTo(1150, 1);
+ expect(y).toBeCloseTo(804, 1);
+ expect(w).toBeCloseTo(330, 1);
+ expect(h).toBeCloseTo(109, 1);
+ expect(named(calls, 'fillText')).toHaveLength(0);
+ });
+
+ it('classic dunkel: Text in #111111, PLATINUM endet bei x 1480, Ziffern und Punkte stimmen', () => {
+ const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
+
+ const texts = named(calls, 'fillText');
+ expect(texts.length).toBe(8 + 4);
+ expect(texts.every((c) => c.fillStyle === '#111111')).toBe(true);
+
+ const plat = letters(calls);
+ expect(plat.map((c) => c.args[0]).join('')).toBe('PLATINUM');
+ expect(plat.every((c) => c.args[2] === 123 && c.textAlign === 'left' && c.font === '700 46px "Nunito Sans Variable"')).toBe(true);
+ const last = plat[plat.length - 1];
+ expect((last.args[1] as number) + 20).toBeCloseTo(1480, 1);
+ // Abstand zwischen zwei Buchstaben = Breite 20 + Laufweite 9
+ expect((plat[1].args[1] as number) - (plat[0].args[1] as number)).toBeCloseTo(29, 1);
+
+ const eights = texts.filter((c) => c.args[0] === '8');
+ expect(eights).toHaveLength(4);
+ expect(eights.map((c) => c.args[1] as number)[0]).toBeCloseTo(863.8, 1);
+ expect(eights.map((c) => c.args[1] as number)[1]).toBeCloseTo(909.4, 1);
+ expect(eights.map((c) => c.args[1] as number)[2]).toBeCloseTo(955, 1);
+ expect(eights.map((c) => c.args[1] as number)[3]).toBeCloseTo(1000.6, 1);
+ expect(eights.every((c) => c.args[2] === 650 && c.textAlign === 'center' && c.font === '700 72px "Nunito Sans Variable"')).toBe(true);
+
+ const bullets = named(calls, 'arc');
+ expect(bullets).toHaveLength(12);
+ expect(bullets.every((c) => c.args[1] === 624 && c.args[2] === 8 && c.fillStyle === '#111111')).toBe(true);
+ expect(bullets[0].args[0]).toBeCloseTo(179.8, 1);
+ expect(bullets[3].args[0]).toBeCloseTo(316.6, 1);
+ expect(bullets[4].args[0]).toBeCloseTo(157 + 45.6 * 5 + 22.8, 1);
+ expect(bullets[11].args[0]).toBeCloseTo(157 + 45.6 * 13 + 22.8, 1);
+ expect(named(calls, 'fill').length).toBeGreaterThanOrEqual(12);
+ });
+
+ it('classic hell: Text in Weiß', () => {
+ const calls = render(cfg({ template: 'classic', details: 'light' }), assets());
+ expect(named(calls, 'fillText').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
+ expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
+ });
+
+ it('classic: Brand-Logo links oben, Netzwerk-Logo unten rechts', () => {
+ const calls = render(cfg({ template: 'classic' }), assets());
+ expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]);
+ const [, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
+ expect([x, y, w, h].map((v) => Math.round(v))).toEqual([1150, 804, 330, 109]);
+ });
+
+ it('classic: Kontaktlos-Bögen in Details-Farbe mit runden Enden', () => {
+ const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
+ const arcs = named(calls, 'strokePath');
+ expect(arcs).toHaveLength(1);
+ expect(String(arcs[0].args[0]).startsWith('M548.75 379.89')).toBe(true);
+ expect(arcs[0]).toMatchObject({ strokeStyle: '#111111', lineWidth: 11.8, lineCap: 'round' });
+ });
+
+ it('signature, Brand none: kein Brand-drawImage, PLATINUM beginnt bei x 60', () => {
+ const calls = render(cfg({ template: 'signature', brand: 'none' }), assets());
+ expect(drawsOf(calls, BRAND)).toHaveLength(0);
+ expect(drawsOf(calls, NETWORK)).toHaveLength(1);
+ const plat = letters(calls);
+ expect(plat[0].args).toEqual(['P', 60, 226]);
+ expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
+ expect(plat[0].textAlign).toBe('left');
+ // Laufweite 8: Buchstabe 2 beginnt bei 60 + 20 + 8
+ expect(plat[1].args[1]).toBeCloseTo(88, 1);
+ expect(named(calls, 'roundRect')).toHaveLength(0);
+ expect(named(calls, 'arc')).toHaveLength(0);
+ });
+
+ it('signature mit Brand: Logo oben links', () => {
+ const calls = render(cfg({ template: 'signature' }), assets());
+ expect(drawsOf(calls, BRAND)).toHaveLength(1);
+ });
+
+ it('fehlende Logo-Bilder werden übersprungen', () => {
+ const calls = render(cfg({ template: 'classic' }), assets({ network: null, brand: null }));
+ expect(named(calls, 'drawImage')).toHaveLength(0);
+ expect(letters(calls)).toHaveLength(8);
+ });
+});
+
+describe('renderCard: Chip', () => {
+ it('hell: heller Verlauf, Linien #7A7A7A, Breite 3, auf das Rechteck beschnitten', () => {
+ const calls = render(cfg({ template: 'classic', details: 'light' }), assets());
+
+ const grad = named(calls, 'createLinearGradient');
+ expect(grad).toHaveLength(1);
+ const g = calls.find((c) => c.fillStyle && (c.fillStyle as Gradient).kind === 'gradient')!.fillStyle as Gradient;
+ expect(g.stops).toEqual([[0, '#F2F2F2'], [1, '#A9A9A9']]);
+
+ const rects = named(calls, 'roundRect');
+ expect(rects).toHaveLength(2); // Füllung und Beschnitt
+ expect(rects[0].args).toEqual([157, 326, 226, 205, 32]);
+ expect(rects[1].args).toEqual([157, 326, 226, 205, 32]);
+
+ const names = calls.map((c) => c.name);
+ const clipAt = names.indexOf('clip');
+ expect(names.slice(clipAt - 3, clipAt)).toEqual(['save', 'beginPath', 'roundRect']);
+ const restoreAt = names.indexOf('restore', clipAt);
+ const strokes = calls.slice(clipAt, restoreAt).filter((c) => c.name === 'stroke');
+ expect(strokes.length).toBeGreaterThanOrEqual(1);
+ expect(strokes.every((c) => c.strokeStyle === '#7A7A7A' && c.lineWidth === 3)).toBe(true);
+
+ // Linien: 3 waagerecht über die ganze Chipbreite, 2 senkrecht von 383 bis 474
+ const moves = calls.slice(clipAt, restoreAt).filter((c) => c.name === 'moveTo').map((c) => c.args);
+ const lines = calls.slice(clipAt, restoreAt).filter((c) => c.name === 'lineTo').map((c) => c.args);
+ expect(moves).toEqual([[157, 383], [157, 429], [157, 474], [234, 383], [307, 383]]);
+ expect(lines).toEqual([[383, 383], [383, 429], [383, 474], [234, 474], [307, 474]]);
+ });
+
+ it('dunkel: dunkler Verlauf, Linien #0A0A0A', () => {
+ const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
+ const g = calls.find((c) => c.fillStyle && (c.fillStyle as Gradient).kind === 'gradient')!.fillStyle as Gradient;
+ expect(g.stops).toEqual([[0, '#4A4A4A'], [1, '#1C1C1C']]);
+ const strokes = named(calls, 'stroke');
+ expect(strokes.length).toBeGreaterThanOrEqual(1);
+ expect(strokes.every((c) => c.strokeStyle === '#0A0A0A' && c.lineWidth === 3)).toBe(true);
+ });
+});
+
+describe('renderCard: Reihenfolge', () => {
+ it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Netzwerk', () => {
+ const calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
+ const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
+ const order = [
+ at((c) => c.name === 'fillRect'),
+ at((c) => c.name === 'drawImage' && c.args[0] === PHOTO),
+ at((c) => c.name === 'createLinearGradient'),
+ at((c) => c.name === 'strokePath'),
+ at((c) => c.name === 'arc'),
+ at((c) => c.name === 'fillText' && c.args[0] === 'P'),
+ at((c) => c.name === 'drawImage' && c.args[0] === BRAND),
+ at((c) => c.name === 'drawImage' && c.args[0] === NETWORK),
+ ];
+ expect(order.every((i) => i >= 0)).toBe(true);
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
+ });
+});
+
+describe('renderCard: strokePath-Standard', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('ohne eigenen strokePath wird ctx.stroke(new Path2D(d)) benutzt', () => {
+ class FakePath {
+ constructor(public d: string) {}
+ }
+ vi.stubGlobal('Path2D', FakePath);
+ const f = fakeCtx();
+ const strokes: unknown[] = [];
+ (f.ctx as unknown as { stroke: (p?: unknown) => void }).stroke = (p) => void strokes.push(p);
+ renderCard(f.ctx, cfg({ template: 'classic' }), assets());
+ const paths = strokes.filter((p) => p instanceof FakePath) as FakePath[];
+ expect(paths).toHaveLength(1);
+ expect(paths[0].d.startsWith('M548.75 379.89')).toBe(true);
+ });
+});
+
+describe('drawCard', () => {
+ it('holt den Kontext ohne Alpha, setzt Transform zurück und skaliert auf 1536x969', () => {
+ const f = fakeCtx();
+ const getContext = vi.fn(() => f.ctx);
+ const canvas = { width: 768, height: 485, getContext } as unknown as HTMLCanvasElement;
+ drawCard(canvas, cfg({ template: 'minimal' }), assets());
+ expect(getContext).toHaveBeenCalledWith('2d', { alpha: false });
+ expect(f.calls[0]).toMatchObject({ name: 'setTransform', args: [1, 0, 0, 1, 0, 0] });
+ expect(f.calls[1].name).toBe('scale');
+ expect(f.calls[1].args[0]).toBeCloseTo(768 / 1536, 5);
+ expect(f.calls[1].args[1]).toBeCloseTo(485 / 969, 5);
+ expect(f.calls[2]).toMatchObject({ name: 'fillRect', args: [0, 0, 1536, 969], fillStyle: '#1B2738' });
+ });
+
+ it('wirft verständlich, wenn kein 2D-Kontext zu haben ist', () => {
+ const canvas = { width: 10, height: 10, getContext: () => null } as unknown as HTMLCanvasElement;
+ expect(() => drawCard(canvas, cfg(), assets())).toThrow(/Canvas/);
+ });
+
+ it('transparentes Bild: Grund wird zuerst gefüllt, Canvas ohne Alpha', () => {
+ const f = fakeCtx();
+ const getContext = vi.fn(() => f.ctx);
+ const canvas = { width: 1536, height: 969, getContext } as unknown as HTMLCanvasElement;
+ drawCard(canvas, cfg({ template: 'image' }), assets({ image: photo(400, 400) }));
+ expect(getContext).toHaveBeenCalledWith('2d', { alpha: false });
+ const draws = f.calls.filter((c) => c.name === 'fillRect' || c.name === 'drawImage').map((c) => c.name);
+ expect(draws).toEqual(['fillRect', 'drawImage']);
+ });
+});
+
+describe('loadCardAssets', () => {
+ const NET_IMG = { tag: 'net-img' };
+ const BRAND_IMG = { tag: 'brand-img' };
+ const fontsLoad = vi.fn();
+ const mocked = vi.mocked(loadMarkImage);
+
+ beforeEach(() => {
+ fontsLoad.mockReset().mockResolvedValue([]);
+ mocked.mockReset().mockImplementation(async (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
+ Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
+ });
+
+ afterEach(() => {
+ Reflect.deleteProperty(document, 'fonts');
+ });
+
+ it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
+ const image = photo(100, 100);
+ const result = await loadCardAssets(cfg({ network: 'jcb', brand: 'apple', details: 'dark', template: 'image' }), image);
+ expect(result.image).toBe(image);
+ expect(result.network).toBe(NET_IMG);
+ expect(result.brand).toBe(BRAND_IMG);
+ expect(mocked).toHaveBeenCalledTimes(2);
+ expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['jcb', 'dark'], ['apple', 'dark']]);
+ expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
+ });
+
+ it('Brand none: kein Brand-Logo laden', async () => {
+ const result = await loadCardAssets(cfg({ brand: 'none' }), null);
+ expect(result.image).toBeNull();
+ expect(result.brand).toBeNull();
+ expect(mocked).toHaveBeenCalledTimes(1);
+ });
+
+ it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
+ fontsLoad.mockRejectedValue(new Error('offline'));
+ await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
+ Reflect.deleteProperty(document, 'fonts');
+ Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
+ await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
+ });
+
+ it('Logo-Fehler wird weitergereicht', async () => {
+ mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: jcb'));
+ await expect(loadCardAssets(cfg({ network: 'jcb' }), null)).rejects.toThrow(/Logo/);
+ });
+});
+
+describe('exportCard', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ function stubCanvas(blob: Blob | null) {
+ const f = fakeCtx();
+ const toBlob = vi.fn((cb: (b: Blob | null) => void) => cb(blob));
+ const canvas = { width: 0, height: 0, getContext: vi.fn(() => f.ctx), toBlob };
+ const create = vi.spyOn(document, 'createElement').mockReturnValue(canvas as unknown as HTMLElement);
+ return { f, canvas, toBlob, create };
+ }
+
+ it('Canvas width x round(width*969/1536), zeichnet und liefert den Blob', async () => {
+ const blob = new Blob(['x'], { type: 'image/png' });
+ const { f, canvas, toBlob, create } = stubCanvas(blob);
+ const out = await exportCard(cfg({ template: 'minimal' }), assets(), { width: 1536, type: 'image/png' });
+ expect(out).toBe(blob);
+ expect(create).toHaveBeenCalledWith('canvas');
+ expect(canvas.width).toBe(1536);
+ expect(canvas.height).toBe(969);
+ expect(canvas.getContext).toHaveBeenCalledWith('2d', { alpha: false });
+ expect(f.calls.some((c) => c.name === 'fillRect')).toBe(true);
+ expect(toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png', undefined);
+ });
+
+ it('Vorschaugröße 480 x 303 als WebP mit Qualität', async () => {
+ const { canvas, toBlob } = stubCanvas(new Blob(['x']));
+ await exportCard(cfg(), assets(), { width: 480, type: 'image/webp', quality: 0.8 });
+ expect(canvas.width).toBe(480);
+ expect(canvas.height).toBe(303);
+ expect(toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/webp', 0.8);
+ });
+
+ it('lehnt ab, wenn toBlob nichts liefert', async () => {
+ stubCanvas(null);
+ await expect(exportCard(cfg(), assets(), { width: 100, type: 'image/png' })).rejects.toThrow();
+ });
+});
diff --git a/web/src/card/render.ts b/web/src/card/render.ts
new file mode 100644
index 0000000..f6fc0c9
--- /dev/null
+++ b/web/src/card/render.ts
@@ -0,0 +1,206 @@
+// Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext.
+// Vorschau, Vorlagen-Miniaturen, PNG-Download und Vorschaubild beim Speichern benutzen alle drawCard.
+import { TONE_COLOR, type CardConfig, type DetailsTone } from './config';
+import { clampCrop, imageRect } from './crop';
+import { CARD_H, CARD_W, fitBrand, fitNetwork, layoutFor, type Layout } from './geometry';
+import { getBrandMark, getNetworkMark, loadMarkImage, svgAspect } from './marks';
+
+export type CardImage = { source: CanvasImageSource; w: number; h: number };
+
+export interface CardAssets {
+ image: CardImage | null;
+ network: CanvasImageSource | null;
+ brand: CanvasImageSource | null;
+}
+
+/** Der kleine Ausschnitt von CanvasRenderingContext2D, den der Renderer braucht (leicht zu faken). */
+export type Ctx = Pick<
+ CanvasRenderingContext2D,
+ | 'save' | 'restore' | 'scale' | 'setTransform' | 'fillRect' | 'drawImage' | 'fillText' | 'measureText'
+ | 'beginPath' | 'arc' | 'fill' | 'stroke' | 'roundRect' | 'clip' | 'moveTo' | 'lineTo'
+ | 'createLinearGradient'
+> & {
+ fillStyle: unknown;
+ strokeStyle: unknown;
+ lineWidth: number;
+ lineCap: CanvasLineCap;
+ font: string;
+ textAlign: CanvasTextAlign;
+ textBaseline: CanvasTextBaseline;
+};
+
+/** Kartengrund: ohne Bild und unter transparenten Bildern. */
+const GROUND = '#1B2738';
+const FONT_FAMILY = '"Nunito Sans Variable"';
+const FONT_LOAD = `700 46px ${FONT_FAMILY}`;
+const PLATINUM = 'PLATINUM';
+const CHIP_LINE_WIDTH = 3;
+
+/** Chip-Metall je Details-Ton: Verlauf von links oben nach rechts unten, Kontaktlinien. */
+const CHIP_METAL: Record = {
+ light: { from: '#F2F2F2', to: '#A9A9A9', lines: '#7A7A7A' },
+ dark: { from: '#4A4A4A', to: '#1C1C1C', lines: '#0A0A0A' },
+};
+
+const font = (size: number) => `700 ${size}px ${FONT_FAMILY}`;
+
+const defaultStrokePath = (ctx: Ctx, d: string) => ctx.stroke(new Path2D(d));
+
+function drawChip(ctx: Ctx, chip: NonNullable, tone: DetailsTone): void {
+ const metal = CHIP_METAL[tone];
+ const { x, y, w, h, r } = chip;
+
+ const gradient = ctx.createLinearGradient(x, y, x + w, y + h);
+ gradient.addColorStop(0, metal.from);
+ gradient.addColorStop(1, metal.to);
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.roundRect(x, y, w, h, r);
+ ctx.fill();
+
+ // Kontaktlinien laufen bis an den Rand, deshalb auf die Chipform beschneiden.
+ ctx.save();
+ ctx.beginPath();
+ ctx.roundRect(x, y, w, h, r);
+ ctx.clip();
+ ctx.beginPath();
+ for (const ly of chip.hLines) {
+ ctx.moveTo(x, ly);
+ ctx.lineTo(x + w, ly);
+ }
+ for (const lx of chip.vLines) {
+ ctx.moveTo(lx, chip.padY[0]);
+ ctx.lineTo(lx, chip.padY[1]);
+ }
+ ctx.strokeStyle = metal.lines;
+ ctx.lineWidth = CHIP_LINE_WIDTH;
+ ctx.stroke();
+ ctx.restore();
+}
+
+function drawNumber(ctx: Ctx, num: NonNullable, color: string): void {
+ ctx.fillStyle = color;
+ ctx.font = font(num.size);
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'alphabetic';
+ [...num.text].forEach((ch, i) => {
+ if (ch === ' ') return;
+ const cx = num.startX + num.pitch * i + num.pitch / 2;
+ if (ch === '•') {
+ ctx.beginPath();
+ ctx.arc(cx, num.bulletY, num.bulletR, 0, Math.PI * 2);
+ ctx.fill();
+ } else {
+ ctx.fillText(ch, cx, num.baseline);
+ }
+ });
+}
+
+function drawPlatinum(ctx: Ctx, p: NonNullable, color: string): void {
+ ctx.fillStyle = color;
+ ctx.font = font(p.size);
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'alphabetic';
+ const chars = [...PLATINUM];
+ const widths = chars.map((c) => ctx.measureText(c).width);
+ // Laufweite nur zwischen den Buchstaben, nicht nach dem letzten.
+ const total = widths.reduce((a, b) => a + b, 0) + (chars.length - 1) * p.trackingPx;
+ let x = p.align === 'right' ? p.x - total : p.x;
+ chars.forEach((c, i) => {
+ ctx.fillText(c, x, p.baseline);
+ x += widths[i] + p.trackingPx;
+ });
+}
+
+/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk. */
+export function renderCard(
+ ctx: Ctx,
+ config: CardConfig,
+ assets: CardAssets,
+ strokePath: (ctx: Ctx, d: string) => void = defaultStrokePath,
+): void {
+ const layout = layoutFor(config.template);
+ const color = TONE_COLOR[config.details];
+
+ ctx.fillStyle = GROUND;
+ ctx.fillRect(0, 0, CARD_W, CARD_H);
+
+ const image = assets.image;
+ if (image) {
+ // Defensiv begrenzen: ein Ausschnitt aus einem anderen Bild darf keine leeren Streifen lassen.
+ const r = imageRect(image, clampCrop(image, config.crop));
+ ctx.drawImage(image.source, r.x, r.y, r.w, r.h);
+ }
+
+ if (layout.chip) drawChip(ctx, layout.chip, config.details);
+
+ if (layout.contactless) {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = layout.contactless.lineWidth;
+ ctx.lineCap = 'round';
+ strokePath(ctx, layout.contactless.path);
+ }
+
+ if (layout.number) drawNumber(ctx, layout.number, color);
+ if (layout.platinum) drawPlatinum(ctx, layout.platinum, color);
+
+ if (layout.brand && config.brand !== 'none' && assets.brand) {
+ const mark = getBrandMark(config.brand);
+ const b = fitBrand(svgAspect(mark.svg), mark.maxH);
+ ctx.drawImage(assets.brand, b.x, b.y, b.w, b.h);
+ }
+
+ if (layout.network && assets.network) {
+ const mark = getNetworkMark(config.network);
+ const n = fitNetwork(svgAspect(mark.svg), mark.maxH);
+ ctx.drawImage(assets.network, n.x, n.y, n.w, n.h);
+ }
+}
+
+/** Zeichnet die Karte auf einen Canvas beliebiger Größe (skaliert von 1536 x 969), ohne Alphakanal. */
+export function drawCard(
+ canvas: HTMLCanvasElement | OffscreenCanvas,
+ config: CardConfig,
+ assets: CardAssets,
+): void {
+ // Die Überladungen beider Canvas-Typen lassen sich als Union nicht aufrufen; zur Laufzeit gleich.
+ const ctx = (canvas as HTMLCanvasElement).getContext('2d', { alpha: false });
+ if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.scale(canvas.width / CARD_W, canvas.height / CARD_H);
+ // Fotos werden stark verkleinert; die Standardqualität (low) zeigt dabei Treppenstufen.
+ ctx.imageSmoothingQuality = 'high';
+ renderCard(ctx, config, assets);
+}
+
+/** Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch. */
+export async function loadCardAssets(config: CardConfig, image: CardImage | null): Promise {
+ // Ohne geladene Schrift misst und zeichnet der Canvas mit der Ersatzschrift.
+ const fontReady = Promise.resolve(typeof document !== 'undefined' ? document.fonts?.load(FONT_LOAD) : undefined)
+ .catch(() => undefined);
+ const [network, brand] = await Promise.all([
+ loadMarkImage(getNetworkMark(config.network), config.details),
+ config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details),
+ fontReady,
+ ]);
+ return { image, network, brand };
+}
+
+/** Rendert die Karte offscreen in der gewünschten Breite (Höhe nach 1536 : 969) und liefert die Bilddatei. */
+export async function exportCard(
+ config: CardConfig,
+ assets: CardAssets,
+ opts: { width: number; type: 'image/png' | 'image/webp'; quality?: number },
+): Promise {
+ const canvas = document.createElement('canvas');
+ canvas.width = opts.width;
+ canvas.height = Math.round((opts.width * CARD_H) / CARD_W);
+ drawCard(canvas, config, assets);
+ return new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (blob) => (blob ? resolve(blob) : reject(new Error('Das Bild konnte nicht erzeugt werden.'))),
+ opts.type,
+ opts.quality,
+ );
+ });
+}