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:
ClaudeandClaude Opus 5.5 committed 2026-10-04 20:32:45 +00:00
1 parent 3051b3b3f9
commit 0be5abd954
44 files changed
+1401 -366

No files matched your search

+2 -2
View File
@@ -350,7 +350,7 @@ describe('save: bestehende Karte', () => {
it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => { it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => {
const fake = fakeApi([existing()]); const fake = fakeApi([existing()]);
const c = config({ network: 'jcb' }); const c = config({ logo: 'abc123' });
const saved = await service(fake).save( const saved = await service(fake).save(
newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }), newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }),
); );
@@ -370,7 +370,7 @@ describe('save: bestehende Karte', () => {
expect(saved.id).toBe('row1'); expect(saved.id).toBe('row1');
expect(saved.imageFileId).toBe('oldImage'); expect(saved.imageFileId).toBe('oldImage');
expect(saved.previewFileId).toBe('file1'); expect(saved.previewFileId).toBe('file1');
expect(saved.config.network).toBe('jcb'); expect(saved.config.logo).toBe('abc123');
}); });
it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => { it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => {
+3 -3
View File
@@ -1,7 +1,7 @@
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite. // Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen. // Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite'; import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
import { parseConfig, serializeConfig, type CardConfig } from '../card/config'; import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
import type { SavedCardRef } from '../editor/state'; import type { SavedCardRef } from '../editor/state';
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite'; import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
import { currentUserId } from './auth'; import { currentUserId } from './auth';
@@ -46,8 +46,8 @@ export interface SaveInput {
export const DEFAULT_CARD_NAME = 'Meine Karte'; export const DEFAULT_CARD_NAME = 'Meine Karte';
const NAME_MAX = 80; const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */ /** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; export { ID_PATTERN };
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */ /** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
export function normalizeName(name: string): string { export function normalizeName(name: string): string {
-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="-66 -67.24 1132.01 1132.01" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-width="36" d="M-48-49.24h1096v1096H-48z"/><path fill="currentColor" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 814.11 999.91" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2m-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3"/></svg>

Before

Width:  |  Height:  |  Size: 655 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 -0.49 220.58 37.13" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.125 0 0 -.125 -.2539 59.38386)"><path d="M145.605 261.297c-17.113-15.453-39.343-22.195-74.535-22.195H56.453v184.644H71.07c35.192 0 56.547-6.297 74.535-22.582 18.836-16.773 30.168-42.762 30.168-69.512 0-26.804-11.332-53.589-30.168-70.355M81.981 471.055H2.03V191.832h79.53c42.289 0 72.824 9.977 99.629 32.234 31.851 26.36 50.687 66.09 50.687 107.184 0 82.402-61.566 139.805-149.894 139.805m174.956-279.223h54.465v279.223h-54.465zm187.64 172.074c-32.683 12.094-42.281 20.071-42.281 35.157 0 17.585 17.098 30.949 40.574 30.949 16.32 0 29.731-6.703 43.922-22.614l28.496 37.317c-23.418 20.484-51.437 30.957-82.051 30.957-49.402 0-87.086-34.309-87.086-80.008 0-38.469 17.547-58.164 68.7-76.574 21.32-7.52 32.171-12.531 37.648-15.902 10.883-7.106 16.332-17.165 16.332-28.891 0-22.625-17.988-39.387-42.289-39.387-25.977 0-46.902 12.992-59.441 37.242l-35.188-33.879c25.094-36.835 55.231-53.16 96.672-53.16 56.602 0 96.305 37.633 96.305 91.688 0 44.363-18.356 64.445-80.313 87.105m97.496-32.656c0-82.074 64.446-145.715 147.383-145.715 23.445 0 43.523 4.61 68.285 16.274v64.113c-21.773-21.793-41.058-30.582-65.754-30.582-54.851 0-93.785 39.769-93.785 96.312 0 53.61 40.164 95.899 91.254 95.899 25.977 0 45.641-9.27 68.285-31.418v64.082c-23.906 12.129-43.566 17.152-67 17.152-82.515 0-148.668-64.929-148.668-146.117m647.516-47.75-74.46 187.555h-59.5l118.49-286.387h29.31l120.63 286.387h-59.04zm159.1-91.668h154.46v47.27h-100.04v75.371h96.35v47.297h-96.35v61.976h100.04v47.309h-154.46zm260.84 150.672h-15.91v84.57h16.77c33.92 0 52.36-14.211 52.36-41.386 0-28.071-18.44-43.184-53.22-43.184m109.27 46.117c0 52.274-36.01 82.434-98.82 82.434h-80.76V191.832h54.4V304h7.1l75.39-112.168h66.98l-87.9 117.633c41.02 8.34 63.61 36.402 63.61 79.156m24.44 68.59h-.99v6.414h1.05c2.9 0 4.4-1.043 4.4-3.156 0-2.172-1.52-3.258-4.46-3.258m10.32 3.348c0 4.894-3.37 7.57-9.29 7.57h-7.88v-24.543h5.86v9.519l6.87-9.519h7.15l-8.07 10.121c3.45.93 5.36 3.445 5.36 6.852" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><path d="M1745.37 438.332c-9.38 0-17.05 7.805-17.05 17.543 0 9.773 7.57 17.574 17.05 17.574 9.33 0 16.94-7.98 16.94-17.574 0-9.656-7.61-17.543-16.94-17.543m.08 38.969c-11.92 0-21.38-9.512-21.38-21.395 0-11.875 9.57-21.398 21.38-21.398 11.61 0 21.12 9.625 21.12 21.398 0 11.715-9.51 21.395-21.12 21.395" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><defs><linearGradient id="discover-g" x1=".3" x2=".7" y1="1" y2="0"><stop offset="0" stop-color="#ffb000"/><stop offset="1" stop-color="#e5600f"/></linearGradient></defs><circle cx="924.03" cy="330.5" r="148.5" fill="url(#discover-g)"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="1 1 298.01 229.52" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-17.94377 -13.02567)scale(1.821516)"><path d="M174 108.3c0 14-11.4 25.4-25.4 25.4H10.4V33.1c0-14 11.4-25.4 25.4-25.4H174z" style="fill:#fff"/><linearGradient id="jcb-g1_" x1="117.386" x2="157.33" y1="81.399" y2="81.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M129 82.5h10.5c.3 0 1-.1 1.3-.1 2-.4 3.7-2.2 3.7-4.7 0-2.4-1.7-4.2-3.7-4.7-.3-.1-.9-.1-1.3-.1H129z" style="fill:url(#jcb-g1_)"/><linearGradient id="jcb-g2_" x1="117.384" x2="157.332" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M138.3 16.2c-10 0-18.2 8.1-18.2 18.2v18.9h25.7c.6 0 1.3 0 1.8.1 5.8.3 10.1 3.3 10.1 8.5 0 4.1-2.9 7.6-8.3 8.3v.2c5.9.4 10.4 3.7 10.4 8.8 0 5.5-5 9.1-11.6 9.1H120v37h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g2_)"/><linearGradient id="jcb-g3_" x1="117.385" x2="157.331" y1="68.399" y2="68.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M143.2 63.1c0-2.4-1.7-4-3.7-4.3-.2 0-.7-.1-1-.1H129v8.8h9.5c.3 0 .9 0 1-.1 2-.3 3.7-1.9 3.7-4.3" style="fill:url(#jcb-g3_)"/><linearGradient id="jcb-g4_" x1="27.959" x2="68.522" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#1f286f"/><stop offset=".475" style="stop-color:#004e94"/><stop offset=".826" style="stop-color:#0066b1"/><stop offset="1" style="stop-color:#006fbc"/></linearGradient><path d="M37.7 16.2c-10 0-18.2 8.1-18.2 18.2v44.9c5.1 2.5 10.4 4.1 15.7 4.1 6.3 0 9.7-3.8 9.7-9V53.2h15.6v21.1c0 8.2-5.1 14.9-22.4 14.9-10.5 0-18.7-2.3-18.7-2.3v38.3h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g4_)"/><linearGradient id="jcb-g5_" x1="72.46" x2="111.855" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#6c2c2f"/><stop offset=".173" style="stop-color:#882730"/><stop offset=".573" style="stop-color:#be1833"/><stop offset=".859" style="stop-color:#dc0436"/><stop offset="1" style="stop-color:#e60039"/></linearGradient><path d="M88 16.2c-10 0-18.2 8.1-18.2 18.2v23.8c4.6-3.9 12.6-6.4 25.5-5.8 6.9.3 14.3 2.2 14.3 2.2v7.7c-3.7-1.9-8.1-3.6-13.8-4-9.8-.7-15.7 4.1-15.7 12.5 0 8.5 5.9 13.3 15.7 12.5 5.7-.4 10.1-2.2 13.8-4V87s-7.3 1.9-14.3 2.2c-12.9.6-20.9-1.9-25.5-5.8v42h26.7c10 0 18.2-8.1 18.2-18.2v-91z" style="fill:url(#jcb-g5_)"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="1.97 1.87 296.07 177.27" xmlns="http://www.w3.org/2000/svg"><path d="M298.032 90.5c.014 48.936-39.646 88.614-88.582 88.627-48.937.012-88.614-39.646-88.627-88.582V90.5c-.013-48.935 39.647-88.615 88.581-88.628 48.937-.013 88.615 39.647 88.628 88.583z" style="fill:#f90"/><path d="M90.001 1.895C41.355 2.204 1.967 41.781 1.967 90.5c0 48.909 39.695 88.604 88.605 88.604 22.955 0 43.879-8.748 59.624-23.086l-.007-.004h.019a89.4 89.4 0 0 0 8.995-9.488H141.05a87 87 0 0 1-6.606-9.127h31.308a89 89 0 0 0 5.158-9.488h-41.635a87 87 0 0 1-3.703-9.309h49.045a88.4 88.4 0 0 0 4.56-28.103c0-6.512-.706-12.861-2.042-18.974h-54.164a88 88 0 0 1 2.528-9.308h49.063a89 89 0 0 0-3.82-9.487H129.27a85 85 0 0 1 5.084-9.307h31.285a89 89 0 0 0-6.877-9.488h-17.443a82 82 0 0 1 8.889-8.95c-15.746-14.34-36.676-23.09-59.636-23.09z" style="fill:#c00"/><path d="M289.143 136.82q.723.001 1.409.371.688.37 1.07 1.062c.256.456.384.937.384 1.435q0 .739-.379 1.424-.377.683-1.061 1.063-.678.374-1.424.375c-.746.001-.972-.126-1.426-.375a2.7 2.7 0 0 1-1.063-1.063 2.9 2.9 0 0 1-.377-1.424c0-.498.127-.979.384-1.435a2.66 2.66 0 0 1 1.071-1.062 3 3 0 0 1 1.412-.371m0 .475q-.602 0-1.176.311c-.38.207-.677.5-.891.888a2.4 2.4 0 0 0-.325 1.194q0 .62.315 1.188.321.566.888.885.57.316 1.188.315.62 0 1.189-.315.567-.319.884-.885.313-.568.313-1.188 0-.623-.321-1.194a2.2 2.2 0 0 0-.894-.888 2.4 2.4 0 0 0-1.17-.311m-1.256 3.975v-3.082h1.062q.54-.001.784.088a.76.76 0 0 1 .388.297.81.81 0 0 1-.098 1.039.95.95 0 0 1-.639.281.9.9 0 0 1 .264.164q.186.18.455.61l.375.603h-.606l-.272-.485q-.322-.572-.521-.718-.137-.104-.397-.105h-.293v1.311h-.5m.496-1.738h.604q.431 0 .588-.129a.42.42 0 0 0 .159-.342.4.4 0 0 0-.075-.244.47.47 0 0 0-.213-.162q-.134-.054-.497-.055h-.564v.932" style="fill:#fcb340"/><path d="m133.719 99.926 1.18-8.02c-.645 0-1.593.279-2.431.279-3.284 0-3.694-1.755-3.436-3.037l3.236-16.13h4.992l1.029-9.103h-4.705l.958-5.516H124.7c-.208.208-5.568 31.022-5.568 34.776 0 5.555 3.118 8.027 7.516 7.988 3.442-.029 6.125-.982 7.071-1.237m2.987-15.288c0 13.332 8.799 16.499 16.297 16.499 6.921 0 10.55-1.604 10.55-1.604l1.662-9.1s-5.848 2.378-10.601 2.378c-10.131 0-8.355-7.554-8.355-7.554l19.463.059s1.239-6.111 1.239-8.602c0-6.217-3.387-13.849-13.745-13.849-9.486.002-16.51 10.223-16.51 21.773m16.546-13.325c5.324 0 4.342 5.984 4.342 6.469H147.12c0-.62.989-6.469 6.132-6.469m59.738 28.61 1.689-10.284s-4.632 2.321-7.807 2.321c-6.693 0-9.378-5.11-9.378-10.601 0-11.137 5.758-17.265 12.168-17.265 4.808 0 8.665 2.699 8.665 2.699l1.54-9.993s-4.554-3.289-9.456-3.308c-14.745-.058-23.182 10.208-23.182 27.955 0 11.763 6.248 19.768 17.506 19.768 3.183 0 8.255-1.292 8.255-1.292M81.83 63.012c-6.469 0-11.427 2.079-11.427 2.079l-1.37 8.127s4.093-1.663 10.281-1.663c3.513 0 6.083.395 6.083 3.25 0 1.734-.314 2.374-.314 2.374s-2.772-.231-4.056-.231c-9.21 0-16.729 3.482-16.729 13.98 0 8.273 5.623 10.17 9.108 10.17 6.657 0 9.292-4.203 9.444-4.215l-.077 3.488h8.307l3.706-25.98c0-11.025-9.616-11.379-12.956-11.379m1.438 21.096c.181 1.586-.41 9.086-6.092 9.086-2.93 0-3.691-2.24-3.691-3.562 0-2.584 1.403-5.683 8.315-5.683 1.61 0 1.197.116 1.468.159m20.347 16.798c2.125 0 14.272.541 14.272-11.994 0-11.721-11.244-9.404-11.244-14.114 0-2.342 1.833-3.08 5.184-3.08 1.329 0 6.447.423 6.447.423l1.189-8.33c0 .001-3.312-.741-8.704-.741-6.979 0-14.063 2.786-14.063 12.318 0 10.802 11.812 9.717 11.812 14.267 0 3.037-3.3 3.287-5.844 3.287-4.401 0-8.363-1.511-8.377-1.438l-1.259 8.245c.229.07 2.674 1.157 10.587 1.157m187.192-45.451-1.705 12.709s-3.553-4.905-9.112-4.905c-10.459 0-15.849 10.423-15.849 22.396 0 7.73 3.844 15.307 11.699 15.307 5.651 0 8.784-3.941 8.784-3.941l-.415 3.365h9.178l7.207-44.862zm-4.052 24.701c0 4.983-2.468 11.64-7.581 11.64-3.396 0-4.988-2.851-4.988-7.324 0-7.315 3.285-12.14 7.432-12.14 3.394 0 5.137 2.33 5.137 7.824M30.749 100.423l5.743-33.87.844 33.87h6.499l12.125-33.87-5.371 33.87h9.658l7.437-44.922-15.342-.117-9.126 27.504-.25-27.387h-14.06l-7.544 44.922z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M176.101 100.487c2.746-15.615 3.724-27.947 11.732-25.393 1.15-6.044 3.891-11.3 5.143-13.858 0 0-.396-.589-2.871-.589-4.225 0-9.866 8.574-9.866 8.574l.843-5.301h-8.786l-5.884 36.566h9.689z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M-612.55 63.012c-6.472 0-11.43 2.079-11.43 2.079l-1.369 8.127s4.095-1.663 10.28-1.663c3.514 0 6.083.395 6.083 3.25 0 1.734-.313 2.374-.313 2.374s-2.771-.231-4.055-.231c-9.211 0-16.729 3.482-16.729 13.98 0 8.273 5.622 10.17 9.107 10.17 6.655 0 9.292-4.203 9.443-4.215l-.078 3.488h8.309l3.705-25.98c.001-11.025-9.615-11.379-12.953-11.379m1.436 21.096c.18 1.586-.411 9.086-6.092 9.086-2.932 0-3.692-2.24-3.692-3.562 0-2.584 1.402-5.683 8.315-5.683 1.611 0 1.199.116 1.469.159" style="fill:#006" transform="translate(831.55605 15.9939)"/><path d="M255.266 100.487c1.508-11.488 4.299-27.616 11.731-25.393 1.149-6.044.041-6.028-2.433-6.028-4.228 0-5.164.154-5.164.154l.844-5.301h-8.785l-5.884 36.567h9.691z" style="fill:#006" Line truncated

Before

Width:  |  Height:  |  Size: 8.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><path fill="#eb001b" d="M308 0a309 309 0 1 0 2 0z"/><path fill="#f79e1b" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#ff5f00" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>

Before

Width:  |  Height:  |  Size: 253 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-text-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-text-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="#8c8c8c" d="M364 66.1h270.4v485.8H364z"/><path fill="url(#mc-silver-text-g1)" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="url(#mc-silver-text-g2)" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#mc-silver-g1)" d="M308 0a309 309 0 1 0 2 0z"/><path fill="url(#mc-silver-g2)" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#8c8c8c" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>

Before

Width:  |  Height:  |  Size: 686 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><path fill="#ff5a00" d="M364 66.1h270.4v485.8H364z"/><path fill="#eb001b" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="#f79e1b" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated

Before

Width:  |  Height:  |  Size: 7.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 51.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 54.65 66.613 52.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated

Before

Width:  |  Height:  |  Size: 5.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="up-silver-g1" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#f8f8f8"/><stop offset="1" stop-color="#bdbdbd"/></linearGradient><linearGradient id="up-silver-g2" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#d6d6d6"/><stop offset="1" stop-color="#8a8a8a"/></linearGradient><linearGradient id="up-silver-g3" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#ebebeb"/><stop offset="1" stop-color="#a4a4a4"/></linearGradient><mask id="up-silver-m" width="280" height="180" x="-10" y="-10" maskUnits="userSpaceOnUse"><path fill="#fff" d="M-10-10h280v180H-10z"/><path d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.353c-4.148.802-4.246.93-8.076 1.421l-.299.267c-.042.128Line truncated

Before

Width:  |  Height:  |  Size: 8.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="-9.6 -9.6 274.74 179.05" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.Line truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 825 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1434cb" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 820 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1a1f71" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 820 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="visa-silver-g" x1=".15" x2=".85" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#visa-silver-g)" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

+49 -22
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { import {
DEFAULT_CONFIG, DEFAULT_CONFIG,
DEFAULT_CROP, DEFAULT_CROP,
NETWORK_IDS, ID_PATTERN,
TONE_COLOR, TONE_COLOR,
parseConfig, parseConfig,
serializeConfig, serializeConfig,
@@ -12,9 +12,9 @@ import {
const custom: CardConfig = { const custom: CardConfig = {
v: 1, v: 1,
template: 'classic', template: 'classic',
network: 'jcb', logo: 'abc123',
details: 'dark', details: 'dark',
brand: 'apple', brand: 'aircards-text',
crop: { x: -120.5, y: 33, zoom: 2.5 }, crop: { x: -120.5, y: 33, zoom: 2.5 },
}; };
@@ -23,7 +23,7 @@ describe('Standardwerte', () => {
expect(DEFAULT_CONFIG).toEqual({ expect(DEFAULT_CONFIG).toEqual({
v: 1, v: 1,
template: 'signature', template: 'signature',
network: 'visa-auto', logo: null,
details: 'light', details: 'light',
brand: 'aircards-symbol', brand: 'aircards-symbol',
crop: { x: 0, y: 0, zoom: 1 }, crop: { x: 0, y: 0, zoom: 1 },
@@ -36,16 +36,13 @@ describe('Standardwerte', () => {
expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP); expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP);
}); });
it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => { it('eine neue Karte hat kein Logo unten rechts', () => {
expect(NETWORK_IDS.length).toBe(18); expect(DEFAULT_CONFIG.logo).toBeNull();
expect([...NETWORK_IDS]).toEqual([ });
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic', it('ID_PATTERN: Appwrite-IDs mit höchstens 36 Zeichen und ohne Sonderzeichen am Anfang', () => {
'up-color', 'up-silver', 'up-white-frame', 'up-english', for (const ok of ['abc123', 'A', '0', 'a.b_c-d', 'x'.repeat(36)]) expect(ID_PATTERN.test(ok), ok).toBe(true);
'amex-blue', 'amex-outline', 'amex-select', for (const bad of ['', 'kaputt id', '-abc', '_abc', '.abc', 'ä', 'a/b', 'x'.repeat(37)]) expect(ID_PATTERN.test(bad), bad).toBe(false);
'jcb', 'discover',
]);
expect(new Set(NETWORK_IDS).size).toBe(18);
}); });
it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => { 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', () => { it('eine alte Konfiguration mit network und Apple-Logo: network entfällt, Logo oben links fällt auf den Standard', () => {
const raw = JSON.stringify({ ...custom, network: 'paypal' }); const raw = '{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}';
expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network }); 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', () => { it('jedes Feld wird einzeln validiert', () => {
@@ -88,11 +107,11 @@ describe('parseConfig / serializeConfig', () => {
}); });
it('Namen aus Object.prototype gelten nicht als gültige Werte', () => { 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({ expect(parseConfig(raw)).toEqual({
...custom, ...custom,
network: DEFAULT_CONFIG.network,
template: DEFAULT_CONFIG.template, template: DEFAULT_CONFIG.template,
details: DEFAULT_CONFIG.details,
brand: DEFAULT_CONFIG.brand, brand: DEFAULT_CONFIG.brand,
}); });
}); });
@@ -137,21 +156,29 @@ describe('parseConfig / serializeConfig', () => {
const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } }); const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } });
const c = parseConfig(raw); const c = parseConfig(raw);
expect(c).toEqual(custom); 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']); expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
}); });
it('serializeConfig schreibt nur bekannte Felder', () => { 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('evil');
expect(s).not.toContain('network');
expect(JSON.parse(s)).toEqual(custom); 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', () => { it('serializeConfig bleibt unter 2000 Zeichen', () => {
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000); expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
const worst: CardConfig = { const worst: CardConfig = {
...custom, ...custom,
network: 'mc-silver-text', logo: 'x'.repeat(36),
brand: 'aircards-symbol', brand: 'aircards-symbol',
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 }, crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
}; };
+19 -14
View File
@@ -3,21 +3,17 @@
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const; const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
const TONES = ['light', 'dark'] as const; const TONES = ['light', 'dark'] as const;
const BRANDS = ['aircards-symbol', 'aircards-text', 'apple', 'none'] as const; const BRANDS = ['aircards-symbol', 'aircards-text', '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;
export type TemplateId = (typeof TEMPLATES)[number]; export type TemplateId = (typeof TEMPLATES)[number];
export type DetailsTone = (typeof TONES)[number]; export type DetailsTone = (typeof TONES)[number];
export type BrandId = (typeof BRANDS)[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. */ /** Zoom-Grenzen relativ zum Cover-Maßstab. */
export const MIN_ZOOM = 1; export const MIN_ZOOM = 1;
@@ -33,8 +29,10 @@ export interface Crop {
export interface CardConfig { export interface CardConfig {
v: 1; v: 1;
template: TemplateId; template: TemplateId;
network: NetworkId; /** ID des Community-Logos unten rechts; null = keins. */
logo: string | null;
details: DetailsTone; details: DetailsTone;
/** Das Logo oben links (AirCard-IOS Symbol oder Text, oder keins). */
brand: BrandId; brand: BrandId;
crop: Crop; crop: Crop;
} }
@@ -44,7 +42,7 @@ export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
export const DEFAULT_CONFIG: CardConfig = { export const DEFAULT_CONFIG: CardConfig = {
v: 1, v: 1,
template: 'signature', template: 'signature',
network: 'visa-auto', logo: null,
details: 'light', details: 'light',
brand: 'aircards-symbol', brand: 'aircards-symbol',
crop: { ...DEFAULT_CROP }, crop: { ...DEFAULT_CROP },
@@ -61,6 +59,11 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
: 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 { function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback; 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 * 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. * 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 { export function parseConfig(json: string | null | undefined): CardConfig {
let raw: unknown = null; let raw: unknown = null;
@@ -96,7 +101,7 @@ export function parseConfig(json: string | null | undefined): CardConfig {
return { return {
v: 1, v: 1,
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template), 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), details: pick(r.details, TONES, DEFAULT_CONFIG.details),
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand), brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
crop: parseCrop(r.crop), crop: parseCrop(r.crop),
@@ -108,7 +113,7 @@ export function serializeConfig(c: CardConfig): string {
return JSON.stringify({ return JSON.stringify({
v: 1, v: 1,
template: c.template, template: c.template,
network: c.network, logo: c.logo,
details: c.details, details: c.details,
brand: c.brand, brand: c.brand,
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom }, crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
+40 -27
View File
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
import { import {
CARD_H, CARD_H,
CARD_W, CARD_W,
LOGO_MAX_H,
MARGIN, MARGIN,
PREVIEW_RADIUS, PREVIEW_RADIUS,
WALLET_KEEP_CLEAR, WALLET_KEEP_CLEAR,
fitBrand, fitBrand,
fitNetwork, fitLogo,
layoutFor, layoutFor,
type Box, type Box,
} from './geometry'; } from './geometry';
@@ -41,36 +42,48 @@ describe('Konstanten', () => {
}); });
}); });
describe('fitNetwork', () => { describe('fitLogo', () => {
it('Standardbreite 330 × 109 sitzt unten rechts auf dem Rand', () => { it('LOGO_MAX_H ist 125', () => {
const b = fitNetwork(330 / 109, 109); expect(LOGO_MAX_H).toBe(125);
expect(b.x).toBeCloseTo(1150, 1); });
expect(b.y).toBeCloseTo(804, 1);
expect(b.w).toBeCloseTo(330, 1); it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => {
expect(b.h).toBeCloseTo(109, 1); 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', () => { 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.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.x + b.w).toBeCloseTo(1480, 1);
expect(b.y + b.h).toBeCloseTo(913, 1); expect(b.y + b.h).toBeCloseTo(913, 1);
}); });
it('reine Symbole dürfen bis 125 px hoch sein', () => { it('hohe Logos werden auf die Höhe begrenzt', () => {
const b = fitNetwork(1, 125); const b = fitLogo(0.5, 125);
expect(b.w).toBeCloseTo(125, 1);
expect(b.h).toBeCloseTo(125, 1); expect(b.h).toBeCloseTo(125, 1);
expect(b.w).toBeCloseTo(62.5, 1);
expect(b.x + b.w).toBeCloseTo(1480, 1); expect(b.x + b.w).toBeCloseTo(1480, 1);
expect(b.y + b.h).toBeCloseTo(913, 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', () => { it('Seitenverhältnis bleibt erhalten', () => {
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) { 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.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); expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
} }
}); });
@@ -105,23 +118,23 @@ describe('fitBrand', () => {
}); });
describe('layoutFor', () => { describe('layoutFor', () => {
it('image: kein Netzwerk, keine Marke, keine optionalen Felder', () => { it('image: kein Logo, keine Marke, keine optionalen Felder', () => {
const l = layoutFor('image'); const l = layoutFor('image');
expect(l.network).toBe(false); expect(l.logo).toBe(false);
expect(l.brand).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'); const l = layoutFor('minimal');
expect(l.network).toBe(true); expect(l.logo).toBe(true);
expect(l.brand).toBe(false); expect(l.brand).toBe(false);
expect(Object.keys(l).sort()).toEqual(['brand', 'network']); expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
}); });
it('classic: alles', () => { it('classic: alles', () => {
const l = layoutFor('classic'); const l = layoutFor('classic');
expect(l.network).toBe(true); expect(l.logo).toBe(true);
expect(l.brand).toBe(true); expect(l.brand).toBe(true);
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' }); expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
expect(l.chip).toEqual({ 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'); const l = layoutFor('signature');
expect(l.network).toBe(true); expect(l.logo).toBe(true);
expect(l.brand).toBe(true); expect(l.brand).toBe(true);
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' }); expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
expect(l.chip).toBeUndefined(); 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.contactless) boxes.contactless = l.contactless.box;
if (l.number) boxes.number = NUMBER_BOX; if (l.number) boxes.number = NUMBER_BOX;
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!; if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
if (l.network) { if (l.logo) {
boxes.networkSymbol = fitNetwork(1, 125); boxes.logoSquare = fitLogo(1);
boxes.networkWide = fitNetwork(6, 109); boxes.logoWide = fitLogo(10);
} }
if (l.brand) boxes.brand = fitBrand(3.6); if (l.brand) boxes.brand = fitBrand(3.6);
+17 -13
View File
@@ -20,19 +20,21 @@ export interface Box {
/** Unten links blendet Wallet die letzten vier Ziffern ein, hier darf nichts liegen. */ /** 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 }; export const WALLET_KEEP_CLEAR: Box = { x: 0, y: 779, w: 620, h: 190 };
const NETWORK_MAX_W = 360; /** Höhe, bis zu der ein Community-Logo unten rechts auf der Karte wachsen darf. */
const NETWORK_RIGHT = CARD_W - MARGIN; // 1480 export const LOGO_MAX_H = 125;
const NETWORK_BOTTOM = CARD_H - MARGIN; // 913 const LOGO_MAX_W = 360;
const LOGO_RIGHT = CARD_W - MARGIN; // 1480
const LOGO_BOTTOM = CARD_H - MARGIN; // 913
const BRAND_MAX_W = 360; const BRAND_MAX_W = 360;
/** Netzwerk-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */ /** Community-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
export function fitNetwork(aspect: number, maxH: number): Box { export function fitLogo(aspect: number, maxH = LOGO_MAX_H): Box {
const w = Math.min(NETWORK_MAX_W, maxH * aspect); const w = Math.min(LOGO_MAX_W, maxH * aspect);
const h = w / 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 { export function fitBrand(aspect: number, maxH = 100): Box {
const w = Math.min(BRAND_MAX_W, maxH * aspect); const w = Math.min(BRAND_MAX_W, maxH * aspect);
const h = w / aspect; const h = w / aspect;
@@ -40,7 +42,9 @@ export function fitBrand(aspect: number, maxH = 100): Box {
} }
export interface Layout { export interface Layout {
network: boolean; /** Community-Logo unten rechts. */
logo: boolean;
/** AirCard-IOS-Logo oben links. */
brand: boolean; brand: boolean;
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' }; platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
chip?: { chip?: {
@@ -55,10 +59,10 @@ export interface Layout {
} }
const LAYOUTS: Record<TemplateId, Layout> = { const LAYOUTS: Record<TemplateId, Layout> = {
image: { network: false, brand: false }, image: { logo: false, brand: false },
minimal: { network: true, brand: false }, minimal: { logo: true, brand: false },
classic: { classic: {
network: true, logo: true,
brand: true, brand: true,
platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' }, platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' },
chip: { chip: {
@@ -80,7 +84,7 @@ const LAYOUTS: Record<TemplateId, Layout> = {
}, },
}, },
signature: { signature: {
network: true, logo: true,
brand: true, brand: true,
platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' }, platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' },
}, },
+48 -109
View File
@@ -1,62 +1,39 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { NETWORK_IDS } from './config';
import favicon from '../../public/favicon.svg?raw'; import favicon from '../../public/favicon.svg?raw';
import { import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg } from './marks';
BRAND_MARKS,
NETWORK_MARKS,
getBrandMark,
getNetworkMark,
loadMarkImage,
svgAspect,
tintSvg,
} from './marks';
const NETWORK_LABELS = [ const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
'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 ALL = [...NETWORK_MARKS, ...BRAND_MARKS]; describe('Register', () => {
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple']; it('BRAND_MARKS: nur die zwei AirCard-IOS-Marken, in fester Reihenfolge, Logo-Gruppe', () => {
expect(BRAND_MARKS.map((m) => m.id)).toEqual(BRAND_IDS);
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']);
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true); expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
}); });
it('Gruppen der Netzwerke', () => { it('Labels stehen im Wortlaut der Vorlage', () => {
const groups = NETWORK_MARKS.map((m) => m.group); expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']);
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('getNetworkMark / getBrandMark finden den Eintrag', () => { it('getBrandMark findet den Eintrag', () => {
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id); for (const id of BRAND_IDS) expect(getBrandMark(id as 'aircards-symbol').id).toBe(id);
expect(getBrandMark('apple').id).toBe('apple');
expect(getBrandMark('aircards-text').label).toBe('AirCard-IOS · Text'); 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', () => { 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.trimStart().startsWith('<svg')).toBe(true);
expect(m.svg).toMatch(/viewBox="[^"]+"/); expect(m.svg).toMatch(/viewBox="[^"]+"/);
expect(m.svg).not.toContain('<image'); 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)', () => { 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]; 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).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(root, m.id).not.toMatch(/\s(width|height)=/); 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); expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
}); });
it('svgAspect: Visa ist ein breites Wortlogo', () => { it('beide Marken sind einfärbbar und enthalten currentColor', () => {
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1); for (const m of BRAND_MARKS) {
}); expect(m.tint, m.id).toBe(true);
expect(m.svg.includes('currentColor'), m.id).toBe(true);
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('tintSvg ersetzt jedes currentColor', () => { it('tintSvg ersetzt jedes currentColor', () => {
for (const id of TINT_IDS) { for (const m of BRAND_MARKS) {
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
const dark = tintSvg(m.svg, 'dark'); const dark = tintSvg(m.svg, 'dark');
expect(dark).toContain('#111111'); expect(dark).toContain('#111111');
expect(dark).not.toContain('currentColor'); expect(dark).not.toContain('currentColor');
@@ -111,14 +79,14 @@ describe('SVG-Inhalt', () => {
} }
}); });
it('tintSvg lässt Marken ohne currentColor unverändert', () => { it('tintSvg lässt ein SVG ohne currentColor unverändert', () => {
const m = getNetworkMark('jcb'); const svg = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
expect(tintSvg(m.svg, 'dark')).toBe(m.svg); expect(tintSvg(svg, 'dark')).toBe(svg);
}); });
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => { it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
const seen = new Map<string, string>(); 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)) { for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
const id = hit[1]; const id = hit[1];
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false); 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', () => { 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 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]); 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); 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', () => { describe('maxH', () => {
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH; it('100 für beide Marken', () => {
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);
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true); 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 () => { it('gleiches (id, tone) liefert dieselbe Promise und lädt nur einmal, anderer Ton eine andere', async () => {
const m = await fresh(); const m = await fresh();
const visa = m.getNetworkMark('visa-auto'); const symbol = m.getBrandMark('aircards-symbol');
const a = m.loadMarkImage(visa, 'dark'); const a = m.loadMarkImage(symbol, 'dark');
const b = m.loadMarkImage(visa, 'dark'); const b = m.loadMarkImage(symbol, 'dark');
const c = m.loadMarkImage(visa, 'light'); const c = m.loadMarkImage(symbol, 'light');
const d = m.loadMarkImage(m.getNetworkMark('jcb'), 'dark'); const d = m.loadMarkImage(m.getBrandMark('aircards-text'), 'dark');
expect(b).toBe(a); expect(b).toBe(a);
expect(c).not.toBe(a); expect(c).not.toBe(a);
expect(d).not.toBe(a); expect(d).not.toBe(a);
@@ -302,15 +249,15 @@ describe('loadMarkImage', () => {
expect(imgs[0]).toBeInstanceOf(FakeImage); expect(imgs[0]).toBeInstanceOf(FakeImage);
expect(blobs).toHaveLength(3); expect(blobs).toHaveLength(3);
// nach dem Laden bleibt der Eintrag im Cache // 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); expect(blobs).toHaveLength(3);
}); });
it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => { it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => {
const m = await fresh(); const m = await fresh();
const visa = m.getNetworkMark('visa-auto'); const symbol = m.getBrandMark('aircards-symbol');
await m.loadMarkImage(visa, 'dark'); await m.loadMarkImage(symbol, 'dark');
await m.loadMarkImage(visa, 'light'); await m.loadMarkImage(symbol, 'light');
const [dark, light] = await Promise.all(blobs.map((b) => b.text())); const [dark, light] = await Promise.all(blobs.map((b) => b.text()));
expect(blobs[0].type).toBe('image/svg+xml'); expect(blobs[0].type).toBe('image/svg+xml');
expect(dark).toContain('#111111'); expect(dark).toContain('#111111');
@@ -320,33 +267,25 @@ describe('loadMarkImage', () => {
const root = /^<svg\b[^>]*>/.exec(dark)![0]; const root = /^<svg\b[^>]*>/.exec(dark)![0];
const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]); const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]);
const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]); const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]);
expect(h).toBe(visa.maxH * 4); expect(h).toBe(symbol.maxH * 4);
expect(w / h).toBeCloseTo(m.svgAspect(visa.svg), 1); expect(w / h).toBeCloseTo(m.svgAspect(symbol.svg), 1);
// Bild bekommt die Blob-URL // Bild bekommt die Blob-URL
expect(env.images[0].src).toBe('blob:fake/1'); expect(env.images[0].src).toBe('blob:fake/1');
expect(env.images[1].src).toBe('blob:fake/2'); 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 () => { it('gibt die Blob-URL nach dem Laden frei', async () => {
const m = await fresh(); 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']); expect(revoked).toEqual(['blob:fake/1']);
}); });
it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => { it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => {
const m = await fresh(); const m = await fresh();
const def = m.getNetworkMark('visa-auto'); const def = m.getBrandMark('aircards-symbol');
env.mode = 'error'; env.mode = 'error';
const first = m.loadMarkImage(def, 'dark'); 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']); expect(revoked).toEqual(['blob:fake/1']);
env.mode = 'load'; env.mode = 'load';
+8 -54
View File
@@ -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. // 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 aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
import aircardsText from '../assets/marks/aircards-text.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 { export interface MarkDef {
id: NetworkId | Exclude<BrandId, 'none'>; id: Exclude<BrandId, 'none'>;
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */ /** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“). */
group: MarkGroup; group: MarkGroup;
label: string; label: string;
svg: string; svg: string;
@@ -39,45 +21,17 @@ export interface MarkDef {
maxH: number; maxH: number;
} }
type NetworkEntry = Omit<MarkDef, 'id'>; type MarkEntry = Omit<MarkDef, 'id'>;
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = { const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
'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> = {
'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 }, '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 }, '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[] = ( export const BRAND_MARKS: readonly MarkDef[] = (
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[] Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
).map((id) => ({ id, ...BRAND_TABLE[id] })); ).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 { export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0]; return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
} }
+98 -32
View File
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadLogoAsset } from '../logos/logo-asset';
import { DEFAULT_CONFIG, type CardConfig } from './config'; import { DEFAULT_CONFIG, type CardConfig } from './config';
import { fitLogo } from './geometry';
import { loadMarkImage } from './marks'; import { loadMarkImage } from './marks';
import { import {
drawCard, drawCard,
@@ -9,6 +11,7 @@ import {
type CardAssets, type CardAssets,
type CardImage, type CardImage,
type Ctx, type Ctx,
type LogoAsset,
} from './render'; } from './render';
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen. // marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
@@ -22,7 +25,6 @@ vi.mock('./marks', () => {
maxH, maxH,
}); });
return { return {
getNetworkMark: (id: string) => mark(id, '0 0 330 109', 109),
getBrandMark: (id: string) => mark(id, '0 0 100 100', 100), getBrandMark: (id: string) => mark(id, '0 0 100 100', 100),
svgAspect: (svg: string) => { svgAspect: (svg: string) => {
const p = /viewBox="([^"]+)"/.exec(svg)![1].split(' ').map(Number); 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 { interface Call {
name: string; name: string;
args: unknown[]; args: unknown[];
@@ -99,13 +104,15 @@ function fakeCtx() {
return { ctx: ctx as unknown as Ctx, calls, strokePath }; 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 BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
const PHOTO = { tag: 'photo' } 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 => ({ const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
image: null, image: null,
network: NETWORK, logo: LOGO,
brand: BRAND, brand: BRAND,
...over, ...over,
}); });
@@ -187,18 +194,42 @@ describe('renderCard: Grund und Bild', () => {
}); });
describe('renderCard: Vorlagen', () => { 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()); const calls = render(cfg({ template: 'minimal' }), assets());
expect(named(calls, 'drawImage')).toHaveLength(1); expect(named(calls, 'drawImage')).toHaveLength(1);
const [src, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[]; const [src, x, y, w, h] = drawsOf(calls, LOGO_SOURCE)[0].args as number[];
expect(src).toBe(NETWORK); expect(src).toBe(LOGO_SOURCE);
expect(x).toBeCloseTo(1150, 1); expect([x, y, w, h]).toEqual([1230, 788, 250, 125]);
expect(y).toBeCloseTo(804, 1);
expect(w).toBeCloseTo(330, 1);
expect(h).toBeCloseTo(109, 1);
expect(named(calls, 'fillText')).toHaveLength(0); 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', () => { 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 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); 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()); const calls = render(cfg({ template: 'classic' }), assets());
expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]); 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(drawsOf(calls, LOGO_SOURCE)[0].args).toEqual([LOGO_SOURCE, 1230, 788, 250, 125]);
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', () => { 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', () => { it('signature, Brand none: kein Brand-drawImage, PLATINUM beginnt bei x 60', () => {
const calls = render(cfg({ template: 'signature', brand: 'none' }), assets()); const calls = render(cfg({ template: 'signature', brand: 'none' }), assets());
expect(drawsOf(calls, BRAND)).toHaveLength(0); expect(drawsOf(calls, BRAND)).toHaveLength(0);
expect(drawsOf(calls, NETWORK)).toHaveLength(1); expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
const plat = letters(calls); const plat = letters(calls);
expect(plat[0].args).toEqual(['P', 60, 226]); expect(plat[0].args).toEqual(['P', 60, 226]);
expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"'); expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
@@ -273,7 +303,7 @@ describe('renderCard: Vorlagen', () => {
}); });
it('fehlende Logo-Bilder werden übersprungen', () => { 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(named(calls, 'drawImage')).toHaveLength(0);
expect(letters(calls)).toHaveLength(8); expect(letters(calls)).toHaveLength(8);
}); });
@@ -319,7 +349,7 @@ describe('renderCard: Chip', () => {
}); });
describe('renderCard: Reihenfolge', () => { 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 calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
const at = (pred: (c: Call) => boolean) => calls.findIndex(pred); const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
const order = [ const order = [
@@ -330,7 +360,7 @@ describe('renderCard: Reihenfolge', () => {
at((c) => c.name === 'arc'), at((c) => c.name === 'arc'),
at((c) => c.name === 'fillText' && c.args[0] === 'P'), 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] === 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.every((i) => i >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order); expect([...order].sort((a, b) => a - b)).toEqual(order);
@@ -386,14 +416,17 @@ describe('drawCard', () => {
}); });
describe('loadCardAssets', () => { describe('loadCardAssets', () => {
const NET_IMG = { tag: 'net-img' };
const BRAND_IMG = { tag: 'brand-img' }; const BRAND_IMG = { tag: 'brand-img' };
const fontsLoad = vi.fn(); const fontsLoad = vi.fn();
const mocked = vi.mocked(loadMarkImage); const mocked = vi.mocked(loadMarkImage);
const defaultLoader = vi.mocked(loadLogoAsset);
const loadLogo = vi.fn<(id: string, tone: 'light' | 'dark') => Promise<LogoAsset | null>>();
beforeEach(() => { beforeEach(() => {
fontsLoad.mockReset().mockResolvedValue([]); 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 } }); Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
}); });
@@ -401,35 +434,68 @@ describe('loadCardAssets', () => {
Reflect.deleteProperty(document, 'fonts'); 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 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.image).toBe(image);
expect(result.network).toBe(NET_IMG); expect(loadLogo).toHaveBeenCalledTimes(1);
expect(result.brand).toBe(BRAND_IMG); expect(loadLogo).toHaveBeenCalledWith('abc123', 'dark');
expect(mocked).toHaveBeenCalledTimes(2); expect(mocked).toHaveBeenCalledTimes(1);
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['jcb', 'dark'], ['apple', 'dark']]); expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['aircards-text', 'dark']]);
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"'); 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 () => { 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.image).toBeNull();
expect(result.brand).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 () => { it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
fontsLoad.mockRejectedValue(new Error('offline')); 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'); Reflect.deleteProperty(document, 'fonts');
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined }); 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 () => { it('Marken-Fehler wird weitergereicht', async () => {
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: jcb')); mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: aircards-symbol'));
await expect(loadCardAssets(cfg({ network: 'jcb' }), null)).rejects.toThrow(/Logo/); await expect(loadCardAssets(cfg(), null, loadLogo)).rejects.toThrow(/Logo/);
}); });
}); });
+36 -13
View File
@@ -1,18 +1,33 @@
// Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext. // 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. // 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 { TONE_COLOR, type CardConfig, type DetailsTone } from './config';
import { clampCrop, imageRect } from './crop'; import { clampCrop, imageRect } from './crop';
import { CARD_H, CARD_W, fitBrand, fitNetwork, layoutFor, type Layout } from './geometry'; import { CARD_H, CARD_W, fitBrand, fitLogo, layoutFor, type Layout } from './geometry';
import { getBrandMark, getNetworkMark, loadMarkImage, svgAspect } from './marks'; import { getBrandMark, loadMarkImage, svgAspect } from './marks';
export type CardImage = { source: CanvasImageSource; w: number; h: number }; 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 { export interface CardAssets {
image: CardImage | null; 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; 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). */ /** Der kleine Ausschnitt von CanvasRenderingContext2D, den der Renderer braucht (leicht zu faken). */
export type Ctx = Pick< export type Ctx = Pick<
CanvasRenderingContext2D, 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( export function renderCard(
ctx: Ctx, ctx: Ctx,
config: CardConfig, config: CardConfig,
@@ -150,10 +165,10 @@ export function renderCard(
ctx.drawImage(assets.brand, b.x, b.y, b.w, b.h); ctx.drawImage(assets.brand, b.x, b.y, b.w, b.h);
} }
if (layout.network && assets.network) { // Gezeichnet wird, was die Bilder mitbringen (sie gehören zu der Konfiguration, mit der sie geladen wurden).
const mark = getNetworkMark(config.network); if (layout.logo && assets.logo) {
const n = fitNetwork(svgAspect(mark.svg), mark.maxH); const l = fitLogo(assets.logo.w / assets.logo.h);
ctx.drawImage(assets.network, n.x, n.y, n.w, n.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); 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. // Ohne geladene Schrift misst und zeichnet der Canvas mit der Ersatzschrift.
const fontReady = Promise.resolve(typeof document !== 'undefined' ? document.fonts?.load(FONT_LOAD) : undefined) const fontReady = Promise.resolve(typeof document !== 'undefined' ? document.fonts?.load(FONT_LOAD) : undefined)
.catch(() => undefined); .catch(() => undefined);
const [network, brand] = await Promise.all([ const [logo, brand] = await Promise.all([
loadMarkImage(getNetworkMark(config.network), config.details), config.logo ? loadLogo(config.logo, config.details) : null,
config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details), config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details),
fontReady, 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. */ /** Rendert die Karte offscreen in der gewünschten Breite (Höhe nach 1536 : 969) und liefert die Bilddatei. */
+21 -1
View File
@@ -6,10 +6,11 @@ vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: ()
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { ApiError } from './api'; import { ApiError } from './api';
import { NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import { import {
CONSENT_TEXT, CONSENT_TEXT,
publishDialog, publishDialog,
publishFailureText,
validatePublishForm, validatePublishForm,
type PublishFormValues, type PublishFormValues,
type PublishOutcome, type PublishOutcome,
@@ -382,6 +383,25 @@ describe('Veröffentlichen-Dialog: Senden', () => {
}); });
}); });
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
void open();
fill();
send();
await settle();
expect(errorText()).toBe(
'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
expect(errorText()).toContain(LOGO_MISSING_PUBLISH);
expect(errorText()).not.toContain('schiefgelaufen');
});
it('publishFailureText reicht ihn ebenso durch', () => {
expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`);
});
});
describe('Veröffentlichen-Dialog: Schließen', () => { describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => { it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open(); const outcome = open();
+2 -2
View File
@@ -7,7 +7,7 @@ import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload'; import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { ApiError, isAlreadyPublished } from './api'; import { ApiError, isAlreadyPublished } from './api';
import { NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */ /** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.'; export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
@@ -67,7 +67,7 @@ export function validatePublishForm(values: PublishFormValues): { field: Publish
} }
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */ /** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set([TOO_LARGE, NOT_RENDERED, READ_MESSAGE, PIXELS_MESSAGE]); const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */ /** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
export function publishFailureText(err: unknown): string { export function publishFailureText(err: unknown): string {
+93 -2
View File
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config'; import { DEFAULT_CONFIG } from '../card/config';
import type { SavedCard } from '../account/cards'; import type { SavedCard } from '../account/cards';
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() })); const mocks = vi.hoisted(() => ({
createJWT: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
}));
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz; // Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht. // nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
@@ -14,9 +19,22 @@ vi.mock('../account/appwrite', () => ({
storage: {}, storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1', APPWRITE_ENDPOINT: 'https://aircard.test/v1',
})); }));
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
vi.mock('../editor/upload', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/upload')>()),
decodeImage: mocks.decodeImage,
}));
import { ApiError, publishCard } from './api'; import { ApiError, publishCard } from './api';
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish'; import {
LOGO_MISSING_PUBLISH,
PNG_MAX_BYTES,
normalizeTags,
publishSavedCard,
renderForGallery,
type PublishDeps,
} from './publish';
const card: SavedCard = { const card: SavedCard = {
id: 'card1', id: 'card1',
@@ -242,6 +260,79 @@ describe('publishSavedCard', () => {
}); });
}); });
describe('Logo der Karte beim Veröffentlichen', () => {
const photo = { source: {}, w: 3000, h: 2000 };
const source = new Blob(['src'], { type: 'image/jpeg' });
const withLogo = (logo: string | null): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, logo } });
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
beforeEach(() => {
mocks.decodeImage.mockReset().mockResolvedValue(photo);
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
mocks.exportCard
.mockReset()
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
});
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
const { t, setLang } = await import('../i18n/i18n');
expect(LOGO_MISSING_PUBLISH).toBe(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
try {
setLang('en', { persist: false });
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
} finally {
setLang('de', { persist: false });
}
});
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
const out = await renderForGallery(withLogo('abc123'), source);
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
});
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
const out = await renderForGallery(withLogo(null), source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
expect(out.png.type).toBe('image/png');
});
it('fehlendes Logo (gelöscht oder ausgeblendet): Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
await expect(renderForGallery(withLogo('gone'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.exportCard).not.toHaveBeenCalled();
});
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
const failure = new TypeError('Failed to fetch');
mocks.loadCardAssets.mockRejectedValue(failure);
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
});
it('publishSavedCard: mit fehlendem Logo wird nichts hochgeladen, nichts gelöscht und die API nicht gerufen', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(fake.names()).toEqual(['userId', 'loadImage']);
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
});
});
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => { describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
const json = (status: number, body: unknown) => const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+8 -1
View File
@@ -8,12 +8,17 @@ import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { clampCrop } from '../card/crop'; import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets } from '../card/render'; import { exportCard, loadCardAssets } from '../card/render';
import { decodeImage } from '../editor/upload'; import { decodeImage } from '../editor/upload';
import { msg } from '../i18n/i18n';
import { isDefinitiveFailure, publishCard, type PublishInput } from './api'; import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */ /** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880; export const PNG_MAX_BYTES = 5_242_880;
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).'; export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: veröffentlicht wird sie nicht ohne dieses Logo. */
export const LOGO_MISSING_PUBLISH = msg(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
const GALLERY_TYPES = ['image/png', 'image/webp']; const GALLERY_TYPES = ['image/png', 'image/webp'];
export interface PublishForm { export interface PublishForm {
@@ -47,10 +52,12 @@ export function normalizeTags(input: string): string[] {
return tags; return tags;
} }
async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> { export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
const image = await decodeImage(imageBlob); const image = await decodeImage(imageBlob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
const assets = await loadCardAssets(config, image); const assets = await loadCardAssets(config, image);
// Ohne das gewählte Logo würde eine andere Karte veröffentlicht, als der Nutzer sie gestaltet hat: lieber abbrechen.
if (assets.logoMissing) throw new Error(LOGO_MISSING_PUBLISH);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY }); const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
return { png, preview }; return { png, preview };
+227 -6
View File
@@ -4,6 +4,7 @@ import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards'; import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config'; import { DEFAULT_CONFIG } from '../card/config';
import type { CardImage } from '../card/render'; import type { CardImage } from '../card/render';
import { PNG_SIZE_WARNING } from './download';
import { createStore, initialEditorState, type EditorStore } from './state'; import { createStore, initialEditorState, type EditorStore } from './state';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -15,6 +16,8 @@ const mocks = vi.hoisted(() => ({
decodeImage: vi.fn(), decodeImage: vi.fn(),
loadCardAssets: vi.fn(), loadCardAssets: vi.fn(),
exportCard: vi.fn(), exportCard: vi.fn(),
drawCard: vi.fn(),
saveBlobAs: vi.fn(),
getGalleryCard: vi.fn(), getGalleryCard: vi.fn(),
loadGalleryImage: vi.fn(), loadGalleryImage: vi.fn(),
logoutListeners: new Set<() => void>(), logoutListeners: new Set<() => void>(),
@@ -35,10 +38,15 @@ vi.mock('../account/cards', () => ({
cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }), cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }),
})); }));
vi.mock('../card/render', () => ({ vi.mock('../card/render', () => ({
drawCard: vi.fn(), drawCard: mocks.drawCard,
loadCardAssets: mocks.loadCardAssets, loadCardAssets: mocks.loadCardAssets,
exportCard: mocks.exportCard, exportCard: mocks.exportCard,
})); }));
// Der Browser-Download selbst (Object-URL, Anker) gehört nicht in diese Tests.
vi.mock('./download', async (importOriginal) => ({
...(await importOriginal<typeof import('./download')>()),
saveBlobAs: mocks.saveBlobAs,
}));
vi.mock('../community/gallery', () => ({ vi.mock('../community/gallery', () => ({
getGalleryCard: mocks.getGalleryCard, getGalleryCard: mocks.getGalleryCard,
loadGalleryImage: mocks.loadGalleryImage, loadGalleryImage: mocks.loadGalleryImage,
@@ -79,7 +87,7 @@ beforeEach(() => {
store = createStore(initialEditorState()); store = createStore(initialEditorState());
mocks.getCurrentUser.mockResolvedValue(user); mocks.getCurrentUser.mockResolvedValue(user);
mocks.requireLogin.mockResolvedValue(true); mocks.requireLogin.mockResolvedValue(true);
mocks.loadCardAssets.mockResolvedValue({ image: null, network: {}, brand: {} }); mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })); mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' }));
unmount = null; unmount = null;
}); });
@@ -110,7 +118,7 @@ function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage }
describe('Speichern', () => { describe('Speichern', () => {
it('speichert eine neue Karte mit Schnappschuss, Vorschau und Nutzer-Prüfung', async () => { it('speichert eine neue Karte mit Schnappschuss, Vorschau und Nutzer-Prüfung', async () => {
const { blob } = withImage(); const { blob } = withImage();
store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, network: 'jcb' } }); store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, logo: 'abc123' } });
const result = savedCard({ id: 'neu', name: 'Mein Urlaub' }); const result = savedCard({ id: 'neu', name: 'Mein Urlaub' });
mocks.save.mockResolvedValue(result); mocks.save.mockResolvedValue(result);
mount(); mount();
@@ -122,14 +130,14 @@ describe('Speichern', () => {
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.getCurrentUser).toHaveBeenCalled(); expect(mocks.getCurrentUser).toHaveBeenCalled();
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ network: 'jcb' }), expect.anything(), { expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), expect.anything(), {
width: 480, width: 480,
type: 'image/webp', type: 'image/webp',
quality: 0.85, quality: 0.85,
}); });
const input = mocks.save.mock.calls[0][0]; const input = mocks.save.mock.calls[0][0];
expect(input).toMatchObject({ name: 'Mein Urlaub', image: blob, imageChanged: true, previous: null }); expect(input).toMatchObject({ name: 'Mein Urlaub', image: blob, imageChanged: true, previous: null });
expect(input.config.network).toBe('jcb'); expect(input.config.logo).toBe('abc123');
expect(input.preview.type).toBe('image/webp'); expect(input.preview.type).toBe('image/webp');
const s = store.get(); const s = store.get();
@@ -982,6 +990,219 @@ describe('Speichern: Schnappschuss beim Klick', () => {
}); });
describe('Community-Logo auf der Karte', () => {
const LOGO_GONE = 'Das gewählte Logo ist nicht mehr verfügbar.';
const MISSING = { image: null, logo: null, brand: {}, logoMissing: true };
const FOUND = { image: null, logo: { source: {}, w: 200, h: 100 }, brand: {}, logoMissing: false };
const withLogo = (logo: string | null, patch: Record<string, unknown> = {}) => ({ ...store.get().config, logo, ...patch });
/** Das Logo „gone“ gibt es nicht mehr, alle anderen schon. */
const goneLoader = async (config: { logo: string | null }) => (config.logo === 'gone' ? MISSING : FOUND);
const downloadButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Herunterladen') as HTMLButtonElement;
beforeEach(() => {
// Hier wird wirklich gezeichnet: requestAnimationFrame läuft (asynchron) ab.
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0) as unknown as number);
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
});
it('die Bilder werden mit dem gewählten Logo geladen (loadCardAssets bekommt die Konfiguration samt logo)', async () => {
store.set({ config: withLogo('abc123', { details: 'dark' }) });
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalled());
const [config, image] = mocks.loadCardAssets.mock.calls[0];
expect(config).toMatchObject({ logo: 'abc123', details: 'dark' });
expect(image).toBeNull();
expect(warningText()).toBe('');
expect(errorText()).toBe('');
});
it('Logo fehlt: Warnung „Das gewählte Logo ist nicht mehr verfügbar.“, kein Fehler, die Karte wird trotzdem gezeichnet', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
withImage();
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
expect(errorText()).toBe('');
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
// Vorschau (Canvas „card-canvas“) und die vier Vorlagen-Miniaturen: alle ohne Logo, mit unveränderter Auswahl
const calls = mocks.drawCard.mock.calls;
expect(calls.some(([canvas]) => (canvas as HTMLElement).classList.contains('card-canvas'))).toBe(true);
expect(new Set(calls.map(([canvas]) => canvas)).size).toBe(5);
for (const [, config, assets] of calls) {
expect(config.logo).toBe('gone');
expect(assets.logo).toBeNull();
}
// die Auswahl bleibt im Zustand
expect(store.get().config.logo).toBe('gone');
});
it('die Warnung erscheint auch ohne Bild (die Miniaturen zeigen die Karte)', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
// ohne Bild keine Vorschau, aber die vier Vorlagen-Miniaturen
await vi.waitFor(() => expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(4));
expect(mocks.drawCard.mock.calls.some(([canvas]) => (canvas as HTMLElement).classList.contains('card-canvas'))).toBe(false);
});
it('Wechsel auf ein vorhandenes Logo: die Warnung verschwindet', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ config: withLogo('da') });
await vi.waitFor(() => expect(warningText()).toBe(''));
expect(errorText()).toBe('');
// und zurück auf das fehlende Logo: der Hinweis kommt wieder
store.set({ config: withLogo('gone') });
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
});
it('Wechsel auf „Kein Logo“: die Warnung verschwindet', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ config: withLogo(null) });
await vi.waitFor(() => expect(warningText()).toBe(''));
});
it('Hell/Dunkel bei fehlendem Logo: der Hinweis bleibt stehen', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ config: withLogo('gone', { details: 'dark' }) });
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
await new Promise((r) => setTimeout(r, 0));
expect(warningText()).toBe(LOGO_GONE);
});
it('ein fehlendes Logo wird nicht immer wieder geladen (kein Fehler-Loop)', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
withImage();
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ cardName: 'Andere Karte' });
store.set({ config: withLogo('gone', { template: 'classic' }) });
store.set({ config: withLogo('gone', { template: 'classic', crop: { x: 10, y: 5, zoom: 2 } }) });
await new Promise((r) => setTimeout(r, 10));
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1);
expect(warningText()).toBe(LOGO_GONE);
});
it('kein „Die Logos konnten nicht geladen werden.“ bei fehlendem Logo, nur bei einem echten Ladefehler', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
expect(errorText()).toBe('');
unmount?.();
mocks.loadCardAssets.mockRejectedValue(new Error('offline'));
store.set({ config: withLogo('anderes') });
mount();
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
expect(warningText()).toBe('');
// kein Wiederholungs-Loop: derselbe Schlüssel wird nicht noch einmal versucht
const attempts = mocks.loadCardAssets.mock.calls.length;
store.set({ cardName: 'x' });
await new Promise((r) => setTimeout(r, 10));
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(attempts);
});
it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } }));
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mocks.loadCardAssets.mockImplementation(goneLoader);
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
expect(errorText()).toBe('');
expect(store.get().config.logo).toBe('gone');
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
downloadButton().click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'gone' }), expect.objectContaining({ logo: null }), {
width: 1536,
type: 'image/png',
});
expect(errorText()).toBe('');
// Der Hinweis bleibt: das Logo fehlt weiterhin.
expect(warningText()).toBe(LOGO_GONE);
});
it('Download: scheitert das Laden der Logos (Netz), steht „Die Logos konnten nicht geladen werden.“ statt der rohen Fehlermeldung', async () => {
withImage();
store.set({ config: withLogo('da') });
mocks.loadCardAssets.mockResolvedValueOnce(FOUND).mockRejectedValue(new TypeError('Failed to fetch'));
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
downloadButton().click();
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
expect(mocks.exportCard).not.toHaveBeenCalled();
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
expect(downloadButton().disabled).toBe(false);
});
it('der Logo-Hinweis und andere Hinweise stehen zusammen und löschen sich nicht gegenseitig', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gone' } }));
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mocks.loadCardAssets.mockImplementation(goneLoader);
mount();
// Bilddatei fehlt UND das Logo fehlt: beide Hinweise
await vi.waitFor(() => expect(warningText()).toBe(`Das Bild dieser Karte fehlt. ${LOGO_GONE}`));
// Ein anderes Logo: nur der Logo-Hinweis geht, der Bild-Hinweis bleibt.
store.set({ config: withLogo('da') });
await vi.waitFor(() => expect(warningText()).toBe('Das Bild dieser Karte fehlt.'));
});
it('der Größenhinweis des Downloads bleibt, wenn der Logo-Hinweis verschwindet (und umgekehrt)', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' }));
withImage();
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
downloadButton().click();
await vi.waitFor(() => expect(warningText()).toBe(`${PNG_SIZE_WARNING} ${LOGO_GONE}`));
store.set({ config: withLogo('da') });
await vi.waitFor(() => expect(warningText()).toBe(PNG_SIZE_WARNING));
});
it('Neue Karte: der Hinweis zum Logo der alten Karte verschwindet', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({
config: withLogo('gone'),
saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
savedConfig: withLogo('gone'),
cardName: 'Urlaub',
});
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
root.querySelector<HTMLButtonElement>('.bound-row button')!.click();
await vi.waitFor(() => expect(store.get().saved).toBeNull());
await vi.waitFor(() => expect(warningText()).toBe(''));
});
it('Englisch: der Hinweis erscheint übersetzt', async () => {
const { setLang } = await import('../i18n/i18n');
setLang('en', { persist: false });
mocks.loadCardAssets.mockResolvedValue(MISSING);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe('The selected logo is no longer available.'));
});
});
describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => { describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`); const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`);
const galleryCard = (patch: Record<string, unknown> = {}) => ({ const galleryCard = (patch: Record<string, unknown> = {}) => ({
@@ -1029,7 +1250,7 @@ describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
it('eine zuvor geöffnete gespeicherte Karte wird entkoppelt: Speichern legt eine neue Karte an', async () => { it('eine zuvor geöffnete gespeicherte Karte wird entkoppelt: Speichern legt eine neue Karte an', async () => {
openTemplate('g1'); openTemplate('g1');
const { blob } = available(); const { blob } = available();
const config = { ...store.get().config, template: 'classic' as const, network: 'jcb' as const }; const config = { ...store.get().config, template: 'classic' as const, logo: 'abc123' };
store.set({ store.set({
image: image(), image: image(),
imageBlob: png(), imageBlob: png(),
+38 -17
View File
@@ -5,11 +5,13 @@ import { getCurrentUser, onLogout } from '../account/auth';
import { errorMessage, errorType } from '../account/errors'; import { errorMessage, errorType } from '../account/errors';
import { requireLogin } from '../account/login-dialog'; import { requireLogin } from '../account/login-dialog';
import { LoginCancelled, withLogin } from '../account/with-login'; import { LoginCancelled, withLogin } from '../account/with-login';
import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type NetworkId, type TemplateId } from '../card/config'; import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type TemplateId } from '../card/config';
import { clampCrop, panBy, zoomTo } from '../card/crop'; import { clampCrop, panBy, zoomTo } from '../card/crop';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
import { getGalleryCard, loadGalleryImage } from '../community/gallery'; import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { t } from '../i18n/i18n';
import { LOGO_MISSING } from '../logos/logo-asset';
import { dropCardParam, dropTemplateParam } from './card-url'; import { dropCardParam, dropTemplateParam } from './card-url';
import { confirmDialog } from './confirm-dialog'; import { confirmDialog } from './confirm-dialog';
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download'; import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
@@ -30,10 +32,11 @@ const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.';
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.'; const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.';
const SAVED_FLASH_MS = 2000; const SAVED_FLASH_MS = 2000;
type Logos = Pick<CardAssets, 'network' | 'brand'>; /** Die geladenen Logo-Bilder; `missing`: die Karte wählt ein Community-Logo, das es nicht (mehr) gibt. */
type Logos = Pick<CardAssets, 'logo' | 'brand'> & { missing: boolean };
/** Alles, was Logo-Bilder bestimmt: wechselt der Schlüssel, müssen sie neu geladen werden. */ /** Alles, was Logo-Bilder bestimmt: wechselt der Schlüssel, müssen sie neu geladen werden. */
const logoKey = (c: CardConfig) => `${c.network}|${c.details}|${c.brand}`; const logoKey = (c: CardConfig) => `${c.logo ?? ''}|${c.details}|${c.brand}`;
/** Der Teil einer gespeicherten Karte, den der Editor-Zustand festhält (ohne Konfiguration). */ /** Der Teil einer gespeicherten Karte, den der Editor-Zustand festhält (ohne Konfiguration). */
function toRef(card: SavedCardRef): SavedCardRef { function toRef(card: SavedCardRef): SavedCardRef {
@@ -65,7 +68,6 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const templates = createTemplatesPanel((template: TemplateId) => patchConfig({ template })); const templates = createTemplatesPanel((template: TemplateId) => patchConfig({ template }));
const options = createOptionsPanel({ const options = createOptionsPanel({
onNetwork: (network: NetworkId) => patchConfig({ network }),
onDetails: (details: DetailsTone) => patchConfig({ details }), onDetails: (details: DetailsTone) => patchConfig({ details }),
onBrand: (brand: BrandId) => patchConfig({ brand }), onBrand: (brand: BrandId) => patchConfig({ brand }),
}); });
@@ -100,6 +102,19 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onNewCard: () => void startNewCard(), onNewCard: () => void startNewCard(),
}); });
// Der Hinweis unter der Vorschau: ein allgemeiner (Bild fehlt, PNG zu groß …) und der zum fehlenden Community-Logo (er gilt,
// solange die geladenen Logos `missing` sind, siehe ensureLogos). Beide teilen sich das Feld, werden aber getrennt gehalten,
// damit das Ende des einen den anderen nicht löscht.
let generalWarning: string | null = null;
function showWarning(): void {
const parts = [generalWarning, logos?.missing ? t(LOGO_MISSING) : null].filter((part): part is string => !!part);
preview.setWarning(parts.length > 0 ? parts.join(' ') : null);
}
function setWarning(message: string | null): void {
generalWarning = message;
showWarning();
}
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten'); const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten');
const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el); const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el);
root.replaceChildren(heading, page); root.replaceChildren(heading, page);
@@ -136,12 +151,15 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
exporting = true; exporting = true;
sync(store.get()); sync(store.get());
preview.setError(null); preview.setError(null);
preview.setWarning(null); setWarning(null);
try { try {
const assets = await loadCardAssets(s.config, s.image); // Die Logos kommen aus dem Netz: scheitert das, soll der Nutzer „Logos konnten nicht geladen werden“ lesen, nicht „Failed to fetch“.
const assets = await loadCardAssets(s.config, s.image).catch(() => {
throw new Error(LOGO_ERROR);
});
const blob = await exportCard(s.config, assets, { width: CARD_W, type: 'image/png' }); const blob = await exportCard(s.config, assets, { width: CARD_W, type: 'image/png' });
saveBlobAs(blob, downloadFileName(s.cardName, new Date())); saveBlobAs(blob, downloadFileName(s.cardName, new Date()));
preview.setWarning(pngSizeWarning(blob.size)); setWarning(pngSizeWarning(blob.size));
} catch (error) { } catch (error) {
preview.setError(error instanceof Error && error.message ? error.message : EXPORT_ERROR); preview.setError(error instanceof Error && error.message ? error.message : EXPORT_ERROR);
} finally { } finally {
@@ -217,7 +235,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const stale = () => disposed || token !== loadToken; const stale = () => disposed || token !== loadToken;
const service = cardsService(); const service = cardsService();
preview.setError(null); preview.setError(null);
preview.setWarning(null); setWarning(null);
preview.setNotice(LOADING_CARD); preview.setNotice(LOADING_CARD);
try { try {
// Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden. // Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden.
@@ -262,11 +280,11 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
savedConfig: config, savedConfig: config,
cardName: card.name, cardName: card.name,
}); });
preview.setWarning(missingImage); setWarning(missingImage);
} catch (err) { } catch (err) {
if (stale()) return; if (stale()) return;
if (err instanceof LoginCancelled) { if (err instanceof LoginCancelled) {
preview.setWarning(LOGIN_TO_OPEN); setWarning(LOGIN_TO_OPEN);
} else if (errorType(err) === 'row_not_found') { } else if (errorType(err) === 'row_not_found') {
// Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie. // Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie.
const s = store.get(); const s = store.get();
@@ -287,7 +305,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const token = ++loadToken; const token = ++loadToken;
const stale = () => disposed || token !== loadToken; const stale = () => disposed || token !== loadToken;
preview.setError(null); preview.setError(null);
preview.setWarning(null); setWarning(null);
preview.setNotice(LOADING_TEMPLATE); preview.setNotice(LOADING_TEMPLATE);
try { try {
// Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404). // Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404).
@@ -363,12 +381,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
cancelCardLoad(); // eine noch ladende Karte soll nicht in den leeren Editor springen cancelCardLoad(); // eine noch ladende Karte soll nicht in den leeren Editor springen
fileToken += 1; // … und ein noch dekodiertes Bild auch nicht fileToken += 1; // … und ein noch dekodiertes Bild auch nicht
preview.setError(null); preview.setError(null);
preview.setWarning(null); setWarning(null);
store.set(initialEditorState()); store.set(initialEditorState());
dropCardParam(); dropCardParam();
} }
// Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert). // Logos und Schrift: werden nur bei Wechsel von Community-Logo, Details oder Marke geladen (Logos sind zwischengespeichert).
// Fehlt das gewählte Community-Logo, wird die Karte ohne es gezeichnet und der Hinweis gezeigt, solange das so bleibt; ein echter
// Ladefehler (Netz, Server) zeigt dagegen LOGO_ERROR, und derselbe Schlüssel wird nicht noch einmal versucht.
let logos: (Logos & { key: string }) | null = null; let logos: (Logos & { key: string }) | null = null;
let pendingKey = ''; let pendingKey = '';
let failedKey = ''; let failedKey = '';
@@ -382,7 +402,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
(assets) => { (assets) => {
if (disposed || token !== logoToken) return; if (disposed || token !== logoToken) return;
pendingKey = ''; pendingKey = '';
logos = { network: assets.network, brand: assets.brand, key }; logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key };
showWarning();
schedule(); schedule();
}, },
() => { () => {
@@ -406,9 +427,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
function draw(): void { function draw(): void {
const s = store.get(); const s = store.get();
// Mit Logos eines anderen Netzwerks würde das Seitenverhältnis nicht passen; lieber auf die neuen warten. // Mit den Logos einer anderen Auswahl würde das Seitenverhältnis nicht passen; lieber auf die neuen warten.
if (!logos || logos.key !== logoKey(s.config)) return; if (!logos || logos.key !== logoKey(s.config)) return;
const assets: CardAssets = { image: s.image, network: logos.network, brand: logos.brand }; const assets: CardAssets = { image: s.image, logo: logos.logo, brand: logos.brand };
try { try {
if (s.image) drawCard(preview.canvas, s.config, assets); if (s.image) drawCard(preview.canvas, s.config, assets);
templates.draw(s.config, assets); templates.draw(s.config, assets);
@@ -428,7 +449,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
// Der Größenhinweis gehört zum letzten Download; ein anderes Bild macht ihn hinfällig. // Der Größenhinweis gehört zum letzten Download; ein anderes Bild macht ihn hinfällig.
if (s.image !== lastImage) { if (s.image !== lastImage) {
lastImage = s.image; lastImage = s.image;
preview.setWarning(null); setWarning(null);
} }
sync(s); sync(s);
ensureLogos(s.config); ensureLogos(s.config);
+5 -4
View File
@@ -73,11 +73,12 @@
line-height: 1.2; line-height: 1.2;
} }
.panel-title + .panel-subtitle { .panel-title + .panel-subtitle,
.editor-logo-slot:empty + .panel-subtitle {
margin-top: 0; margin-top: 0;
} }
/* Kacheln (Vorlagen, Netzwerke, Logos) */ /* Kacheln (Vorlagen, Logos) */
.tile { .tile {
appearance: none; appearance: none;
@@ -154,7 +155,7 @@
display: block; display: block;
} }
/* Kartennetzwerk und Logo */ /* Logo oben links */
.mark-grid { .mark-grid {
display: grid; display: grid;
@@ -503,7 +504,7 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
/* Ab 1024 px: schmale Optionsspalte → zwei statt drei Netzwerk-Kacheln pro Reihe, damit Beschriftungen nicht mitten im Wort brechen */ /* Ab 1024 px: schmale Optionsspalte → zwei statt drei Logo-Kacheln pro Reihe, damit Beschriftungen nicht mitten im Wort brechen */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.mark-grid { .mark-grid {
+101
View File
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG, type CardConfig } from '../card/config';
import { setLang } from '../i18n/i18n';
import { createOptionsPanel, type OptionsPanel } from './options-panel';
const handlers = { onDetails: vi.fn(), onBrand: vi.fn() };
let panel: OptionsPanel;
const cfg = (patch: Partial<CardConfig> = {}): CardConfig => ({ ...DEFAULT_CONFIG, crop: { ...DEFAULT_CONFIG.crop }, ...patch });
const headings = () => [...panel.el.querySelectorAll('h2, h3')].map((h) => h.textContent);
const tiles = () => [...panel.el.querySelectorAll<HTMLButtonElement>('.mark-tile')];
const tile = (label: string) => tiles().find((b) => b.getAttribute('aria-label') === label)!;
beforeEach(() => {
handlers.onDetails.mockReset();
handlers.onBrand.mockReset();
panel = createOptionsPanel(handlers);
});
describe('Optionen: Logo oben links', () => {
it('keine Netzwerk-Kacheln mehr: nur drei Kacheln (Symbol, Text, Kein Logo), keine Überschrift „Kartennetzwerk“', () => {
expect(headings()).not.toContain('Kartennetzwerk');
expect(tiles().map((b) => b.getAttribute('aria-label'))).toEqual([
'Logo – AirCard-IOS · Symbol',
'Logo – AirCard-IOS · Text',
'Kein Logo',
]);
});
it('kein Apple und keine fremden Marken in den Kacheln', () => {
const text = panel.el.textContent! + tiles().map((b) => b.getAttribute('title')).join(' ');
expect(text).not.toMatch(/apple|visa|mastercard|unionpay|american express|jcb|discover/i);
});
it('die Überschrift lautet „Logo oben links“ und beschriftet die Kachelgruppe', () => {
const heading = [...panel.el.querySelectorAll('h3')].find((h) => h.textContent === 'Logo oben links')!;
expect(heading).toBeDefined();
const group = panel.el.querySelector(`[aria-labelledby="${heading.id}"]`)!;
expect(group.getAttribute('role')).toBe('group');
expect([...group.querySelectorAll('.mark-tile')]).toEqual(tiles());
});
it('Reihenfolge der Überschriften: Individuell gestalten, Details, Logo oben links', () => {
expect(headings()).toEqual(['Individuell gestalten', 'Details', 'Logo oben links']);
});
it('die Überschrift „Logo oben links“ wird übersetzt', () => {
setLang('en', { persist: false });
panel = createOptionsPanel(handlers);
expect(headings()).toContain('Top-left logo');
expect(headings()).not.toContain('Logo oben links');
});
it('Klick auf eine Kachel meldet die Marke (oder none)', () => {
tile('Logo – AirCard-IOS · Symbol').click();
tile('Logo – AirCard-IOS · Text').click();
tile('Kein Logo').click();
expect(handlers.onBrand.mock.calls).toEqual([['aircards-symbol'], ['aircards-text'], ['none']]);
});
it('select markiert die gewählte Marke mit aria-pressed', () => {
const pressed = () => tiles().filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.getAttribute('aria-label'));
panel.select(cfg({ brand: 'aircards-text' }));
expect(pressed()).toEqual(['Logo – AirCard-IOS · Text']);
panel.select(cfg({ brand: 'none' }));
expect(pressed()).toEqual(['Kein Logo']);
panel.select(cfg({ brand: 'aircards-symbol' }));
expect(pressed()).toEqual(['Logo – AirCard-IOS · Symbol']);
});
});
describe('Optionen: Details und Platz für das Community-Logo', () => {
it('Details Hell/Dunkel: Auswahl und Meldung', () => {
const inputs = [...panel.el.querySelectorAll<HTMLInputElement>('input[name="card-details"]')];
expect(inputs.map((i) => i.value)).toEqual(['light', 'dark']);
panel.select(cfg({ details: 'dark' }));
expect(inputs.map((i) => i.checked)).toEqual([false, true]);
inputs[0].checked = true;
inputs[0].dispatchEvent(new Event('change'));
expect(handlers.onDetails).toHaveBeenCalledWith('light');
});
it('logoSlot: ein leerer Platz ganz oben, direkt unter „Individuell gestalten“, vor den Details', () => {
expect(panel.logoSlot).toBeInstanceOf(HTMLElement);
expect(panel.logoSlot.childElementCount).toBe(0);
expect(panel.el.contains(panel.logoSlot)).toBe(true);
const children = [...panel.el.children];
const slot = children.indexOf(panel.logoSlot);
const details = children.findIndex((c) => c.textContent === 'Details');
expect(children[0].textContent).toBe('Individuell gestalten');
expect(slot).toBe(1);
expect(slot).toBeLessThan(details);
});
it('der Platz bleibt beim Auswählen unberührt (Task 8 füllt ihn)', () => {
const before = panel.logoSlot;
panel.select(cfg({ logo: 'abc123' }));
expect(panel.logoSlot).toBe(before);
expect(panel.logoSlot.childElementCount).toBe(0);
});
});
+21 -22
View File
@@ -1,10 +1,11 @@
// Rechte Spalte „Individuell gestalten“: Kartennetzwerk, Details (Hell/Dunkel) und Logo. // Rechte Spalte „Individuell gestalten“: oben der Platz für das Community-Logo (unten rechts auf der Karte),
import type { BrandId, CardConfig, DetailsTone, NetworkId } from '../card/config'; // dann Details (Hell/Dunkel) und das Logo oben links.
import { BRAND_MARKS, NETWORK_MARKS, tintSvg, type MarkDef } from '../card/marks'; import type { BrandId, CardConfig, DetailsTone } from '../card/config';
import { BRAND_MARKS, tintSvg, type MarkDef } from '../card/marks';
import { t } from '../i18n/i18n';
import { el, icon } from './dom'; import { el, icon } from './dom';
export interface OptionsHandlers { export interface OptionsHandlers {
onNetwork(id: NetworkId): void;
onDetails(tone: DetailsTone): void; onDetails(tone: DetailsTone): void;
onBrand(id: BrandId): void; onBrand(id: BrandId): void;
} }
@@ -12,6 +13,8 @@ export interface OptionsHandlers {
export interface OptionsPanel { export interface OptionsPanel {
el: HTMLElement; el: HTMLElement;
select(config: CardConfig): void; select(config: CardConfig): void;
/** Leerer Platz oben in der Spalte, für den Bereich „Logo unten rechts“ (der Logo-Auswahl). */
logoSlot: HTMLElement;
} }
const TONES: readonly { id: DetailsTone; label: string }[] = [ const TONES: readonly { id: DetailsTone; label: string }[] = [
@@ -41,11 +44,6 @@ function heading(id: string, text: string, level: 'h2' | 'h3') {
} }
export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel { export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
const networkTiles = NETWORK_MARKS.map((def) => ({
id: def.id as NetworkId,
button: markTile(def, () => handlers.onNetwork(def.id as NetworkId)),
}));
const brandTiles: { id: BrandId; button: HTMLButtonElement }[] = BRAND_MARKS.map((def) => ({ const brandTiles: { id: BrandId; button: HTMLButtonElement }[] = BRAND_MARKS.map((def) => ({
id: def.id as BrandId, id: def.id as BrandId,
button: markTile(def, () => handlers.onBrand(def.id as BrandId)), button: markTile(def, () => handlers.onBrand(def.id as BrandId)),
@@ -59,38 +57,39 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
none.addEventListener('click', () => handlers.onBrand('none')); none.addEventListener('click', () => handlers.onBrand('none'));
brandTiles.push({ id: 'none', button: none }); brandTiles.push({ id: 'none', button: none });
const toneInputs = TONES.map((t) => { const toneInputs = TONES.map((tone) => {
const input = el('input', { type: 'radio', name: 'card-details', value: t.id, class: 'pill-input' }); const input = el('input', { type: 'radio', name: 'card-details', value: tone.id, class: 'pill-input' });
input.addEventListener('change', () => { input.addEventListener('change', () => {
if (input.checked) handlers.onDetails(t.id); if (input.checked) handlers.onDetails(tone.id);
}); });
const label = el( const label = el(
'label', 'label',
{ class: 'pill' }, { class: 'pill' },
input, input,
el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${t.id}` }), t.label), el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${tone.id}` }), tone.label),
); );
return { id: t.id, input, label }; return { id: tone.id, input, label };
}); });
const logoSlot = el('div', { class: 'editor-logo-slot' });
const section = el( const section = el(
'section', 'section',
{ class: 'editor-side editor-options' }, { class: 'editor-side editor-options' },
heading('editor-options-heading', 'Individuell gestalten', 'h2'), heading('editor-options-heading', 'Individuell gestalten', 'h2'),
heading('editor-network-heading', 'Kartennetzwerk', 'h3'), logoSlot,
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-network-heading' }, ...networkTiles.map((t) => t.button)),
heading('editor-details-heading', 'Details', 'h3'), heading('editor-details-heading', 'Details', 'h3'),
el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((t) => t.label)), el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((input) => input.label)),
heading('editor-brand-heading', 'Logo', 'h3'), heading('editor-brand-heading', t('Logo oben links'), 'h3'),
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((t) => t.button)), el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((tile) => tile.button)),
); );
return { return {
el: section, el: section,
logoSlot,
select(config) { select(config) {
for (const t of networkTiles) t.button.setAttribute('aria-pressed', String(t.id === config.network)); for (const tile of brandTiles) tile.button.setAttribute('aria-pressed', String(tile.id === config.brand));
for (const t of brandTiles) t.button.setAttribute('aria-pressed', String(t.id === config.brand)); for (const tone of toneInputs) tone.input.checked = tone.id === config.details;
for (const t of toneInputs) t.input.checked = t.id === config.details;
}, },
}; };
} }
+6
View File
@@ -31,6 +31,12 @@ export const EN: Readonly<Record<string, string>> = {
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.': 'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.', 'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
// Logo auf der Karte (logos/logo-asset.ts, community/publish.ts, editor/options-panel.ts)
'Das gewählte Logo ist nicht mehr verfügbar.': 'The selected logo is no longer available.',
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.':
'This card’s logo is no longer available. Open the card in the editor and choose another one.',
'Logo oben links': 'Top-left logo',
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich. // API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
'Bitte melde dich an.': 'Please sign in.', 'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.', 'Nicht gefunden.': 'Not found.',
+406
View File
@@ -0,0 +1,406 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CommunityLogo } from './logos';
const mocks = vi.hoisted(() => ({ getLogo: vi.fn(), loadLogoBlob: vi.fn(), createImageBitmap: vi.fn() }));
// Zeile und Datei kommen über getLogo/loadLogoBlob; Appwrite, Bilddekodierung und Canvas sind hier ersetzt.
vi.mock('./logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
/** Eine frische Kopie des Moduls, damit der Zwischenspeicher je Test leer beginnt. */
async function fresh() {
vi.resetModules();
return import('./logo-asset');
}
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'l1',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'l1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h }) as unknown as ImageBitmap;
const file = () => new Blob(['png'], { type: 'image/png' });
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
interface CanvasCall {
name: string;
args: unknown[];
/** Stand von `globalCompositeOperation` und `fillStyle` im Moment des Aufrufs. */
globalCompositeOperation: unknown;
fillStyle: unknown;
}
interface FakeCanvas {
width: number;
height: number;
calls: CanvasCall[];
getContext: ReturnType<typeof vi.fn>;
}
const canvases: FakeCanvas[] = [];
/** `document.createElement('canvas')` liefert aufzeichnende Fakes (happy-dom hat keinen 2D-Kontext). */
function stubCanvas(options: { noContext?: boolean } = {}): void {
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
if (tag !== 'canvas') throw new Error(`unerwartetes Element: ${tag}`);
const ctx: Record<string, unknown> = { globalCompositeOperation: 'source-over', fillStyle: '#000000' };
const canvas: FakeCanvas = { width: 0, height: 0, calls: [], getContext: vi.fn(() => (options.noContext ? null : ctx)) };
const record =
(name: string) =>
(...args: unknown[]) =>
void canvas.calls.push({ name, args, globalCompositeOperation: ctx.globalCompositeOperation, fillStyle: ctx.fillStyle });
ctx.drawImage = record('drawImage');
ctx.fillRect = record('fillRect');
canvases.push(canvas);
return canvas as unknown as HTMLElement;
}) as typeof document.createElement);
}
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
mocks.getLogo.mockResolvedValue(logo());
mocks.loadLogoBlob.mockResolvedValue(file());
mocks.createImageBitmap.mockResolvedValue(bitmap(200, 100));
vi.stubGlobal('createImageBitmap', mocks.createImageBitmap);
canvases.length = 0;
stubCanvas();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('LOGO_MISSING', () => {
it('der Hinweis aus der Spec, auf Englisch übersetzt', async () => {
const { LOGO_MISSING } = await fresh();
// Nach resetModules ist auch i18n frisch (die Sprache des Setups gilt für die alte Kopie): die Sprache hier selbst setzen.
const { t, setLang } = await import('../i18n/i18n');
setLang('de', { persist: false });
expect(LOGO_MISSING).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(t(LOGO_MISSING)).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
setLang('en', { persist: false });
expect(t(LOGO_MISSING)).toBe('The selected logo is no longer available.');
});
});
describe('loadLogoAsset: Laden', () => {
it('mehrfarbiges Logo: das dekodierte Bild in Originalgröße, ohne Canvas', async () => {
const { loadLogoAsset } = await fresh();
const blob = file();
const decoded = bitmap(200, 100);
mocks.loadLogoBlob.mockResolvedValue(blob);
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBe(decoded);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.getLogo).toHaveBeenCalledWith('l1');
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'l1', fileId: 'l1' }));
expect(mocks.createImageBitmap).toHaveBeenCalledWith(blob);
expect(canvases).toHaveLength(0);
});
it('fehlende Zeile (gelöscht oder ausgeblendet für mich): null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('ausgeblendetes Logo (Hochlader und Admins können die Zeile lesen): trotzdem null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('Datei fehlt (404): null', async () => {
const { loadLogoAsset } = await fresh();
mocks.loadLogoBlob.mockRejectedValue(fileGone());
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.loadLogoBlob.mockRejectedValue(Object.assign(new Error('weg'), { code: 404 }));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('ein echter Ladefehler (Netz, 5xx) ist kein fehlendes Logo: er wird weitergereicht', async () => {
const { loadLogoAsset } = await fresh();
const failure = Object.assign(new Error('Bad gateway'), { code: 502, type: 'general_server_error' });
mocks.loadLogoBlob.mockRejectedValue(failure);
await expect(loadLogoAsset('l1', 'light')).rejects.toBe(failure);
const offline = new TypeError('Failed to fetch');
mocks.getLogo.mockRejectedValue(offline);
await expect(loadLogoAsset('l2', 'light')).rejects.toBe(offline);
});
it('nicht lesbare Datei: null (das Logo ist nicht verfügbar)', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockRejectedValue(new DOMException('kaputt', 'InvalidStateError'));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(0, 0));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('mehr als 1 000 000 Pixel (nur eine veränderte Datei): null, genau 1 000 000 und die größte erlaubte Größe: ein Bild', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockResolvedValue(bitmap(1001, 1000));
await expect(loadLogoAsset('big', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(1000, 1000));
await expect(loadLogoAsset('limit', 'light')).resolves.toMatchObject({ w: 1000, h: 1000 });
mocks.createImageBitmap.mockResolvedValue(bitmap(720, 250));
await expect(loadLogoAsset('max', 'light')).resolves.toMatchObject({ w: 720, h: 250 });
});
it('zu großes einfarbiges Logo: null, es wird kein Canvas dafür angelegt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(bitmap(30000, 30000));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(canvases).toHaveLength(0);
});
});
describe('loadLogoAsset: einfarbige Logos', () => {
it.each([
['light', '#FFFFFF'],
['dark', '#111111'],
] as const)('Details %s: Canvas in Bildgröße, Bild zeichnen, dann source-in mit %s füllen', async (tone, color) => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', tone);
expect(canvases).toHaveLength(1);
const canvas = canvases[0];
expect(asset?.source).toBe(canvas);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect([canvas.width, canvas.height]).toEqual([200, 100]);
expect(canvas.getContext).toHaveBeenCalledWith('2d');
expect(canvas.calls.map((c) => c.name)).toEqual(['drawImage', 'fillRect']);
// erst das Bild (die Form kommt aus dem Alphakanal) …
expect(canvas.calls[0].args[0]).toBe(decoded);
expect(canvas.calls[0].globalCompositeOperation).toBe('source-over');
// … dann die Farbe nur dort, wo das Bild deckt
expect(canvas.calls[1]).toMatchObject({ args: [0, 0, 200, 100], globalCompositeOperation: 'source-in', fillStyle: color });
});
it('mehrfarbige Logos bleiben unverändert (kein Canvas, beide Tonarten dasselbe Bild)', async () => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.createImageBitmap.mockResolvedValue(decoded);
expect((await loadLogoAsset('l1', 'light'))?.source).toBe(decoded);
expect((await loadLogoAsset('l1', 'dark'))?.source).toBe(decoded);
expect(canvases).toHaveLength(0);
});
it('ohne 2D-Kontext wirft es (das ist kein fehlendes Logo)', async () => {
const { loadLogoAsset } = await fresh();
stubCanvas({ noContext: true });
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow(/Canvas/);
});
});
describe('loadLogoAsset: Zwischenspeicher', () => {
it('gleiche (id, tone) lädt nur einmal, auch bei gleichzeitigen Aufrufen und später', async () => {
const { loadLogoAsset } = await fresh();
const a = loadLogoAsset('l1', 'light');
const b = loadLogoAsset('l1', 'light');
expect(b).toBe(a);
const first = await a;
expect(await loadLogoAsset('l1', 'light')).toBe(first);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(1);
expect(mocks.createImageBitmap).toHaveBeenCalledTimes(1);
});
it('eine andere Tonart ist ein eigenes Bild, die Zeile wird aber nur einmal geholt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const light = await loadLogoAsset('l1', 'light');
const dark = await loadLogoAsset('l1', 'dark');
expect(dark).not.toBe(light);
expect(canvases).toHaveLength(2);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlendes Logo (null) bleibt ebenfalls zwischengespeichert', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('forgetLogo: danach wird Zeile und Bild neu geladen', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
forgetLogo('l1');
mocks.getLogo.mockResolvedValue(null); // inzwischen gelöscht
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
expect(first).not.toBeNull();
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('forgetLogo vergisst nur dieses Logo', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
mocks.getLogo.mockImplementation(async (id: string) => logo({ id, fileId: id }));
const other = await loadLogoAsset('l2', 'light');
await loadLogoAsset('l1', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l2', 'light')).toBe(other);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
// l1 und l1.x sind verschiedene Logos: das Präfix darf nicht auch l1.x erwischen
mocks.getLogo.mockClear();
const dotted = await loadLogoAsset('l1.x', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l1.x', 'light')).toBe(dotted);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlgeschlagener Ladevorgang bleibt nicht im Cache (Zeile und Bild)', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow('Failed to fetch');
await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
mocks.loadLogoBlob.mockRejectedValueOnce(Object.assign(new Error('Bad gateway'), { code: 502 }));
await expect(loadLogoAsset('l2', 'light')).rejects.toThrow('Bad gateway');
await expect(loadLogoAsset('l2', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
});
it('ein Fehler nach forgetLogo wirft den neuen Eintrag nicht aus dem Cache', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
let fail!: (e: Error) => void;
mocks.getLogo.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
const stale = loadLogoAsset('l1', 'light');
stale.catch(() => undefined);
forgetLogo('l1');
const current = await loadLogoAsset('l1', 'light');
fail(new Error('spät'));
await expect(stale).rejects.toThrow('spät');
expect(await loadLogoAsset('l1', 'light')).toBe(current);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('rememberLogo legt die Zeile in den Cache: getLogo wird nicht gebraucht', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ id: 'neu', fileId: 'neu' }));
await expect(loadLogoAsset('neu', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).not.toHaveBeenCalled();
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'neu', fileId: 'neu' }));
});
it('rememberLogo mit einem ausgeblendeten Logo: null', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('rememberLogo mit einem jetzt ausgeblendeten Logo verwirft schon geladene Bilder (ein gesperrtes Logo wird nicht mehr gezeichnet)', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
await expect(loadLogoAsset('l1', 'light')).resolves.not.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.not.toBeNull();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
});
it('rememberLogo mit einem sichtbaren Logo lässt geladene Bilder stehen', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
rememberLogo(logo({ likeCount: 7 }));
expect(await loadLogoAsset('l1', 'light')).toBe(first);
});
});
describe('loadLogoAsset: ohne createImageBitmap', () => {
class FakeImage {
static mode: 'load' | 'error' = 'load';
static size = { w: 200, h: 100 };
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
#src = '';
get src() {
return this.#src;
}
set src(value: string) {
this.#src = value;
this.naturalWidth = FakeImage.size.w;
this.naturalHeight = FakeImage.size.h;
queueMicrotask(() => (FakeImage.mode === 'load' ? this.onload?.() : this.onerror?.()));
}
}
const created: string[] = [];
const revoked: string[] = [];
const realCreate = URL.createObjectURL;
const realRevoke = URL.revokeObjectURL;
beforeEach(() => {
FakeImage.mode = 'load';
FakeImage.size = { w: 200, h: 100 };
created.length = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => {
created.push(`blob:fake/${created.length + 1}`);
return created[created.length - 1];
});
URL.revokeObjectURL = vi.fn((url: string) => void revoked.push(url));
vi.stubGlobal('createImageBitmap', undefined);
vi.stubGlobal('Image', FakeImage);
});
afterEach(() => {
URL.createObjectURL = realCreate;
URL.revokeObjectURL = realRevoke;
});
it('lädt das Logo über ein Image aus einer Object-URL und gibt die URL danach frei', async () => {
const { loadLogoAsset } = await fresh();
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBeInstanceOf(FakeImage);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.createImageBitmap).not.toHaveBeenCalled();
expect(created).toEqual(['blob:fake/1']);
expect(revoked).toEqual(['blob:fake/1']);
});
it('Ladefehler des Bildes: null, die URL ist trotzdem freigegeben', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.mode = 'error';
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(revoked).toEqual(['blob:fake/1']);
});
it('auch hier gilt die Pixelgrenze', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.size = { w: 2000, h: 1000 };
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
});
it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const asset = await loadLogoAsset('l1', 'dark');
expect(asset?.source).toBe(canvases[0]);
expect(canvases[0].calls[0].args[0]).toBeInstanceOf(FakeImage);
expect(canvases[0].calls[1]).toMatchObject({ globalCompositeOperation: 'source-in', fillStyle: '#111111' });
});
});
+142
View File
@@ -0,0 +1,142 @@
// Community-Logos als Bild für den Kartenrenderer: Zeile und PNG laden, dekodieren, einfarbige Logos in der Details-Farbe füllen.
// Zeilen und fertige Bilder bleiben im Speicher (je Logo und Details-Farbe nur ein Ladevorgang). Ein Logo, das es nicht (mehr)
// gibt, ergibt null; ein echter Ladefehler (Netz, Server) wirft und bleibt nicht im Zwischenspeicher.
import { TONE_COLOR, type DetailsTone } from '../card/config';
// Nur der Typ: render.ts importiert dieses Modul (Standard-Lader), zur Laufzeit darf kein Kreis entstehen.
import type { LogoAsset } from '../card/render';
import { msg } from '../i18n/i18n';
import { getLogo, loadLogoBlob, type CommunityLogo } from './logos';
/** Hinweis im Editor, wenn die Karte ein Logo wählt, das gelöscht oder ausgeblendet wurde. */
export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
/**
* Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles
* Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet.
*/
const MAX_LOGO_PIXELS = 1_000_000;
type Decoded = LogoAsset;
// Zeilen (auch „gibt es nicht“ = null) und fertige Bilder; die Schlüssel der Bilder sind „<id>:<tone>“ (IDs enthalten kein „:“).
const rows = new Map<string, Promise<CommunityLogo | null>>();
const assets = new Map<string, Promise<LogoAsset | null>>();
/** Verwirft die fertigen Bilder eines Logos (beide Details-Farben). */
function dropImages(id: string): void {
for (const key of assets.keys()) if (key.startsWith(`${id}:`)) assets.delete(key);
}
/**
* Legt eine Zeile, die der Aufrufer schon hat (Logo-Liste, frisch hochgeladenes Logo), in den Zwischenspeicher: spart `getLogo`.
* Ist sie ausgeblendet, fallen schon geladene Bilder des Logos weg: ein gesperrtes Logo darf nicht mehr gezeichnet werden.
*/
export function rememberLogo(logo: CommunityLogo): void {
rows.set(logo.id, Promise.resolve(logo));
if (logo.hidden) dropImages(logo.id);
}
/** Vergisst Zeile und Bilder eines Logos (nach Löschen oder Ausblenden); der nächste Zugriff lädt neu. */
export function forgetLogo(id: string): void {
rows.delete(id);
dropImages(id);
}
/** Die Zeile eines Logos, einmal je ID; ein Fehler wird nicht festgehalten. */
function rowFor(id: string): Promise<CommunityLogo | null> {
const cached = rows.get(id);
if (cached) return cached;
const request = getLogo(id);
// Nur entfernen, was noch derselbe Eintrag ist: forgetLogo/rememberLogo können ihn inzwischen ersetzt haben.
request.catch(() => {
if (rows.get(id) === request) rows.delete(id);
});
rows.set(id, request);
return request;
}
/** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */
function isFileMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 || type === 'storage_file_not_found';
}
/** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */
function decodeWithImage(blob: Blob): Promise<Decoded> {
const url = URL.createObjectURL(blob);
return new Promise<Decoded>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ source: img, w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
img.src = url;
}).finally(() => URL.revokeObjectURL(url));
}
/** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */
async function decode(blob: Blob): Promise<Decoded | null> {
try {
let decoded: Decoded;
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(blob);
decoded = { source: bitmap, w: bitmap.width, h: bitmap.height };
} else {
decoded = await decodeWithImage(blob);
}
return decoded.w > 0 && decoded.h > 0 ? decoded : null;
} catch {
return null;
}
}
/** Ein Canvas in Bildgröße, in dem nur noch die Form des Bildes (sein Alphakanal) steht, gefüllt mit `color`. */
function fillShape({ source, w, h }: Decoded, color: string): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
ctx.drawImage(source, 0, 0);
// „source-in“: die Farbe bleibt nur dort, wo das Bild schon deckt.
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = color;
ctx.fillRect(0, 0, w, h);
return canvas;
}
async function build(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
const logo = await rowFor(id);
// Ausgeblendete Logos können Hochlader und Admins noch lesen. Ein gesperrtes Logo darf aber nie in einer Karte stehen,
// die sich veröffentlichen lässt: es gilt als nicht verfügbar.
if (!logo || logo.hidden) return null;
let blob: Blob;
try {
blob = await loadLogoBlob(logo);
} catch (err) {
if (isFileMissing(err)) return null;
throw err;
}
const decoded = await decode(blob);
if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null;
if (!logo.mono) return decoded;
return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h };
}
/**
* Das Community-Logo `id` als Bild für den Renderer. Einfarbige Logos (`mono`) haben die Farbe der Details (Hell/Dunkel),
* mehrfarbige bleiben unverändert. null: das Logo gibt es nicht (mehr), ist ausgeblendet, oder seine Datei ist unbrauchbar.
* Wirft bei einem echten Ladefehler; der bleibt nicht im Zwischenspeicher, ein späterer Aufruf versucht es neu.
*/
export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
const key = `${id}:${tone}`;
const cached = assets.get(key);
if (cached) return cached;
const request = build(id, tone);
request.catch(() => {
if (assets.get(key) === request) assets.delete(key);
});
assets.set(key, request);
return request;
}
+2 -2
View File
@@ -261,7 +261,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]); mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]);
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' })); mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
mocks.decodeImage.mockResolvedValue(image); mocks.decodeImage.mockResolvedValue(image);
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null }); mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
mocks.exportCard.mockResolvedValue(png); mocks.exportCard.mockResolvedValue(png);
mount(); mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await vi.waitFor(() => expect(tiles()).toHaveLength(1));
@@ -327,7 +327,7 @@ describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () =
mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' })); mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' }));
mocks.decodeImage.mockResolvedValue(image); mocks.decodeImage.mockResolvedValue(image);
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null }); mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
mocks.exportCard.mockResolvedValue(png); mocks.exportCard.mockResolvedValue(png);
mount(); mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await vi.waitFor(() => expect(tiles()).toHaveLength(1));
+5 -1
View File
@@ -141,7 +141,11 @@ describe('Theme und Kartenausgabe', () => {
return true; return true;
}, },
}); });
const assets: CardAssets = { image: null, network: {} as CanvasImageSource, brand: {} as CanvasImageSource }; const assets: CardAssets = {
image: null,
logo: { source: {} as CanvasImageSource, w: 200, h: 100 },
brand: {} as CanvasImageSource,
};
renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath')); renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath'));
return log; return log;
} }
+4
View File
@@ -25,6 +25,10 @@ describe('Einzelne Quellen', () => {
expect(definedIn(/\b(?:const|let|var)\s+CARD_H\b/)).toEqual(['card/geometry.ts']); expect(definedIn(/\b(?:const|let|var)\s+CARD_H\b/)).toEqual(['card/geometry.ts']);
}); });
it('ID_PATTERN (Muster der Appwrite-IDs, auch für die Logo-ID der Konfiguration) wird nur in card/config.ts definiert', () => {
expect(definedIn(/\b(?:const|let|var)\s+ID_PATTERN\b/)).toEqual(['card/config.ts']);
});
it('reportsLabel wird nur in pages/format.ts definiert', () => { it('reportsLabel wird nur in pages/format.ts definiert', () => {
expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']); expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']);
}); });