import { Platform } from 'react-native'; import { logger, type FontDetails, type IPaywall } from '@namiml/sdk-core'; type FontModule = { loadAsync(map: Record): Promise; }; type PreparedFont = FontDetails & { alias: string; }; type HostedFontsMap = Record; type FontVariantKey = 'regular' | 'bold' | 'italic' | 'boldItalic'; export type ResolvedFontDescriptor = { family?: string; isHosted: boolean; variant?: FontVariantKey; }; let fontModule: FontModule | null = null; try { fontModule = require('expo-font'); } catch { try { fontModule = require('@amazon-devices/expo-font'); } catch { fontModule = null; } } const knownFontFaces = new Set(); const loadedFonts = new Set(); const loadingFonts = new Map>(); let warnedMissingFontModule = false; const warnedFailedFontLoads = new Set(); const fontFamilyVariants = new Map>(); const aliasToFamilyKey = new Map(); const KEPLER_VARIANT_COLLISION_FAMILY_KEYS = new Set(['All-ProDisplayC']); export async function prepareAndLoadFonts( fontsObject?: HostedFontsMap, ): Promise { if (!fontsObject) return false; if (!fontModule) { if (!warnedMissingFontModule && Object.keys(fontsObject).length > 0) { warnedMissingFontModule = true; logger.warn( '[NamiExpo][fonts] Paywall requested hosted fonts, but expo-font is unavailable in the current app runtime.', ); } return false; } const fonts = collectHostedFonts(fontsObject); for (const font of fonts) { registerFontMetadata(font); } const results = await Promise.all(fonts.map((font) => loadFont(font))); return results.some(Boolean); } export function prewarmPaywallFonts(paywalls: Array): void { const uniqueCollections = new Set(); for (const paywall of paywalls) { if (!paywall?.fonts) { continue; } uniqueCollections.add(paywall.fonts); } for (const fonts of uniqueCollections) { void prepareAndLoadFonts(fonts); } } export async function prepareAndLoadFontsWithTimeout( fontsObject?: HostedFontsMap, timeoutMs = 1200, ): Promise<'ready' | 'timeout' | 'failed' | 'none'> { if (!fontsObject || Object.keys(fontsObject).length === 0) { return 'none'; } let timer: ReturnType | undefined; const result: 'ready' | 'failed' | 'timeout' = await Promise.race([ ensureFontsReady(fontsObject).then((value) => (value === 'none' ? 'failed' : value)), new Promise<'timeout'>((resolve) => { timer = setTimeout(() => resolve('timeout'), timeoutMs); }), ]); if (timer) { clearTimeout(timer); } return result; } export async function ensureFontsReady( fontsObject?: HostedFontsMap, ): Promise<'ready' | 'failed' | 'none'> { if (!fontsObject || Object.keys(fontsObject).length === 0) { return 'none'; } await prepareAndLoadFonts(fontsObject); const fonts = collectHostedFonts(fontsObject); const allReady = fonts.every((font) => loadedFonts.has(font.alias || buildFontFaceFamily(font))); return allReady ? 'ready' : 'failed'; } function collectHostedFonts(fontsObject?: HostedFontsMap): PreparedFont[] { if (!fontsObject) { return []; } return Object.entries(fontsObject) .flatMap(([fontName, value]) => { if (!value?.file) { return []; } return [{ ...value, alias: sanitizeFontName(fontName), } satisfies PreparedFont]; }); } async function loadFont(font: PreparedFont): Promise { if (!fontModule) return false; if (shouldSkipHostedFontLoad(font)) { return false; } const family = font.alias || buildFontFaceFamily(font); knownFontFaces.add(family); if (loadedFonts.has(family)) return false; const existing = loadingFonts.get(family); if (existing) { await existing; return false; } let loaded = false; const promise = fontModule .loadAsync({ [family]: { uri: font.file } }) .then(() => { loadedFonts.add(family); loaded = true; }) .catch((error) => { if (!warnedFailedFontLoads.has(family)) { warnedFailedFontLoads.add(family); logger.warn( `[NamiExpo][fonts] Failed to load hosted font "${family}" from "${font.file}".`, error, ); } // Keep the renderer resilient if a hosted font is unavailable. }) .finally(() => { loadingFonts.delete(family); }); loadingFonts.set(family, promise); await promise; return loaded; } export function buildFontFaceFamily(font: FontDetails): string { return sanitizeFontName(`${font.family}-${font.style}`); } export function sanitizeFontName(value: string): string { return value.replace(/ /g, ''); } export function resolveFontFamily( fontName?: string, options?: { italic?: boolean; bold?: boolean }, ): string | undefined { return resolveFontDescriptor(fontName, options).family; } export function resolveFontDescriptor( fontName?: string, options?: { italic?: boolean; bold?: boolean }, ): ResolvedFontDescriptor { if (!fontName) { return { family: undefined, isHosted: false, }; } const sanitized = sanitizeFontName(fontName); const familyKey = aliasToFamilyKey.get(sanitized) ?? sanitized; const exact = getKnownFontFamily(sanitized); if (exact) { return { family: exact, isHosted: true, variant: getRegisteredFontVariant(familyKey, exact), }; } if (options?.bold || options?.italic) { const knownVariant = getKnownVariantFamily(familyKey, options); if (knownVariant) { return { family: knownVariant, isHosted: true, variant: getRegisteredFontVariant(familyKey, knownVariant), }; } } const knownBase = getKnownVariantFamily(familyKey); if (knownBase) { return { family: knownBase, isHosted: true, variant: getRegisteredFontVariant(familyKey, knownBase), }; } return { family: undefined, isHosted: false, }; } function registerFontMetadata(font: PreparedFont): void { const familyKey = sanitizeFontName(font.family); aliasToFamilyKey.set(font.alias, familyKey); const variant = normalizeFontVariant(font.style); const variants = fontFamilyVariants.get(familyKey) ?? new Map(); variants.set(variant, font.alias); fontFamilyVariants.set(familyKey, variants); } function getKnownFontFamily(candidate: string): string | undefined { if (loadedFonts.has(candidate)) { return candidate; } return undefined; } function getKnownVariantFamily( candidate: string, options?: { italic?: boolean; bold?: boolean }, ): string | undefined { const variants = fontFamilyVariants.get(candidate); if (!variants) { return undefined; } const desired = buildRequestedVariantKey(options); const exact = variants.get(desired); if (exact && getKnownFontFamily(exact)) { return exact; } const regular = variants.get('regular'); if (regular && getKnownFontFamily(regular)) { return regular; } if (!options?.bold && !options?.italic) { const bold = variants.get('bold'); if (bold && getKnownFontFamily(bold)) { return bold; } const italic = variants.get('italic'); if (italic && getKnownFontFamily(italic)) { return italic; } const boldItalic = variants.get('boldItalic'); if (boldItalic && getKnownFontFamily(boldItalic)) { return boldItalic; } } return undefined; } function getRegisteredFontVariant( familyKey: string, alias: string, ): FontVariantKey | undefined { const variants = fontFamilyVariants.get(familyKey); if (!variants) { return undefined; } for (const [variant, family] of variants.entries()) { if (family === alias) { return variant; } } return undefined; } function buildRequestedVariantKey( options?: { italic?: boolean; bold?: boolean }, ): FontVariantKey { if (options?.bold && options?.italic) return 'boldItalic'; if (options?.bold) return 'bold'; if (options?.italic) return 'italic'; return 'regular'; } function normalizeFontVariant(style?: string): FontVariantKey { const normalized = sanitizeFontName(style ?? '').toLowerCase(); const hasBold = normalized.includes('bold'); const hasItalic = normalized.includes('italic'); if (hasBold && hasItalic) return 'boldItalic'; if (hasBold) return 'bold'; if (hasItalic) return 'italic'; return 'regular'; } function shouldSkipHostedFontLoad(font: PreparedFont): boolean { const isTelevisionRuntime = Platform.isTV === true; if (!isTelevisionRuntime) { return false; } const familyKey = sanitizeFontName(font.family); if (!KEPLER_VARIANT_COLLISION_FAMILY_KEYS.has(familyKey)) { return false; } const variant = normalizeFontVariant(font.style); return variant === 'italic' || variant === 'boldItalic'; } export function inferFontNameVariant(fontName?: string): { italic: boolean; bold: boolean } { const normalized = sanitizeFontName(fontName ?? '').toLowerCase(); return { italic: normalized.includes('italic'), bold: normalized.includes('bold'), }; }