import type { EventSettings } from '@/shared' /** * 大屏字体:把 settings 里的字体 id 解析成可用的 CSS 字体栈。 * * 内置字体在 `@/shared/styles/fonts.css` 里已有 @font-face 与 `--font-*` 令牌,直接用令牌; * 上传字体要从 `<项目目录>/uploads/fonts/` 现取,这里动态注册 @font-face, * 家族名统一加 `scoreboard-custom-` 前缀,避免与内置家族撞名。 * * 大屏是无人值守的:字体只是外观,任何一步失败都必须静默回落到内置默认字体, * 绝不能让整块屏空着或显示方块。 */ /** 内置字体 id → CSS 令牌(与 `fonts.css` 的 `--font-*` 一一对应)。 */ export const BUILTIN_FONT_VARS: Readonly> = { 'builtin:noto-sans': 'var(--font-noto-sans)', 'builtin:noto-serif': 'var(--font-noto-serif)', 'builtin:misans': 'var(--font-misans)', 'builtin:source-han-sans': 'var(--font-source-han-sans)', } /** 内置字体的下拉显示名(与服务端 GET /api/fonts 的 label 一致)。 */ export const BUILTIN_FONTS: ReadonlyArray<{ id: string; label: string }> = [ { id: 'builtin:noto-sans', label: 'Noto Sans SC(思源黑体同源)' }, { id: 'builtin:noto-serif', label: 'Noto Serif SC(思源宋体同源)' }, { id: 'builtin:misans', label: 'MiSans' }, { id: 'builtin:source-han-sans', label: 'Source Han Sans CN(思源黑体)' }, ] export const CUSTOM_FONT_PREFIX = 'custom:' export const CUSTOM_FONT_DIR = '/uploads/fonts/' export const CUSTOM_FAMILY_PREFIX = 'scoreboard-custom-' const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2'] /** 上传字体的回落链:与内置默认令牌一致,注册失败时大屏仍是正常可读的黑体。 */ const FALLBACK_STACK = "var(--font-noto-sans, 'Noto Sans SC', system-ui, sans-serif)" /** 从 `custom:<文件名>` 取文件名;不是上传字体或扩展名不受支持时返回 null。 */ export function customFontName(id: string | null | undefined): string | null { const value = (id ?? '').trim() if (!value.startsWith(CUSTOM_FONT_PREFIX)) return null const name = value.slice(CUSTOM_FONT_PREFIX.length) if (name === '') return null const lower = name.toLowerCase() return FONT_EXTENSIONS.some((ext) => lower.endsWith(ext)) ? name : null } /** 上传字体的静态地址;文件名可能含汉字与空格,必须转义。 */ export function customFontUrl(name: string): string { return CUSTOM_FONT_DIR + encodeURIComponent(name) } /** * 上传字体的 @font-face 家族名。 * * 只保留字母数字与汉字:家族名会被拼进 CSS,引号/分号之类的字符必须挡在外面。 */ export function customFontFamily(id: string | null | undefined): string | null { const name = customFontName(id) if (name === null) return null const safe = name .replace(/[^0-9A-Za-z\u3400-\u9fff\uf900-\ufaff]/g, '-') .replace(/^-+|-+$/g, '') return CUSTOM_FAMILY_PREFIX + (safe === '' ? 'font' : safe) } /** * 字体 id → CSS 字体栈;空值或未知 id 返回 null(调用方保持现状,即内置默认字体)。 * * 这是纯函数:上传字体即便还没注册成功,也返回带回落链的完整栈—— * 浏览器会先用回落字体渲染,注册完成后自动切换,不会出现空白。 */ export function fontStack(id: string | null | undefined): string | null { const value = (id ?? '').trim() if (value === '') return null const builtin = BUILTIN_FONT_VARS[value] if (builtin !== undefined) return builtin const family = customFontFamily(value) if (family === null) return null return `'${family}', ${FALLBACK_STACK}` } /** 已注册(或正在注册)的上传字体,避免设置每次下发都重复加载同一份文件。 */ const registered = new Map>() /** * 注册上传字体的 @font-face,成功返回家族名,失败返回 null。 * * 加载失败不缓存:现场可能是服务端刚重启导致文件一时取不到, * 下次设置下发时应当重试,而不是把这块屏永久钉在默认字体上。 */ export function registerCustomFont(id: string | null | undefined): Promise { const value = (id ?? '').trim() const cached = registered.get(value) if (cached !== undefined) return cached const task = loadCustomFont(value).then((family) => { if (family === null) registered.delete(value) return family }) registered.set(value, task) return task } async function loadCustomFont(id: string): Promise { const name = customFontName(id) const family = customFontFamily(id) if (name === null || family === null) return null // 没有 FontFace API(老内核 / 非浏览器环境)时只能回落,不能让大屏报错。 if (typeof FontFace === 'undefined' || typeof document === 'undefined' || document.fonts === undefined) { return null } try { const face = new FontFace(family, `url("${customFontUrl(name)}")`) await face.load() document.fonts.add(face) return family } catch (error) { console.warn('[大屏字体] 上传字体加载失败,回落到默认字体:', id, error) return null } } let applySeq = 0 /** * 把 settings 里的字体挂到 `document.documentElement` 的 CSS 变量上, * 由 `@/shared/styles/base.css` 消费(标题用 `--display-font-title`、正文用 `--display-font-body`)。 * * 未配置或加载失败时删掉内联变量,让 base.css 的 `:root` 默认值生效—— * 没配字体的赛事视觉必须与之前完全一致。 */ export async function applyDisplayFonts(settings: EventSettings | null): Promise { const seq = ++applySeq const display = settings?.display const [title, body] = await Promise.all([ resolveStack(display?.fontTitle), resolveStack(display?.fontBody), ]) // 加载期间可能又来了一份设置:丢弃过期结果,避免慢的旧字体盖掉新选择。 if (seq !== applySeq) return setRootFontVar('--display-font-title', title) setRootFontVar('--display-font-body', body) } async function resolveStack(id: string | null | undefined): Promise { const value = (id ?? '').trim() if (value === '' || BUILTIN_FONT_VARS[value] !== undefined) return fontStack(value) // 上传字体必须先确认能加载:加载不了就用默认字体,不留一个不存在的家族名在栈里。 const family = await registerCustomFont(value) return family === null ? null : fontStack(value) } function setRootFontVar(name: string, value: string | null): void { if (typeof document === 'undefined') return if (value === null) { document.documentElement.style.removeProperty(name) return } document.documentElement.style.setProperty(name, value) }