Files
apes-sidebar/tests/terminal-font.spec.ts
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

249 lines
13 KiB
TypeScript
Raw Permalink 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 tests: the custom font prefs (side card settings,
* 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, and the stack is terminated with
* a generic family so an unresolvable name degrades to a monospace instead
* of the proportional standard font.
*/
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,
ICON_FONT_FALLBACKS,
resolveTerminalFont,
withIconFontFallbacks,
withMonospaceFallback,
} from '../src/client/terminal-font.ts'
describe('clampTerminalFontSize', () => {
it('rounds and clamps into the 9–32 contract', () => {
expect(clampTerminalFontSize(13)).toBe(13)
expect(clampTerminalFontSize(15.6)).toBe(16)
expect(clampTerminalFontSize(5)).toBe(TERMINAL_FONT_SIZE_MIN)
expect(clampTerminalFontSize(40)).toBe(TERMINAL_FONT_SIZE_MAX)
})
})
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('keeps fully-patched fonts BEHIND a leading generic (they carry Latin and would become the base)', () => {
// A leading generic resolves, so prepending a fully-patched Nerd Font
// (which ships Latin glyphs) would make it xterm's measuring/base font
// and override the user/theme family precedence. Only the symbols-only
// patches (no Latin) may precede it.
const stack = withIconFontFallbacks('monospace')
expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"')).toBeLessThan(indexOfFamily(stack, 'monospace'))
expect(indexOfFamily(stack, '"Hack Nerd Font Mono"')).toBeGreaterThan(indexOfFamily(stack, 'monospace'))
expect(indexOfFamily(stack, '"JetBrainsMono Nerd Font Mono"')).toBeGreaterThan(indexOfFamily(stack, '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')
// The theme font stays the base (first) entry; the monospace tail keeps
// the grid alive, and icon fonts top it up.
expect(fontFamily).toBe(withIconFontFallbacks(withMonospaceFallback('var-theme-font')))
expect(fontFamily.startsWith('var-theme-font')).toBe(true)
expect(fontFamily.endsWith('monospace')).toBe(true)
expect(fontSize).toBe(13)
})
it('terminates a custom family that names no generic, keeping the grid monospaced', () => {
// The reported failure: a family installed only on the machine running the
// shell resolves to nothing in the browser, and without a generic tail the
// fallback is the proportional standard font (PingFang SC on macOS), whose
// advance xterm then measures the cell from.
const { fontFamily } = resolveTerminalFont(
{ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'Maple Mono NF CN' },
undefined,
)
expect(fontFamily.startsWith('Maple Mono NF CN')).toBe(true)
expect(fontFamily.endsWith('monospace')).toBe(true)
})
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(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
// A whitespace-only pref counts as empty (theme default).
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily)
.toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
})
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(withMonospaceFallback('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(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"')
expect(resolved.fontFamily.endsWith('monospace')).toBe(true)
}
})
})
describe('withMonospaceFallback', () => {
it('appends a monospace tail when the stack names no generic family', () => {
expect(withMonospaceFallback('"Maple Mono NF CN"')).toBe('"Maple Mono NF CN", monospace')
expect(withMonospaceFallback('Menlo, Consolas')).toBe('Menlo, Consolas, monospace')
})
it('leaves a stack that already names a generic family untouched', () => {
expect(withMonospaceFallback('"JetBrains Mono", monospace')).toBe('"JetBrains Mono", monospace')
expect(withMonospaceFallback('ui-monospace, "SF Mono"')).toBe('ui-monospace, "SF Mono"')
// A deliberate non-monospace generic is the user's call, not ours to override.
expect(withMonospaceFallback('"Foo", sans-serif')).toBe('"Foo", sans-serif')
})
it('matches generic families case-insensitively and only when unquoted', () => {
expect(withMonospaceFallback('"Foo", MONOSPACE')).toBe('"Foo", MONOSPACE')
// A quoted name merely containing the word is a family, not a generic.
expect(withMonospaceFallback('"My monospace Font"')).toBe('"My monospace Font", monospace')
})
it('splits on top-level commas only, keeping quoted names and var() intact', () => {
expect(withMonospaceFallback('"Foo, Bar"')).toBe('"Foo, Bar", monospace')
expect(withMonospaceFallback('var(--code, Menlo)')).toBe('var(--code, Menlo), monospace')
expect(withMonospaceFallback("'Esc\\'aped, Name'")).toBe("'Esc\\'aped, Name', monospace")
})
it('drops the empty entries a stray comma produces', () => {
expect(withMonospaceFallback('"Foo",')).toBe('"Foo", monospace')
expect(withMonospaceFallback(' "Foo" ,, Menlo ')).toBe('"Foo", Menlo, monospace')
})
it('is idempotent', () => {
const once = withMonospaceFallback('"Maple Mono NF CN"')
expect(withMonospaceFallback(once)).toBe(once)
expect(withMonospaceFallback(DEFAULT_TERMINAL_FONT_FAMILY)).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
})
it('falls back to the built-in stack for an empty value or a CSS-wide keyword', () => {
// Appending to a CSS-wide keyword yields invalid CSS the CSSOM drops
// wholesale, which would leave xterm with no font at all.
expect(withMonospaceFallback('')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
expect(withMonospaceFallback(' , ')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
expect(withMonospaceFallback('inherit')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
expect(withMonospaceFallback('REVERT-LAYER')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
// Only a lone keyword is one; as part of a stack it is just a family name.
expect(withMonospaceFallback('inherit, Menlo')).toBe('inherit, Menlo, monospace')
})
})