fix(terminal): 补全 Nerd Font 图标字体回退,修复提示符图标显示为豆腐块

starship / powerlevel10k 等提示符的图标取自 Nerd Font 私有区。Chromium 的
隐式系统字体回退能覆盖 BMP PUA(U+E000–U+F8FF,如 U+E0B0 powerline 分隔符),
但覆盖不到补充平面 PUA-B(U+F0000+)——Nerd Fonts v3 把 Material Design 图标集
迁到了这里,于是这部分图标渲染为豆腐块。经排查用户字体本身含完整字形(format-12
cmap 覆盖上述码点),而 macOS 系统等宽字体(Menlo / SF Mono / Monaco)对这些
码点零覆盖,根因是插件的 fontFamily 栈未显式列出任何 Nerd Font。

修复:新增 withIconFontFallbacks(),在 resolveTerminalFont 出口为胜出的基础
字体追加 Nerd Font 图标族(显式命名可使浏览器逐字符查询,两个平面都能命中)。
基础字体优先级链(用户 pref > 主题 > 内置栈)保持不变。

三条约束:
- 只追加不前置:xterm 用字体栈首项测量单元格宽度,基础字体须留在最前;
- 插入到第一个通用族(monospace 等)之前:通用族是 catch-all,其后的字体
  永不被查询,仅判断尾项会让主题给出的 `ui-monospace, Menlo, monospace, …`
  这类栈静默失效;
- 按族名去重(引号/大小写/空白不敏感),用户自列的字体保持原有优先级。

另外:
- 过滤 CSS 全局关键字(inherit/initial/unset/revert/revert-layer)。主题字体
  取自原始 token 值,皮肤确实会设成这类值,追加后会得到非法 font-family 被
  CSSOM 静默丢弃,导致主题字体与图标字体一起失效,现改为回退到下一级;
- 字体栈按顶层逗号切分并感知引号与括号,避免拆坏 "Foo,Bar" 与 var(--x, y);
- 不列彩色 emoji 字体:本问题的图标全为 PUA 且真 emoji 有独立回退路径,而
  彩色字体会捕获提示符依赖等宽的 BMP 符号(U+26A0、U+2714 等)破坏网格。

单测 12 → 19 例,补齐中间通用族回归守卫、CSS 全局关键字、多通用族、幂等性与
回退顺序契约;全量 594 例通过。
This commit is contained in:
ShelbyTang
2026-08-18 10:50:30 +08:00
parent 6ee1ba11d0
commit dd5a3dda07
3 changed files with 321 additions and 8 deletions
+135 -6
View File
@@ -3,10 +3,19 @@
* terminal card) resolve into xterm options with a strict fallback chain —
* user family > theme code font > the built-in monospace stack, and the size
* clamped into the 9–32 contract.
*
* On top of the base family, icon fonts (Nerd Font + color emoji) are
* appended so shell prompts drawing from the Private Use Areas render real
* glyphs instead of the missing-glyph box.
*/
import { describe, expect, it } from 'vitest'
import { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../src/prefs-shared.ts'
import { DEFAULT_TERMINAL_FONT_FAMILY, resolveTerminalFont } from '../src/client/terminal-font.ts'
import {
DEFAULT_TERMINAL_FONT_FAMILY,
ICON_FONT_FALLBACKS,
resolveTerminalFont,
withIconFontFallbacks,
} from '../src/client/terminal-font.ts'
describe('clampTerminalFontSize', () => {
it('rounds and clamps into the 9–32 contract', () => {
@@ -17,26 +26,146 @@ describe('clampTerminalFontSize', () => {
})
})
describe('withIconFontFallbacks', () => {
/** Index of a family in a resolved stack, -1 when absent. */
function indexOfFamily(stack: string, family: string): number {
return stack.split(',').map(part => part.trim()).indexOf(family)
}
it('keeps the base font first so xterm measures cells against it', () => {
const stack = withIconFontFallbacks('"JetBrains Mono", monospace')
expect(stack.startsWith('"JetBrains Mono"')).toBe(true)
})
it('splices icon fonts ahead of a trailing generic family (a generic always resolves)', () => {
const stack = withIconFontFallbacks('"JetBrains Mono", monospace')
const parts = stack.split(',').map(part => part.trim())
expect(parts[parts.length - 1]).toBe('monospace')
expect(parts.indexOf('"Symbols Nerd Font Mono"')).toBeGreaterThan(0)
expect(parts.indexOf('"Symbols Nerd Font Mono"')).toBeLessThan(parts.length - 1)
})
it('splices ahead of a generic sitting MID-stack, not just a trailing one', () => {
// Regression guard: a generic anywhere is a catch-all, so appending
// after it silently defeats the whole fix (the icon fonts would never
// be consulted). Stacks of this shape come from theme tokens.
const stack = withIconFontFallbacks('ui-monospace, Menlo, monospace, "Apple Color Emoji"')
expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"'))
.toBeLessThan(indexOfFamily(stack, 'ui-monospace'))
})
it('splices ahead of the FIRST generic when several are present', () => {
const stack = withIconFontFallbacks('Menlo, monospace, sans-serif')
const nerd = indexOfFamily(stack, '"Symbols Nerd Font Mono"')
expect(nerd).toBeGreaterThan(indexOfFamily(stack, 'Menlo'))
expect(nerd).toBeLessThan(indexOfFamily(stack, 'monospace'))
expect(indexOfFamily(stack, 'sans-serif')).toBe(stack.split(',').length - 1)
})
it('appends at the end when the stack has no generic family', () => {
const stack = withIconFontFallbacks('"JetBrains Mono"')
expect(stack.startsWith('"JetBrains Mono", "Symbols Nerd Font Mono"')).toBe(true)
})
it('orders symbols-only patches ahead of fully-patched distributions', () => {
// Order IS the priority contract: the symbols-only families carry no
// Latin glyphs, so they can never hijack ASCII cell metrics.
const stack = withIconFontFallbacks('Menlo, monospace')
expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"'))
.toBeLessThan(indexOfFamily(stack, '"Hack Nerd Font Mono"'))
})
it('names Nerd Font families explicitly to cover both PUA planes', () => {
const stack = withIconFontFallbacks('Menlo, monospace')
expect(stack).toContain('"Symbols Nerd Font Mono"')
expect(stack).toContain('"Hack Nerd Font Mono"')
})
it('omits color-emoji families (Chromium routes emoji through its own fallback)', () => {
// Listing a color font ahead of the generic would let it capture BMP
// symbols the prompt expects in monospace and break the cell grid.
const stack = withIconFontFallbacks('Menlo, monospace')
expect(stack).not.toContain('Apple Color Emoji')
expect(stack).not.toContain('Segoe UI Emoji')
expect(stack).not.toContain('Noto Color Emoji')
})
it('does not duplicate a family the caller already listed, preserving its priority', () => {
const stack = withIconFontFallbacks("'hack nerd font mono', monospace")
expect(stack.toLowerCase().split('hack nerd font mono').length - 1).toBe(1)
// The caller's entry keeps pole position.
expect(stack.startsWith("'hack nerd font mono'")).toBe(true)
})
it('treats quote, case and whitespace differences as the same family', () => {
const stack = withIconFontFallbacks('"Symbols Nerd Font Mono", monospace')
expect(stack.toLowerCase().split('symbols').length - 1).toBe(2) // Mono + proportional
})
it('is idempotent so TerminalView\'s option diff does not thrash', () => {
const once = withIconFontFallbacks('Menlo, monospace')
expect(withIconFontFallbacks(once)).toBe(once)
})
it('splits on top-level commas only, keeping quoted and function values whole', () => {
expect(withIconFontFallbacks('"Foo,Bar", monospace')).toContain('"Foo,Bar"')
expect(withIconFontFallbacks('var(--code, monospace)')).toContain('var(--code, monospace)')
})
it('falls back to the icon list alone for an empty stack (defensive)', () => {
expect(withIconFontFallbacks('')).toBe(ICON_FONT_FALLBACKS.join(', '))
expect(withIconFontFallbacks(' , ')).toBe(ICON_FONT_FALLBACKS.join(', '))
})
})
describe('resolveTerminalFont', () => {
it('falls back to the theme code font when the family pref is empty', () => {
const { fontFamily, fontSize } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font')
expect(fontFamily).toBe('var-theme-font')
// The theme font stays the base (first) entry; icon fonts top it up.
expect(fontFamily).toBe(withIconFontFallbacks('var-theme-font'))
expect(fontFamily.startsWith('var-theme-font')).toBe(true)
expect(fontSize).toBe(13)
})
it('falls back to the built-in monospace stack when neither the pref nor the theme provides one', () => {
expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily)
.toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
// A whitespace-only pref counts as empty (theme default).
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily)
.toBe(DEFAULT_TERMINAL_FONT_FAMILY)
.toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
})
it('prefers the custom family verbatim and clamps the size', () => {
it('skips a CSS-wide keyword instead of building an invalid declaration', () => {
// Skins do set tokens to these; appending to one yields an invalid
// font-family that the CSSOM discards, losing every font at once.
for (const keyword of ['inherit', 'initial', 'unset', 'revert', 'REVERT-LAYER']) {
const { fontFamily } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, keyword)
expect(fontFamily).toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
expect(fontFamily.toLowerCase()).not.toContain(keyword.toLowerCase())
}
// Same guard on the user pref, which then defers to the theme font.
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'inherit' }, 'var-theme-font').fontFamily)
.toBe(withIconFontFallbacks('var-theme-font'))
})
it('prefers the custom family as the base and clamps the size', () => {
const { fontFamily, fontSize } = resolveTerminalFont(
{ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: '"JetBrains Mono", monospace', terminalFontSize: 40 },
'var-theme-font',
)
expect(fontFamily).toBe('"JetBrains Mono", monospace')
expect(fontFamily).toBe(withIconFontFallbacks('"JetBrains Mono", monospace'))
expect(fontFamily.startsWith('"JetBrains Mono"')).toBe(true)
expect(fontFamily).not.toContain('var-theme-font')
expect(fontSize).toBe(TERMINAL_FONT_SIZE_MAX)
})
it('applies icon fallbacks no matter which base family wins', () => {
for (const resolved of [
resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font'),
resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined),
resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'Menlo' }, undefined),
]) {
expect(resolved.fontFamily).toContain('"Symbols Nerd Font Mono"')
}
})
})