在设置页「侧边卡片」→ 终端卡片填入自定义「终端字体」后,若该族名在浏览器里 解析不到,终端不是退化成"没有想要的字体",而是整个网格崩掉:字符间距被撑开、 字形变成比例字形、Nerd Font 图标全部消失。 用户实测(macOS + Chrome,填 `Maple Mono NF CN`): | 「终端字体」的值 | DevTools Rendered Fonts | 结果 | | --- | --- | --- | | `Maple Mono NF CN` | **PingFang SC** | cell 12.09px @ 13px 字号(0.93em),网格崩坏 | | `Maple Mono NF CN, monospace` | **Menlo** | 间距恢复正常 | `resolveTerminalFont` 对用户值(以及主题 `--ds-font-family-code` token 值) 原样返回,只有内置的 `DEFAULT_TERMINAL_FONT_FAMILY` 末尾带 `monospace`。 字体栈若不以通用族收尾,浏览器解析不到任何具名字体时会落到**标准字体** (standard font,macOS 中文环境是 PingFang SC)——它是**比例字体**。 xterm 用它的推进宽度量 cell(0.93em,等宽字体应为 0.6em),网格随即失真。 这条路径比想象中容易踩:终端里跑的 shell 在 host 上,字体却由**浏览器所在机器** 提供,两者不同机时(远程/容器化的 DSH)用户按 host 侧装的字体填名字必然解析失败。 新增 `withMonospaceFallback()`,在 `resolveTerminalFont` 出口保证字体栈以通用族收尾。 - **不做字体嗅探**:终端字体设计文档 §2 把"字体合法性嗅探"列为非目标;本改动 不检测具名字体是否存在,只补一个通用族尾项。 - **已有通用族则原样保留**:包括用户主动写的非等宽通用族(`sans-serif` 等) ——那是用户的选择,不该被覆盖。 - **主题 token 值同样兜底**:它掉进比例字体的后果与用户值完全一样。 - **CSS 全局关键字单独处理**:`inherit` / `initial` / `unset` / `revert` / `revert-layer` 追加逗号后是非法 CSS,会被 CSSOM 整条丢弃,反而让 xterm 一个字体都拿不到;这类值直接回落内置栈。 - 字体栈按**顶层逗号**切分并感知引号、转义与括号,避免拆坏 `"Foo, Bar"` 与 `var(--x, y)`;通用族匹配大小写不敏感且只认未加引号的项。 - `tests/terminal-font.spec.ts` 4 → 13 例(兜底追加、已有通用族免动、大小写与 引号边界、顶层逗号切分、幂等性、CSS 全局关键字、空值) - `pnpm typecheck` 零错误 - 全量 `pnpm test`:941 passed(改动前基线 932 passed,+9 为本次新增); 另有 31 例失败在本分支基线(干净 main)上同样失败,与本改动无关 互补、不重叠。#190 解决"字体解析成功、但栈里没显式列 Nerd Font 导致补充平面 PUA 图标缺字";本 PR 解决"字体压根没解析到、栈又没有通用族尾项导致落进比例 字体"。两者都落在 `terminal-font.ts` 的同一出口,合并时按先后 rebase 即可。
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
/**
|
||
* Terminal font resolution: the user's custom font prefs (SidebarPrefs,
|
||
* configured under the terminal card's secondary settings) turned into the
|
||
* xterm options. Kept as a pure module (no DOM, no xterm) so the fallback
|
||
* chain and clamping are unit-testable without mounting a terminal.
|
||
*/
|
||
import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts'
|
||
|
||
/** The built-in fallback stack when neither the user nor the theme sets one. */
|
||
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||
|
||
/**
|
||
* Icon fonts appended to whichever base stack wins, so shell prompts that
|
||
* draw glyphs from the Nerd Font Private Use Areas resolve to a real glyph
|
||
* instead of the missing-glyph box (aka tofu).
|
||
*
|
||
* Why this is needed even though the OS "should" fall back automatically:
|
||
* prompt frameworks (starship, powerlevel10k, oh-my-posh) take their icons
|
||
* from the PUA. Chromium's implicit system fallback reliably covers the
|
||
* *BMP* PUA (U+E000–U+F8FF — e.g. the U+E0B0 powerline separator) but NOT
|
||
* the *supplementary-plane* PUA-B (U+F0000+) where Nerd Fonts v3 relocated
|
||
* the Material Design icon set. Naming the families explicitly makes the
|
||
* browser consult them per character, which covers both planes.
|
||
*
|
||
* Deliberately NOT listed: color-emoji families. Chromium routes genuine
|
||
* emoji code points through a dedicated emoji fallback path (which is why
|
||
* emoji already render), so naming them buys nothing here — while placing a
|
||
* color font ahead of the generic family risks capturing BMP symbols the
|
||
* prompt expects in monospace (U+26A0 ⚠, U+2714 ✔ …) and rendering them as
|
||
* wide color glyphs that break the cell grid.
|
||
*
|
||
* Ordering rationale: the symbols-only patches ship glyphs without Latin,
|
||
* so they can never hijack ASCII metrics — the safest first hop. The
|
||
* fully-patched distributions follow for users who installed one of those
|
||
* instead. Both the `… Mono` and proportional family names are listed
|
||
* because the Nerd Fonts installers register them as distinct families.
|
||
*
|
||
* These are strictly *appended*, never prepended: xterm derives its cell
|
||
* metrics from the first entry, so the base font must stay in front or the
|
||
* whole grid would be re-measured against an icon font.
|
||
*/
|
||
export const ICON_FONT_FALLBACKS: readonly string[] = [
|
||
// Symbols-only patches (glyph coverage without Latin) — ideal fallbacks.
|
||
'"Symbols Nerd Font Mono"',
|
||
'"Symbols Nerd Font"',
|
||
// Common fully-patched Nerd Font distributions.
|
||
'"Hack Nerd Font Mono"',
|
||
'"Hack Nerd Font"',
|
||
'"JetBrainsMono Nerd Font Mono"',
|
||
'"JetBrainsMono Nerd Font"',
|
||
'"FiraCode Nerd Font Mono"',
|
||
'"FiraCode Nerd Font"',
|
||
'"CaskaydiaCove Nerd Font Mono"',
|
||
'"CaskaydiaCove Nerd Font"',
|
||
'"SauceCodePro Nerd Font Mono"',
|
||
'"UbuntuMono Nerd Font Mono"',
|
||
'"Iosevka Nerd Font Mono"',
|
||
'"MesloLGS Nerd Font Mono"',
|
||
'"MesloLGS NF"',
|
||
]
|
||
|
||
/** The symbols-only patch count at the head of {@link ICON_FONT_FALLBACKS}
|
||
* (these carry no Latin glyphs, so they can never hijack ASCII metrics). */
|
||
const SYMBOLS_ONLY_COUNT = 2
|
||
|
||
/**
|
||
* CSS generic font families. A generic is a catch-all that always resolves,
|
||
* so icon fonts must be spliced in *before* the first one to stay reachable.
|
||
*/
|
||
const GENERIC_FAMILIES = new Set([
|
||
'monospace', 'serif', 'sans-serif', 'cursive', 'fantasy',
|
||
'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded',
|
||
'math', 'emoji', 'fangsong',
|
||
])
|
||
|
||
/**
|
||
* CSS-wide keywords. These are only valid as an *entire* declaration value,
|
||
* so a stack cannot be appended to one.
|
||
*/
|
||
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer'])
|
||
|
||
/** Normalize one family name for comparison: unquote, collapse runs of
|
||
* whitespace, casefold. */
|
||
function normalizeFamily(family: string): string {
|
||
return family
|
||
.trim()
|
||
.replace(/^["']|["']$/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* Split a CSS font-family stack on its top-level commas.
|
||
*
|
||
* Naive `split(',')` would corrupt quoted family names containing a comma
|
||
* and function values such as `var(--x, monospace)`, so quotes (with
|
||
* backslash escapes) and parentheses are tracked.
|
||
*
|
||
* @param stack - a CSS font-family stack.
|
||
* @returns the trimmed, non-empty family entries in source order.
|
||
*/
|
||
function splitFamilies(stack: string): string[] {
|
||
const entries: string[] = []
|
||
let buffer = ''
|
||
let quote: string | null = null
|
||
let depth = 0
|
||
for (let i = 0; i < stack.length; i += 1) {
|
||
const char = stack[i] as string
|
||
if (quote !== null) {
|
||
// Escapes are copied verbatim so `\"` does not close the quote.
|
||
if (char === '\\' && i + 1 < stack.length) {
|
||
buffer += char + (stack[i + 1] as string)
|
||
i += 1
|
||
continue
|
||
}
|
||
buffer += char
|
||
if (char === quote) quote = null
|
||
continue
|
||
}
|
||
if (char === '"' || char === "'") {
|
||
quote = char
|
||
buffer += char
|
||
continue
|
||
}
|
||
if (char === '(') depth += 1
|
||
else if (char === ')') depth = Math.max(0, depth - 1)
|
||
else if (char === ',' && depth === 0) {
|
||
entries.push(buffer)
|
||
buffer = ''
|
||
continue
|
||
}
|
||
buffer += char
|
||
}
|
||
entries.push(buffer)
|
||
return entries.map(entry => entry.trim()).filter(entry => entry !== '')
|
||
}
|
||
|
||
/**
|
||
* Append {@link ICON_FONT_FALLBACKS} to a CSS font-family stack, keeping
|
||
* the caller's own entries and order intact.
|
||
*
|
||
* - Families already named in `stack` are not duplicated (quote-, case- and
|
||
* whitespace-insensitive), so a user who already lists their Nerd Font
|
||
* keeps their exact priority.
|
||
* - The icon fonts are spliced in ahead of the *first* generic family
|
||
* (`monospace` etc.), because a generic always resolves: anything after it
|
||
* would never be consulted. A stack that OPENS with a generic is the one
|
||
* exception: only the symbols-only patches (no Latin) may precede it — a
|
||
* fully-patched Nerd Font there would become xterm's measuring base font
|
||
* and override the user/theme family precedence, so it is placed after the
|
||
* generic instead.
|
||
* - Idempotent — re-applying to an already-topped-up stack is a no-op, which
|
||
* matters because `TerminalView` diffs the resolved value against the live
|
||
* `term.options.fontFamily` before reflowing.
|
||
*
|
||
* @param stack - a CSS font-family stack (base font first).
|
||
* @returns the stack with icon fallbacks merged in.
|
||
*/
|
||
export function withIconFontFallbacks(stack: string): string {
|
||
const entries = splitFamilies(stack)
|
||
if (entries.length === 0) return ICON_FONT_FALLBACKS.join(', ')
|
||
|
||
const present = new Set(entries.map(normalizeFamily))
|
||
const notPresent = (family: string): boolean => !present.has(normalizeFamily(family))
|
||
const symbolsOnly = ICON_FONT_FALLBACKS.slice(0, SYMBOLS_ONLY_COUNT).filter(notPresent)
|
||
const patched = ICON_FONT_FALLBACKS.slice(SYMBOLS_ONLY_COUNT).filter(notPresent)
|
||
if (symbolsOnly.length === 0 && patched.length === 0) return entries.join(', ')
|
||
|
||
const firstGeneric = entries.findIndex(entry => GENERIC_FAMILIES.has(normalizeFamily(entry)))
|
||
const cut = firstGeneric === -1 ? entries.length : firstGeneric
|
||
// A fully-patched distribution ships Latin glyphs: placed ahead of a
|
||
// LEADING generic (cut === 0) it would become xterm's measuring/base font
|
||
// and override the user/theme family precedence. Only the symbols-only
|
||
// patches — which carry no Latin — may precede it; the patched fonts go
|
||
// after the generic (still reachable per character, never the base).
|
||
if (cut === 0) {
|
||
return [...symbolsOnly, entries[0]!, ...patched, ...entries.slice(1)].join(', ')
|
||
}
|
||
return [...entries.slice(0, cut), ...symbolsOnly, ...patched, ...entries.slice(cut)].join(', ')
|
||
}
|
||
|
||
/**
|
||
* Reduce one link of the base-family chain to a usable stack, or `''` when
|
||
* it cannot carry appended fallbacks so the next link should win.
|
||
*
|
||
* Rejecting CSS-wide keywords matters because the theme font arrives as a
|
||
* raw token value (`tokenValue('--ds-font-family-code')`); skins do set
|
||
* tokens to `initial`/`inherit`/`unset` (see `effectiveTokenValue` in
|
||
* `theme.ts`, which guards the color tokens for the same reason). Appending
|
||
* to such a value yields an invalid `font-family`, which the CSSOM discards
|
||
* silently — the terminal would lose the theme font *and* the icon fonts.
|
||
*/
|
||
function usableBase(value: string | undefined): string {
|
||
const trimmed = (value ?? '').trim()
|
||
if (trimmed === '') return ''
|
||
if (CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase())) return ''
|
||
return trimmed
|
||
}
|
||
|
||
/**
|
||
* Guarantee the stack ends in a generic family, so a font the browser cannot
|
||
* resolve degrades to a monospace one.
|
||
*
|
||
* Without this a stack of unresolvable names (a misspelled family, or one only
|
||
* installed on the machine running the shell rather than the one running the
|
||
* browser) falls through to the browser's *standard* font, which is
|
||
* proportional — xterm then measures its cell from a proportional advance and
|
||
* the whole grid breaks, rather than merely losing the requested typeface.
|
||
*
|
||
* This does not sniff whether the named families exist (an explicit non-goal
|
||
* of the terminal font design): it only terminates the stack. A stack that
|
||
* already names a generic family is returned untouched, so a user who
|
||
* deliberately wrote one keeps it. Generics are matched unquoted only — a
|
||
* quoted `"monospace"` is a family *name*, not the keyword.
|
||
*
|
||
* @param stack - the resolved font-family value.
|
||
* @returns the stack, ending in a generic family.
|
||
*/
|
||
export function withMonospaceFallback(stack: string): string {
|
||
const families = splitFamilies(stack)
|
||
if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY
|
||
const only = families.length === 1 ? families[0] : undefined
|
||
if (only !== undefined && CSS_WIDE_KEYWORDS.has(only.toLowerCase())) return DEFAULT_TERMINAL_FONT_FAMILY
|
||
if (families.some((family) => GENERIC_FAMILIES.has(family.toLowerCase()))) return families.join(', ')
|
||
return `${families.join(', ')}, monospace`
|
||
}
|
||
|
||
/**
|
||
* Resolve the xterm font options for the given prefs.
|
||
*
|
||
* The base family keeps its existing precedence — user pref > theme code
|
||
* font > built-in stack. {@link withMonospaceFallback} then terminates the
|
||
* stack with a generic family so an unresolvable name degrades to a
|
||
* monospace instead of the proportional standard font, and finally
|
||
* {@link withIconFontFallbacks} tops it up so prompt icons resolve
|
||
* regardless of which base won.
|
||
*
|
||
* @param prefs - the current side card preferences.
|
||
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
|
||
* token value, read live by the caller); undefined when the token is absent.
|
||
* @returns the `fontFamily` / `fontSize` xterm options.
|
||
*/
|
||
export function resolveTerminalFont(
|
||
prefs: SidebarPrefs,
|
||
themeFontFamily: string | undefined,
|
||
): { fontFamily: string; fontSize: number } {
|
||
const base = usableBase(prefs.terminalFontFamily)
|
||
|| usableBase(themeFontFamily)
|
||
|| DEFAULT_TERMINAL_FONT_FAMILY
|
||
return {
|
||
fontFamily: withIconFontFallbacks(withMonospaceFallback(base)),
|
||
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
|
||
}
|
||
}
|