feat(card): Canvas-Renderer und Export

renderCard zeichnet in 1536 x 969 in fester Reihenfolge (Grund, Bild mit
begrenztem Ausschnitt, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk),
drawCard skaliert auf beliebige Canvas ohne Alpha, loadCardAssets wartet
auf Schrift und Logos, exportCard liefert PNG/WebP als Blob.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:52:26 +00:00
1 parent a610df31dc
commit 9a46342e0c
2 files changed
+678

No files matched your search

+472
View File
@@ -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: `<svg viewBox="${viewBox}"></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<string, unknown> = {
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> = {}): CardAssets => ({
image: null,
network: NETWORK,
brand: BRAND,
...over,
});
const photo = (w: number, h: number): CardImage => ({ source: PHOTO, w, h });
const cfg = (over: Partial<CardConfig> = {}): 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();
});
});
+206
View File
@@ -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<DetailsTone, { from: string; to: string; lines: string }> = {
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<Layout['chip']>, 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<Layout['number']>, 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<Layout['platinum']>, 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<CardAssets> {
// 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<Blob> {
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<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Das Bild konnte nicht erzeugt werden.'))),
opts.type,
opts.quality,
);
});
}