Files
apes-sidebar/src/client/terminal-font.ts
T
GeekRicardo 0504e3918b fix(terminal): 自定义字体解析失败时兜底到等宽,避免掉进比例字体毁掉网格
在设置页「侧边卡片」→ 终端卡片填入自定义「终端字体」后,若该族名在浏览器里
解析不到,终端不是退化成"没有想要的字体",而是整个网格崩掉:字符间距被撑开、
字形变成比例字形、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 即可。
2026-08-29 15:49:35 +08:00

256 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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),
}
}