feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3051b3b3f9
commit
0be5abd954
44 files changed
+1401
-366
No files matched your search
+49
-22
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CROP,
|
||||
NETWORK_IDS,
|
||||
ID_PATTERN,
|
||||
TONE_COLOR,
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
const custom: CardConfig = {
|
||||
v: 1,
|
||||
template: 'classic',
|
||||
network: 'jcb',
|
||||
logo: 'abc123',
|
||||
details: 'dark',
|
||||
brand: 'apple',
|
||||
brand: 'aircards-text',
|
||||
crop: { x: -120.5, y: 33, zoom: 2.5 },
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('Standardwerte', () => {
|
||||
expect(DEFAULT_CONFIG).toEqual({
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
logo: null,
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: 0, y: 0, zoom: 1 },
|
||||
@@ -36,16 +36,13 @@ describe('Standardwerte', () => {
|
||||
expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP);
|
||||
});
|
||||
|
||||
it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => {
|
||||
expect(NETWORK_IDS.length).toBe(18);
|
||||
expect([...NETWORK_IDS]).toEqual([
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
]);
|
||||
expect(new Set(NETWORK_IDS).size).toBe(18);
|
||||
it('eine neue Karte hat kein Logo unten rechts', () => {
|
||||
expect(DEFAULT_CONFIG.logo).toBeNull();
|
||||
});
|
||||
|
||||
it('ID_PATTERN: Appwrite-IDs mit höchstens 36 Zeichen und ohne Sonderzeichen am Anfang', () => {
|
||||
for (const ok of ['abc123', 'A', '0', 'a.b_c-d', 'x'.repeat(36)]) expect(ID_PATTERN.test(ok), ok).toBe(true);
|
||||
for (const bad of ['', 'kaputt id', '-abc', '_abc', '.abc', 'ä', 'a/b', 'x'.repeat(37)]) expect(ID_PATTERN.test(bad), bad).toBe(false);
|
||||
});
|
||||
|
||||
it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => {
|
||||
@@ -72,9 +69,31 @@ describe('parseConfig / serializeConfig', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('unbekanntes Netzwerk setzt nur dieses Feld auf den Standard', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'paypal' });
|
||||
expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network });
|
||||
it('eine alte Konfiguration mit network und Apple-Logo: network entfällt, Logo oben links fällt auf den Standard', () => {
|
||||
const raw = '{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}';
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
v: 1,
|
||||
template: 'classic',
|
||||
logo: null,
|
||||
details: 'dark',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: 0, y: 0, zoom: 1 },
|
||||
});
|
||||
// andere unbekannte Marken ebenso
|
||||
expect(parseConfig('{"brand":"visa"}').brand).toBe('aircards-symbol');
|
||||
});
|
||||
|
||||
it('logo: eine gültige ID bleibt, alles andere ergibt null', () => {
|
||||
expect(parseConfig('{"logo":"abc123"}').logo).toBe('abc123');
|
||||
expect(parseConfig(JSON.stringify({ logo: 'x'.repeat(36) })).logo).toBe('x'.repeat(36));
|
||||
for (const bad of ['kaputt id', '', '-abc', 'x'.repeat(37), 42, true, null, ['abc'], { id: 'abc' }]) {
|
||||
expect(parseConfig(JSON.stringify({ logo: bad })).logo, JSON.stringify(bad)).toBeNull();
|
||||
}
|
||||
expect(parseConfig('{}').logo).toBeNull();
|
||||
});
|
||||
|
||||
it('das gewählte Logo bleibt nur dem Feld logo zugeordnet: ein altes network wird nie zum Logo', () => {
|
||||
expect(parseConfig('{"network":"abc123"}').logo).toBeNull();
|
||||
});
|
||||
|
||||
it('jedes Feld wird einzeln validiert', () => {
|
||||
@@ -88,11 +107,11 @@ describe('parseConfig / serializeConfig', () => {
|
||||
});
|
||||
|
||||
it('Namen aus Object.prototype gelten nicht als gültige Werte', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'constructor', template: '__proto__', brand: 'toString' });
|
||||
const raw = JSON.stringify({ ...custom, template: '__proto__', details: 'constructor', brand: 'toString' });
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
...custom,
|
||||
network: DEFAULT_CONFIG.network,
|
||||
template: DEFAULT_CONFIG.template,
|
||||
details: DEFAULT_CONFIG.details,
|
||||
brand: DEFAULT_CONFIG.brand,
|
||||
});
|
||||
});
|
||||
@@ -137,21 +156,29 @@ describe('parseConfig / serializeConfig', () => {
|
||||
const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } });
|
||||
const c = parseConfig(raw);
|
||||
expect(c).toEqual(custom);
|
||||
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'network', 'template', 'v']);
|
||||
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'logo', 'template', 'v']);
|
||||
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
|
||||
});
|
||||
|
||||
it('serializeConfig schreibt nur bekannte Felder', () => {
|
||||
const s = serializeConfig({ ...custom, evil: 1 } as CardConfig);
|
||||
const s = serializeConfig({ ...custom, evil: 1, network: 'jcb' } as CardConfig);
|
||||
expect(s).not.toContain('evil');
|
||||
expect(s).not.toContain('network');
|
||||
expect(JSON.parse(s)).toEqual(custom);
|
||||
});
|
||||
|
||||
it('serializeConfig schreibt genau {v, template, logo, details, brand, crop} in dieser Reihenfolge', () => {
|
||||
expect(Object.keys(JSON.parse(serializeConfig(custom)))).toEqual(['v', 'template', 'logo', 'details', 'brand', 'crop']);
|
||||
expect(serializeConfig(DEFAULT_CONFIG)).toBe(
|
||||
'{"v":1,"template":"signature","logo":null,"details":"light","brand":"aircards-symbol","crop":{"x":0,"y":0,"zoom":1}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializeConfig bleibt unter 2000 Zeichen', () => {
|
||||
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
|
||||
const worst: CardConfig = {
|
||||
...custom,
|
||||
network: 'mc-silver-text',
|
||||
logo: 'x'.repeat(36),
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
|
||||
};
|
||||
|
||||
+19
-14
@@ -3,21 +3,17 @@
|
||||
|
||||
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
|
||||
const TONES = ['light', 'dark'] as const;
|
||||
const BRANDS = ['aircards-symbol', 'aircards-text', 'apple', 'none'] as const;
|
||||
const NETWORKS = [
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
] as const;
|
||||
const BRANDS = ['aircards-symbol', 'aircards-text', 'none'] as const;
|
||||
|
||||
export type TemplateId = (typeof TEMPLATES)[number];
|
||||
export type DetailsTone = (typeof TONES)[number];
|
||||
export type BrandId = (typeof BRANDS)[number];
|
||||
export type NetworkId = (typeof NETWORKS)[number];
|
||||
|
||||
export const NETWORK_IDS: readonly NetworkId[] = NETWORKS;
|
||||
/**
|
||||
* Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. Steht hier, damit die reine
|
||||
* Konfiguration die ID des Community-Logos prüfen kann, ohne Appwrite zu laden (account/cards.ts reicht sie weiter).
|
||||
*/
|
||||
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
|
||||
/** Zoom-Grenzen relativ zum Cover-Maßstab. */
|
||||
export const MIN_ZOOM = 1;
|
||||
@@ -33,8 +29,10 @@ export interface Crop {
|
||||
export interface CardConfig {
|
||||
v: 1;
|
||||
template: TemplateId;
|
||||
network: NetworkId;
|
||||
/** ID des Community-Logos unten rechts; null = keins. */
|
||||
logo: string | null;
|
||||
details: DetailsTone;
|
||||
/** Das Logo oben links (AirCard-IOS Symbol oder Text, oder keins). */
|
||||
brand: BrandId;
|
||||
crop: Crop;
|
||||
}
|
||||
@@ -44,7 +42,7 @@ export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
|
||||
export const DEFAULT_CONFIG: CardConfig = {
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
logo: null,
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { ...DEFAULT_CROP },
|
||||
@@ -61,6 +59,11 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** Die ID eines Community-Logos; alles, was keine Appwrite-ID sein kann, ergibt null (kein Logo). */
|
||||
function parseLogo(value: unknown): string | null {
|
||||
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function finite(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
@@ -82,6 +85,8 @@ function parseCrop(value: unknown): Crop {
|
||||
/**
|
||||
* Liest eine gespeicherte Konfiguration. Jedes Feld wird einzeln geprüft, Unbekanntes oder
|
||||
* Kaputtes fällt auf den Standard dieses Feldes zurück. Liefert immer ein neues Objekt.
|
||||
* Ältere Karten kennen noch das Feld `network` (Kartennetzwerk) und das Apple-Logo: `network` wird ignoriert,
|
||||
* `brand: 'apple'` fällt auf den Standard.
|
||||
*/
|
||||
export function parseConfig(json: string | null | undefined): CardConfig {
|
||||
let raw: unknown = null;
|
||||
@@ -96,7 +101,7 @@ export function parseConfig(json: string | null | undefined): CardConfig {
|
||||
return {
|
||||
v: 1,
|
||||
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template),
|
||||
network: pick(r.network, NETWORKS, DEFAULT_CONFIG.network),
|
||||
logo: parseLogo(r.logo),
|
||||
details: pick(r.details, TONES, DEFAULT_CONFIG.details),
|
||||
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
|
||||
crop: parseCrop(r.crop),
|
||||
@@ -108,7 +113,7 @@ export function serializeConfig(c: CardConfig): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
template: c.template,
|
||||
network: c.network,
|
||||
logo: c.logo,
|
||||
details: c.details,
|
||||
brand: c.brand,
|
||||
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
|
||||
|
||||
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CARD_H,
|
||||
CARD_W,
|
||||
LOGO_MAX_H,
|
||||
MARGIN,
|
||||
PREVIEW_RADIUS,
|
||||
WALLET_KEEP_CLEAR,
|
||||
fitBrand,
|
||||
fitNetwork,
|
||||
fitLogo,
|
||||
layoutFor,
|
||||
type Box,
|
||||
} from './geometry';
|
||||
@@ -41,36 +42,48 @@ describe('Konstanten', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitNetwork', () => {
|
||||
it('Standardbreite 330 × 109 sitzt unten rechts auf dem Rand', () => {
|
||||
const b = fitNetwork(330 / 109, 109);
|
||||
expect(b.x).toBeCloseTo(1150, 1);
|
||||
expect(b.y).toBeCloseTo(804, 1);
|
||||
expect(b.w).toBeCloseTo(330, 1);
|
||||
expect(b.h).toBeCloseTo(109, 1);
|
||||
describe('fitLogo', () => {
|
||||
it('LOGO_MAX_H ist 125', () => {
|
||||
expect(LOGO_MAX_H).toBe(125);
|
||||
});
|
||||
|
||||
it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => {
|
||||
expect(fitLogo(2, 125)).toEqual({ x: 1230, y: 788, w: 250, h: 125 });
|
||||
});
|
||||
|
||||
it('ohne maxH gilt LOGO_MAX_H', () => {
|
||||
expect(fitLogo(2)).toEqual(fitLogo(2, LOGO_MAX_H));
|
||||
expect(fitLogo(1)).toEqual({ x: 1355, y: 788, w: 125, h: 125 });
|
||||
});
|
||||
|
||||
it('sehr breite Logos werden auf 360 begrenzt', () => {
|
||||
const b = fitNetwork(6, 109);
|
||||
const b = fitLogo(10, 125);
|
||||
expect(b.w).toBeCloseTo(360, 1);
|
||||
expect(b.h).toBeCloseTo(60, 1);
|
||||
expect(b.h).toBeCloseTo(36, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('reine Symbole dürfen bis 125 px hoch sein', () => {
|
||||
const b = fitNetwork(1, 125);
|
||||
expect(b.w).toBeCloseTo(125, 1);
|
||||
it('hohe Logos werden auf die Höhe begrenzt', () => {
|
||||
const b = fitLogo(0.5, 125);
|
||||
expect(b.h).toBeCloseTo(125, 1);
|
||||
expect(b.w).toBeCloseTo(62.5, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('maxH ist einstellbar', () => {
|
||||
const b = fitLogo(1, 100);
|
||||
expect(b.w).toBeCloseTo(100, 1);
|
||||
expect(b.h).toBeCloseTo(100, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('Seitenverhältnis bleibt erhalten', () => {
|
||||
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) {
|
||||
const b = fitNetwork(aspect, 109);
|
||||
const b = fitLogo(aspect);
|
||||
expect(b.w / b.h).toBeCloseTo(aspect, 1);
|
||||
expect(b.h).toBeLessThanOrEqual(109 + 1e-9);
|
||||
expect(b.h).toBeLessThanOrEqual(LOGO_MAX_H + 1e-9);
|
||||
expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
|
||||
}
|
||||
});
|
||||
@@ -105,23 +118,23 @@ describe('fitBrand', () => {
|
||||
});
|
||||
|
||||
describe('layoutFor', () => {
|
||||
it('image: kein Netzwerk, keine Marke, keine optionalen Felder', () => {
|
||||
it('image: kein Logo, keine Marke, keine optionalen Felder', () => {
|
||||
const l = layoutFor('image');
|
||||
expect(l.network).toBe(false);
|
||||
expect(l.logo).toBe(false);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||||
});
|
||||
|
||||
it('minimal: nur Netzwerk', () => {
|
||||
it('minimal: nur Logo', () => {
|
||||
const l = layoutFor('minimal');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||||
});
|
||||
|
||||
it('classic: alles', () => {
|
||||
const l = layoutFor('classic');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
|
||||
expect(l.chip).toEqual({
|
||||
@@ -141,9 +154,9 @@ describe('layoutFor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('signature: Netzwerk, Marke und PLATINUM links', () => {
|
||||
it('signature: Logo, Marke und PLATINUM links', () => {
|
||||
const l = layoutFor('signature');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
|
||||
expect(l.chip).toBeUndefined();
|
||||
@@ -193,9 +206,9 @@ describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (
|
||||
if (l.contactless) boxes.contactless = l.contactless.box;
|
||||
if (l.number) boxes.number = NUMBER_BOX;
|
||||
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
|
||||
if (l.network) {
|
||||
boxes.networkSymbol = fitNetwork(1, 125);
|
||||
boxes.networkWide = fitNetwork(6, 109);
|
||||
if (l.logo) {
|
||||
boxes.logoSquare = fitLogo(1);
|
||||
boxes.logoWide = fitLogo(10);
|
||||
}
|
||||
if (l.brand) boxes.brand = fitBrand(3.6);
|
||||
|
||||
|
||||
+17
-13
@@ -20,19 +20,21 @@ export interface Box {
|
||||
/** Unten links blendet Wallet die letzten vier Ziffern ein, hier darf nichts liegen. */
|
||||
export const WALLET_KEEP_CLEAR: Box = { x: 0, y: 779, w: 620, h: 190 };
|
||||
|
||||
const NETWORK_MAX_W = 360;
|
||||
const NETWORK_RIGHT = CARD_W - MARGIN; // 1480
|
||||
const NETWORK_BOTTOM = CARD_H - MARGIN; // 913
|
||||
/** Höhe, bis zu der ein Community-Logo unten rechts auf der Karte wachsen darf. */
|
||||
export const LOGO_MAX_H = 125;
|
||||
const LOGO_MAX_W = 360;
|
||||
const LOGO_RIGHT = CARD_W - MARGIN; // 1480
|
||||
const LOGO_BOTTOM = CARD_H - MARGIN; // 913
|
||||
const BRAND_MAX_W = 360;
|
||||
|
||||
/** Netzwerk-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
|
||||
export function fitNetwork(aspect: number, maxH: number): Box {
|
||||
const w = Math.min(NETWORK_MAX_W, maxH * aspect);
|
||||
/** Community-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
|
||||
export function fitLogo(aspect: number, maxH = LOGO_MAX_H): Box {
|
||||
const w = Math.min(LOGO_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
return { x: NETWORK_RIGHT - w, y: NETWORK_BOTTOM - h, w, h };
|
||||
return { x: LOGO_RIGHT - w, y: LOGO_BOTTOM - h, w, h };
|
||||
}
|
||||
|
||||
/** AirCard-/Apple-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
|
||||
/** AirCard-IOS-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
|
||||
export function fitBrand(aspect: number, maxH = 100): Box {
|
||||
const w = Math.min(BRAND_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
@@ -40,7 +42,9 @@ export function fitBrand(aspect: number, maxH = 100): Box {
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
network: boolean;
|
||||
/** Community-Logo unten rechts. */
|
||||
logo: boolean;
|
||||
/** AirCard-IOS-Logo oben links. */
|
||||
brand: boolean;
|
||||
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
|
||||
chip?: {
|
||||
@@ -55,10 +59,10 @@ export interface Layout {
|
||||
}
|
||||
|
||||
const LAYOUTS: Record<TemplateId, Layout> = {
|
||||
image: { network: false, brand: false },
|
||||
minimal: { network: true, brand: false },
|
||||
image: { logo: false, brand: false },
|
||||
minimal: { logo: true, brand: false },
|
||||
classic: {
|
||||
network: true,
|
||||
logo: true,
|
||||
brand: true,
|
||||
platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' },
|
||||
chip: {
|
||||
@@ -80,7 +84,7 @@ const LAYOUTS: Record<TemplateId, Layout> = {
|
||||
},
|
||||
},
|
||||
signature: {
|
||||
network: true,
|
||||
logo: true,
|
||||
brand: true,
|
||||
platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' },
|
||||
},
|
||||
|
||||
+48
-109
@@ -1,62 +1,39 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { NETWORK_IDS } from './config';
|
||||
import favicon from '../../public/favicon.svg?raw';
|
||||
import {
|
||||
BRAND_MARKS,
|
||||
NETWORK_MARKS,
|
||||
getBrandMark,
|
||||
getNetworkMark,
|
||||
loadMarkImage,
|
||||
svgAspect,
|
||||
tintSvg,
|
||||
} from './marks';
|
||||
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg } from './marks';
|
||||
|
||||
const NETWORK_LABELS = [
|
||||
'Automatisch', 'Blau', 'Marineblau', 'Metallisches Silber',
|
||||
'Farbe', 'Metallisches Silber', 'Wortmarke', 'Silber + Text', 'Klassisch',
|
||||
'Farbe', 'Metallisches Silber', 'Weißer Rahmen', 'Englisch',
|
||||
'Blau', 'Umriss', 'Select',
|
||||
'JCB', 'Discover',
|
||||
];
|
||||
const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
|
||||
|
||||
const ALL = [...NETWORK_MARKS, ...BRAND_MARKS];
|
||||
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple'];
|
||||
|
||||
describe('Registers', () => {
|
||||
it('NETWORK_MARKS folgt NETWORK_IDS', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.id)).toEqual([...NETWORK_IDS]);
|
||||
});
|
||||
|
||||
it('Labels stehen im Wortlaut der Vorlage in Reihenfolge', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.label)).toEqual(NETWORK_LABELS);
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text', 'Apple']);
|
||||
});
|
||||
|
||||
it('BRAND_MARKS: Logo-Gruppe, feste Reihenfolge', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(['aircards-symbol', 'aircards-text', 'apple']);
|
||||
describe('Register', () => {
|
||||
it('BRAND_MARKS: nur die zwei AirCard-IOS-Marken, in fester Reihenfolge, Logo-Gruppe', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(BRAND_IDS);
|
||||
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
|
||||
});
|
||||
|
||||
it('Gruppen der Netzwerke', () => {
|
||||
const groups = NETWORK_MARKS.map((m) => m.group);
|
||||
expect(groups).toEqual([
|
||||
'Visa', 'Visa', 'Visa', 'Visa',
|
||||
'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard',
|
||||
'UnionPay', 'UnionPay', 'UnionPay', 'UnionPay',
|
||||
'American Express', 'American Express', 'American Express',
|
||||
'JCB', 'Discover',
|
||||
]);
|
||||
it('Labels stehen im Wortlaut der Vorlage', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']);
|
||||
});
|
||||
|
||||
it('getNetworkMark / getBrandMark finden den Eintrag', () => {
|
||||
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id);
|
||||
expect(getBrandMark('apple').id).toBe('apple');
|
||||
it('getBrandMark findet den Eintrag', () => {
|
||||
for (const id of BRAND_IDS) expect(getBrandMark(id as 'aircards-symbol').id).toBe(id);
|
||||
expect(getBrandMark('aircards-text').label).toBe('AirCard-IOS · Text');
|
||||
});
|
||||
|
||||
it('keine Netzwerk- und keine Apple-Marken mehr im Register', () => {
|
||||
const registry = BRAND_MARKS.map((m) => `${m.id} ${m.group} ${m.label}`).join('\n');
|
||||
expect(registry).not.toMatch(/visa|mastercard|unionpay|american express|jcb|discover|apple/i);
|
||||
});
|
||||
|
||||
it('assets/marks enthält nur noch die zwei AirCard-IOS-Dateien (keine fremden Marken-Logos)', () => {
|
||||
const files = readdirSync(join(import.meta.dirname, '../assets/marks')).sort();
|
||||
expect(files).toEqual(['aircards-symbol.svg', 'aircards-text.svg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG-Inhalt', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
expect(m.svg.trimStart().startsWith('<svg')).toBe(true);
|
||||
expect(m.svg).toMatch(/viewBox="[^"]+"/);
|
||||
expect(m.svg).not.toContain('<image');
|
||||
@@ -67,7 +44,7 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
const root = /^<svg\b[^>]*>/.exec(m.svg.trimStart())![0];
|
||||
expect(root, m.id).toContain('xmlns="http://www.w3.org/2000/svg"');
|
||||
expect(root, m.id).not.toMatch(/\s(width|height)=/);
|
||||
@@ -84,24 +61,15 @@ describe('SVG-Inhalt', () => {
|
||||
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
|
||||
});
|
||||
|
||||
it('svgAspect: Visa ist ein breites Wortlogo', () => {
|
||||
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1);
|
||||
});
|
||||
|
||||
it('Einfärbbar genau bei den fünf Tint-Marken', () => {
|
||||
const tinted = ALL.filter((m) => m.tint).map((m) => m.id).sort();
|
||||
expect(tinted).toEqual([...TINT_IDS].sort());
|
||||
});
|
||||
|
||||
it('Tint-Marken enthalten currentColor, alle anderen nicht', () => {
|
||||
for (const m of ALL) {
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(m.tint);
|
||||
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
|
||||
for (const m of BRAND_MARKS) {
|
||||
expect(m.tint, m.id).toBe(true);
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg ersetzt jedes currentColor', () => {
|
||||
for (const id of TINT_IDS) {
|
||||
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
|
||||
for (const m of BRAND_MARKS) {
|
||||
const dark = tintSvg(m.svg, 'dark');
|
||||
expect(dark).toContain('#111111');
|
||||
expect(dark).not.toContain('currentColor');
|
||||
@@ -111,14 +79,14 @@ describe('SVG-Inhalt', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg lässt Marken ohne currentColor unverändert', () => {
|
||||
const m = getNetworkMark('jcb');
|
||||
expect(tintSvg(m.svg, 'dark')).toBe(m.svg);
|
||||
it('tintSvg lässt ein SVG ohne currentColor unverändert', () => {
|
||||
const svg = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
|
||||
expect(tintSvg(svg, 'dark')).toBe(svg);
|
||||
});
|
||||
|
||||
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
|
||||
const id = hit[1];
|
||||
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false);
|
||||
@@ -129,36 +97,15 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
describe('Verweise innerhalb der Dateien', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
const defined = new Set([...m.svg.matchAll(/\bid="([^"]+)"/g)].map((h) => h[1]));
|
||||
const refs = [...m.svg.matchAll(/url\(\s*['"]?#([^)'"\s]+)/g)].map((h) => h[1]);
|
||||
for (const ref of refs) expect(defined.has(ref), `${ref} fehlt in ${m.id}`).toBe(true);
|
||||
});
|
||||
|
||||
it('die Silber-/Verlaufs-Marken verwenden überhaupt Verweise', () => {
|
||||
for (const id of ['visa-silver', 'mc-silver', 'mc-silver-text', 'up-silver', 'jcb', 'discover']) {
|
||||
expect(getNetworkMark(id as never).svg, id).toMatch(/url\(#/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxH', () => {
|
||||
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
|
||||
|
||||
it('109 für Visa, Mastercard-Wortmarken, UnionPay English, Discover', () => {
|
||||
for (const id of ['visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', 'mc-wordmark', 'mc-silver-text', 'up-english', 'discover']) {
|
||||
expect(h(id), id).toBe(109);
|
||||
}
|
||||
});
|
||||
|
||||
it('125 für Symbol-Logos', () => {
|
||||
for (const id of ['mc-color', 'mc-silver', 'mc-classic', 'up-color', 'up-silver', 'up-white-frame', 'amex-blue', 'amex-outline', 'jcb']) {
|
||||
expect(h(id), id).toBe(125);
|
||||
}
|
||||
});
|
||||
|
||||
it('90 für Amex Select, 100 für alle Marken', () => {
|
||||
expect(h('amex-select')).toBe(90);
|
||||
it('100 für beide Marken', () => {
|
||||
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -290,11 +237,11 @@ describe('loadMarkImage', () => {
|
||||
|
||||
it('gleiches (id, tone) liefert dieselbe Promise und lädt nur einmal, anderer Ton eine andere', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
const a = m.loadMarkImage(visa, 'dark');
|
||||
const b = m.loadMarkImage(visa, 'dark');
|
||||
const c = m.loadMarkImage(visa, 'light');
|
||||
const d = m.loadMarkImage(m.getNetworkMark('jcb'), 'dark');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
const a = m.loadMarkImage(symbol, 'dark');
|
||||
const b = m.loadMarkImage(symbol, 'dark');
|
||||
const c = m.loadMarkImage(symbol, 'light');
|
||||
const d = m.loadMarkImage(m.getBrandMark('aircards-text'), 'dark');
|
||||
expect(b).toBe(a);
|
||||
expect(c).not.toBe(a);
|
||||
expect(d).not.toBe(a);
|
||||
@@ -302,15 +249,15 @@ describe('loadMarkImage', () => {
|
||||
expect(imgs[0]).toBeInstanceOf(FakeImage);
|
||||
expect(blobs).toHaveLength(3);
|
||||
// nach dem Laden bleibt der Eintrag im Cache
|
||||
expect(m.loadMarkImage(visa, 'dark')).toBe(a);
|
||||
expect(m.loadMarkImage(symbol, 'dark')).toBe(a);
|
||||
expect(blobs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
await m.loadMarkImage(visa, 'dark');
|
||||
await m.loadMarkImage(visa, 'light');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
await m.loadMarkImage(symbol, 'dark');
|
||||
await m.loadMarkImage(symbol, 'light');
|
||||
const [dark, light] = await Promise.all(blobs.map((b) => b.text()));
|
||||
expect(blobs[0].type).toBe('image/svg+xml');
|
||||
expect(dark).toContain('#111111');
|
||||
@@ -320,33 +267,25 @@ describe('loadMarkImage', () => {
|
||||
const root = /^<svg\b[^>]*>/.exec(dark)![0];
|
||||
const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]);
|
||||
const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]);
|
||||
expect(h).toBe(visa.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(visa.svg), 1);
|
||||
expect(h).toBe(symbol.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(symbol.svg), 1);
|
||||
// Bild bekommt die Blob-URL
|
||||
expect(env.images[0].src).toBe('blob:fake/1');
|
||||
expect(env.images[1].src).toBe('blob:fake/2');
|
||||
});
|
||||
|
||||
it('Marken ohne currentColor bleiben im Blob unverändert (bis auf die Größe)', async () => {
|
||||
const m = await fresh();
|
||||
const jcb = m.getNetworkMark('jcb');
|
||||
await m.loadMarkImage(jcb, 'dark');
|
||||
const text = await blobs[0].text();
|
||||
expect(text.replace(/^<svg width="[\d.]+" height="[\d.]+"/, '<svg')).toBe(jcb.svg);
|
||||
});
|
||||
|
||||
it('gibt die Blob-URL nach dem Laden frei', async () => {
|
||||
const m = await fresh();
|
||||
await m.loadMarkImage(m.getNetworkMark('visa-blue'), 'light');
|
||||
await m.loadMarkImage(m.getBrandMark('aircards-text'), 'light');
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
});
|
||||
|
||||
it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => {
|
||||
const m = await fresh();
|
||||
const def = m.getNetworkMark('visa-auto');
|
||||
const def = m.getBrandMark('aircards-symbol');
|
||||
env.mode = 'error';
|
||||
const first = m.loadMarkImage(def, 'dark');
|
||||
await expect(first).rejects.toThrow(/visa-auto/);
|
||||
await expect(first).rejects.toThrow(/aircards-symbol/);
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
|
||||
env.mode = 'load';
|
||||
|
||||
+8
-54
@@ -1,35 +1,17 @@
|
||||
// Logo-Register: SVG-Dateien der Netzwerke und Marken, Einfärben und Laden als Bild für den Canvas.
|
||||
// Logo-Register: SVG-Dateien der AirCard-IOS-Marken (Logo oben links), Einfärben und Laden als Bild für den Canvas.
|
||||
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
|
||||
// Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke.
|
||||
|
||||
import { TONE_COLOR, type BrandId, type DetailsTone, type NetworkId, NETWORK_IDS } from './config';
|
||||
import { TONE_COLOR, type BrandId, type DetailsTone } from './config';
|
||||
|
||||
import visaAuto from '../assets/marks/visa-auto.svg?raw';
|
||||
import visaBlue from '../assets/marks/visa-blue.svg?raw';
|
||||
import visaNavy from '../assets/marks/visa-navy.svg?raw';
|
||||
import visaSilver from '../assets/marks/visa-silver.svg?raw';
|
||||
import mcColor from '../assets/marks/mc-color.svg?raw';
|
||||
import mcSilver from '../assets/marks/mc-silver.svg?raw';
|
||||
import mcWordmark from '../assets/marks/mc-wordmark.svg?raw';
|
||||
import mcSilverText from '../assets/marks/mc-silver-text.svg?raw';
|
||||
import mcClassic from '../assets/marks/mc-classic.svg?raw';
|
||||
import upColor from '../assets/marks/up-color.svg?raw';
|
||||
import upSilver from '../assets/marks/up-silver.svg?raw';
|
||||
import upWhiteFrame from '../assets/marks/up-white-frame.svg?raw';
|
||||
import upEnglish from '../assets/marks/up-english.svg?raw';
|
||||
import amexBlue from '../assets/marks/amex-blue.svg?raw';
|
||||
import amexOutline from '../assets/marks/amex-outline.svg?raw';
|
||||
import amexSelect from '../assets/marks/amex-select.svg?raw';
|
||||
import jcb from '../assets/marks/jcb.svg?raw';
|
||||
import discover from '../assets/marks/discover.svg?raw';
|
||||
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
|
||||
import aircardsText from '../assets/marks/aircards-text.svg?raw';
|
||||
import apple from '../assets/marks/apple.svg?raw';
|
||||
|
||||
export type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo';
|
||||
export type MarkGroup = 'Logo';
|
||||
|
||||
export interface MarkDef {
|
||||
id: NetworkId | Exclude<BrandId, 'none'>;
|
||||
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */
|
||||
id: Exclude<BrandId, 'none'>;
|
||||
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“). */
|
||||
group: MarkGroup;
|
||||
label: string;
|
||||
svg: string;
|
||||
@@ -39,45 +21,17 @@ export interface MarkDef {
|
||||
maxH: number;
|
||||
}
|
||||
|
||||
type NetworkEntry = Omit<MarkDef, 'id'>;
|
||||
type MarkEntry = Omit<MarkDef, 'id'>;
|
||||
|
||||
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = {
|
||||
'visa-auto': { group: 'Visa', label: 'Automatisch', svg: visaAuto, tint: true, maxH: 109 },
|
||||
'visa-blue': { group: 'Visa', label: 'Blau', svg: visaBlue, tint: false, maxH: 109 },
|
||||
'visa-navy': { group: 'Visa', label: 'Marineblau', svg: visaNavy, tint: false, maxH: 109 },
|
||||
'visa-silver': { group: 'Visa', label: 'Metallisches Silber', svg: visaSilver, tint: false, maxH: 109 },
|
||||
'mc-color': { group: 'Mastercard', label: 'Farbe', svg: mcColor, tint: false, maxH: 125 },
|
||||
'mc-silver': { group: 'Mastercard', label: 'Metallisches Silber', svg: mcSilver, tint: false, maxH: 125 },
|
||||
'mc-wordmark': { group: 'Mastercard', label: 'Wortmarke', svg: mcWordmark, tint: false, maxH: 109 },
|
||||
'mc-silver-text': { group: 'Mastercard', label: 'Silber + Text', svg: mcSilverText, tint: false, maxH: 109 },
|
||||
'mc-classic': { group: 'Mastercard', label: 'Klassisch', svg: mcClassic, tint: false, maxH: 125 },
|
||||
'up-color': { group: 'UnionPay', label: 'Farbe', svg: upColor, tint: false, maxH: 125 },
|
||||
'up-silver': { group: 'UnionPay', label: 'Metallisches Silber', svg: upSilver, tint: false, maxH: 125 },
|
||||
'up-white-frame': { group: 'UnionPay', label: 'Weißer Rahmen', svg: upWhiteFrame, tint: false, maxH: 125 },
|
||||
'up-english': { group: 'UnionPay', label: 'Englisch', svg: upEnglish, tint: false, maxH: 109 },
|
||||
'amex-blue': { group: 'American Express', label: 'Blau', svg: amexBlue, tint: false, maxH: 125 },
|
||||
'amex-outline': { group: 'American Express', label: 'Umriss', svg: amexOutline, tint: true, maxH: 125 },
|
||||
'amex-select': { group: 'American Express', label: 'Select', svg: amexSelect, tint: false, maxH: 90 },
|
||||
jcb: { group: 'JCB', label: 'JCB', svg: jcb, tint: false, maxH: 125 },
|
||||
discover: { group: 'Discover', label: 'Discover', svg: discover, tint: false, maxH: 109 },
|
||||
};
|
||||
|
||||
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, NetworkEntry> = {
|
||||
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
|
||||
'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 },
|
||||
'aircards-text': { group: 'Logo', label: 'AirCard-IOS · Text', svg: aircardsText, tint: true, maxH: 100 },
|
||||
apple: { group: 'Logo', label: 'Apple', svg: apple, tint: true, maxH: 100 },
|
||||
};
|
||||
|
||||
export const NETWORK_MARKS: readonly MarkDef[] = NETWORK_IDS.map((id) => ({ id, ...NETWORK_TABLE[id] }));
|
||||
|
||||
export const BRAND_MARKS: readonly MarkDef[] = (
|
||||
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
|
||||
).map((id) => ({ id, ...BRAND_TABLE[id] }));
|
||||
|
||||
export function getNetworkMark(id: NetworkId): MarkDef {
|
||||
return NETWORK_MARKS.find((m) => m.id === id) ?? NETWORK_MARKS[0];
|
||||
}
|
||||
|
||||
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
|
||||
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
|
||||
}
|
||||
|
||||
+98
-32
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadLogoAsset } from '../logos/logo-asset';
|
||||
import { DEFAULT_CONFIG, type CardConfig } from './config';
|
||||
import { fitLogo } from './geometry';
|
||||
import { loadMarkImage } from './marks';
|
||||
import {
|
||||
drawCard,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
type CardAssets,
|
||||
type CardImage,
|
||||
type Ctx,
|
||||
type LogoAsset,
|
||||
} from './render';
|
||||
|
||||
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
|
||||
@@ -22,7 +25,6 @@ vi.mock('./marks', () => {
|
||||
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);
|
||||
@@ -32,6 +34,9 @@ vi.mock('./marks', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Der Standard-Lader der Logos (Appwrite, Dekodierung) kommt in diesen Tests nie zum Einsatz.
|
||||
vi.mock('../logos/logo-asset', () => ({ loadLogoAsset: vi.fn() }));
|
||||
|
||||
interface Call {
|
||||
name: string;
|
||||
args: unknown[];
|
||||
@@ -99,13 +104,15 @@ function fakeCtx() {
|
||||
return { ctx: ctx as unknown as Ctx, calls, strokePath };
|
||||
}
|
||||
|
||||
const NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
|
||||
const LOGO_SOURCE = { tag: 'logo' } as unknown as CanvasImageSource;
|
||||
const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
|
||||
const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
|
||||
/** Ein Community-Logo mit Seitenverhältnis 2 : 1 (die Größe in Pixeln spielt für den Platz auf der Karte keine Rolle). */
|
||||
const LOGO: LogoAsset = { source: LOGO_SOURCE, w: 200, h: 100 };
|
||||
|
||||
const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
|
||||
image: null,
|
||||
network: NETWORK,
|
||||
logo: LOGO,
|
||||
brand: BRAND,
|
||||
...over,
|
||||
});
|
||||
@@ -187,18 +194,42 @@ describe('renderCard: Grund und Bild', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Vorlagen', () => {
|
||||
it('minimal: nur das Netzwerk-Logo unten rechts', () => {
|
||||
it('minimal: nur das Community-Logo unten rechts (Seitenverhältnis 2 : 1 → 250 × 125, rechte Kante 1480, Unterkante 913)', () => {
|
||||
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);
|
||||
const [src, x, y, w, h] = drawsOf(calls, LOGO_SOURCE)[0].args as number[];
|
||||
expect(src).toBe(LOGO_SOURCE);
|
||||
expect([x, y, w, h]).toEqual([1230, 788, 250, 125]);
|
||||
expect(named(calls, 'fillText')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('das Logo sitzt dort, wo fitLogo es hinlegt (Seitenverhältnis aus w und h des Logos)', () => {
|
||||
const wide: LogoAsset = { source: LOGO_SOURCE, w: 720, h: 72 };
|
||||
const [src, x, y, w, h] = drawsOf(render(cfg({ template: 'classic' }), assets({ logo: wide })), LOGO_SOURCE)[0].args as number[];
|
||||
const box = fitLogo(10);
|
||||
expect(src).toBe(LOGO_SOURCE);
|
||||
expect([x, y, w, h]).toEqual([box.x, box.y, box.w, box.h]);
|
||||
expect(w).toBe(360);
|
||||
});
|
||||
|
||||
it('Vorlage image: auch mit Logo wird es nicht gezeichnet', () => {
|
||||
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(0);
|
||||
expect(named(calls, 'drawImage')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ohne Logo (assets.logo null) wird nichts unten rechts gezeichnet, auch wenn die Konfiguration eines wählt', () => {
|
||||
for (const template of ['minimal', 'classic', 'signature'] as const) {
|
||||
const calls = render(cfg({ template, logo: 'abc123' }), assets({ logo: null }));
|
||||
expect(drawsOf(calls, LOGO_SOURCE), template).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('das Logo hängt nur an den Bildern, nicht an config.logo (die Bilder gehören zur Konfiguration, mit der sie geladen wurden)', () => {
|
||||
const calls = render(cfg({ template: 'minimal', logo: null }), assets());
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('classic dunkel: Text in #111111, PLATINUM endet bei x 1480, Ziffern und Punkte stimmen', () => {
|
||||
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
|
||||
|
||||
@@ -238,11 +269,10 @@ describe('renderCard: Vorlagen', () => {
|
||||
expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
|
||||
});
|
||||
|
||||
it('classic: Brand-Logo links oben, Netzwerk-Logo unten rechts', () => {
|
||||
it('classic: Brand-Logo links oben, Community-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]);
|
||||
expect(drawsOf(calls, LOGO_SOURCE)[0].args).toEqual([LOGO_SOURCE, 1230, 788, 250, 125]);
|
||||
});
|
||||
|
||||
it('classic: Kontaktlos-Bögen in Details-Farbe mit runden Enden', () => {
|
||||
@@ -256,7 +286,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
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);
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
|
||||
const plat = letters(calls);
|
||||
expect(plat[0].args).toEqual(['P', 60, 226]);
|
||||
expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
|
||||
@@ -273,7 +303,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
});
|
||||
|
||||
it('fehlende Logo-Bilder werden übersprungen', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ network: null, brand: null }));
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ logo: null, brand: null }));
|
||||
expect(named(calls, 'drawImage')).toHaveLength(0);
|
||||
expect(letters(calls)).toHaveLength(8);
|
||||
});
|
||||
@@ -319,7 +349,7 @@ describe('renderCard: Chip', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Reihenfolge', () => {
|
||||
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Netzwerk', () => {
|
||||
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Logo', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
|
||||
const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
|
||||
const order = [
|
||||
@@ -330,7 +360,7 @@ describe('renderCard: Reihenfolge', () => {
|
||||
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),
|
||||
at((c) => c.name === 'drawImage' && c.args[0] === LOGO_SOURCE),
|
||||
];
|
||||
expect(order.every((i) => i >= 0)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
@@ -386,14 +416,17 @@ describe('drawCard', () => {
|
||||
});
|
||||
|
||||
describe('loadCardAssets', () => {
|
||||
const NET_IMG = { tag: 'net-img' };
|
||||
const BRAND_IMG = { tag: 'brand-img' };
|
||||
const fontsLoad = vi.fn();
|
||||
const mocked = vi.mocked(loadMarkImage);
|
||||
const defaultLoader = vi.mocked(loadLogoAsset);
|
||||
const loadLogo = vi.fn<(id: string, tone: 'light' | 'dark') => Promise<LogoAsset | null>>();
|
||||
|
||||
beforeEach(() => {
|
||||
fontsLoad.mockReset().mockResolvedValue([]);
|
||||
mocked.mockReset().mockImplementation(async (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
|
||||
mocked.mockReset().mockImplementation(async () => BRAND_IMG as unknown as HTMLImageElement);
|
||||
defaultLoader.mockReset().mockResolvedValue(LOGO);
|
||||
loadLogo.mockReset().mockResolvedValue(LOGO);
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
|
||||
});
|
||||
|
||||
@@ -401,35 +434,68 @@ describe('loadCardAssets', () => {
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
});
|
||||
|
||||
it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
|
||||
it('lädt Community-Logo 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);
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123', brand: 'aircards-text', details: 'dark', template: 'image' }), image, loadLogo);
|
||||
expect(result).toEqual({ image, logo: LOGO, brand: BRAND_IMG, logoMissing: false });
|
||||
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(loadLogo).toHaveBeenCalledTimes(1);
|
||||
expect(loadLogo).toHaveBeenCalledWith('abc123', 'dark');
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['aircards-text', 'dark']]);
|
||||
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
|
||||
});
|
||||
|
||||
it('ohne Logo (logo null): kein Lader-Aufruf, kein fehlendes Logo', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: null }), null, loadLogo);
|
||||
expect(loadLogo).not.toHaveBeenCalled();
|
||||
expect(result.logo).toBeNull();
|
||||
expect(result.logoMissing).toBe(false);
|
||||
});
|
||||
|
||||
it('eine leere logo-Angabe zählt wie kein Logo', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: '' }), null, loadLogo);
|
||||
expect(loadLogo).not.toHaveBeenCalled();
|
||||
expect(result.logoMissing).toBe(false);
|
||||
});
|
||||
|
||||
it('der Lader liefert null (gelöscht, ausgeblendet): logo null und logoMissing true, Brand-Logo wird trotzdem geladen', async () => {
|
||||
loadLogo.mockResolvedValue(null);
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo);
|
||||
expect(result.logo).toBeNull();
|
||||
expect(result.logoMissing).toBe(true);
|
||||
expect(result.brand).toBe(BRAND_IMG);
|
||||
});
|
||||
|
||||
it('der Lader wirft: loadCardAssets wirft (ein echter Ladefehler ist kein fehlendes Logo)', async () => {
|
||||
loadLogo.mockRejectedValue(new Error('offline'));
|
||||
await expect(loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo)).rejects.toThrow('offline');
|
||||
});
|
||||
|
||||
it('ohne dritten Parameter lädt loadLogoAsset', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123', details: 'light' }), null);
|
||||
expect(defaultLoader).toHaveBeenCalledWith('abc123', 'light');
|
||||
expect(result.logo).toBe(LOGO);
|
||||
});
|
||||
|
||||
it('Brand none: kein Brand-Logo laden', async () => {
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null);
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null, loadLogo);
|
||||
expect(result.image).toBeNull();
|
||||
expect(result.brand).toBeNull();
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 });
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
|
||||
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_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/);
|
||||
it('Marken-Fehler wird weitergereicht', async () => {
|
||||
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: aircards-symbol'));
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).rejects.toThrow(/Logo/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+36
-13
@@ -1,18 +1,33 @@
|
||||
// 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 { loadLogoAsset } from '../logos/logo-asset';
|
||||
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';
|
||||
import { CARD_H, CARD_W, fitBrand, fitLogo, layoutFor, type Layout } from './geometry';
|
||||
import { getBrandMark, loadMarkImage, svgAspect } from './marks';
|
||||
|
||||
export type CardImage = { source: CanvasImageSource; w: number; h: number };
|
||||
|
||||
/** Ein Community-Logo, fertig zum Zeichnen (einfarbige schon in der Details-Farbe); w/h = Pixel der Quelle, daraus folgt das Seitenverhältnis. */
|
||||
export interface LogoAsset {
|
||||
source: CanvasImageSource;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CardAssets {
|
||||
image: CardImage | null;
|
||||
network: CanvasImageSource | null;
|
||||
/** Das Community-Logo unten rechts; null = keins oder nicht verfügbar. */
|
||||
logo: LogoAsset | null;
|
||||
/** Das AirCard-IOS-Logo oben links. */
|
||||
brand: CanvasImageSource | null;
|
||||
}
|
||||
|
||||
/** Was `loadCardAssets` liefert: dazu die Auskunft, ob die Karte ein Logo wählt, das es nicht (mehr) gibt. */
|
||||
export interface LoadedAssets extends CardAssets {
|
||||
logoMissing: boolean;
|
||||
}
|
||||
|
||||
/** Der kleine Ausschnitt von CanvasRenderingContext2D, den der Renderer braucht (leicht zu faken). */
|
||||
export type Ctx = Pick<
|
||||
CanvasRenderingContext2D,
|
||||
@@ -112,7 +127,7 @@ function drawPlatinum(ctx: Ctx, p: NonNullable<Layout['platinum']>, color: strin
|
||||
});
|
||||
}
|
||||
|
||||
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk. */
|
||||
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Marke oben links, Logo unten rechts. */
|
||||
export function renderCard(
|
||||
ctx: Ctx,
|
||||
config: CardConfig,
|
||||
@@ -150,10 +165,10 @@ export function renderCard(
|
||||
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);
|
||||
// Gezeichnet wird, was die Bilder mitbringen (sie gehören zu der Konfiguration, mit der sie geladen wurden).
|
||||
if (layout.logo && assets.logo) {
|
||||
const l = fitLogo(assets.logo.w / assets.logo.h);
|
||||
ctx.drawImage(assets.logo.source, l.x, l.y, l.w, l.h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,17 +188,25 @@ export function drawCard(
|
||||
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> {
|
||||
/**
|
||||
* Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch.
|
||||
* Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet), ist `logo` null und `logoMissing` true; die Karte
|
||||
* lässt sich dann ohne Logo zeichnen. Ein echter Ladefehler (Netz, Server) wirft dagegen.
|
||||
*/
|
||||
export async function loadCardAssets(
|
||||
config: CardConfig,
|
||||
image: CardImage | null,
|
||||
loadLogo: (id: string, tone: DetailsTone) => Promise<LogoAsset | null> = loadLogoAsset,
|
||||
): Promise<LoadedAssets> {
|
||||
// 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),
|
||||
const [logo, brand] = await Promise.all([
|
||||
config.logo ? loadLogo(config.logo, config.details) : null,
|
||||
config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details),
|
||||
fontReady,
|
||||
]);
|
||||
return { image, network, brand };
|
||||
return { image, logo, brand, logoMissing: Boolean(config.logo) && logo === null };
|
||||
}
|
||||
|
||||
/** Rendert die Karte offscreen in der gewünschten Breite (Höhe nach 1536 : 969) und liefert die Bilddatei. */
|
||||
|
||||
Reference in new issue
Block a user