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 即可。
This commit is contained in:
committed by
Menghuan1918
parent
2088ef6891
commit
0504e3918b
@@ -198,12 +198,43 @@ function usableBase(value: string | undefined): string {
|
||||
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 — and then {@link withIconFontFallbacks} tops it up
|
||||
* so prompt icons resolve regardless of which base won.
|
||||
* 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`
|
||||
@@ -218,7 +249,7 @@ export function resolveTerminalFont(
|
||||
|| usableBase(themeFontFamily)
|
||||
|| DEFAULT_TERMINAL_FONT_FAMILY
|
||||
return {
|
||||
fontFamily: withIconFontFallbacks(base),
|
||||
fontFamily: withIconFontFallbacks(withMonospaceFallback(base)),
|
||||
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
*
|
||||
* 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.
|
||||
* 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'
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
ICON_FONT_FALLBACKS,
|
||||
resolveTerminalFont,
|
||||
withIconFontFallbacks,
|
||||
withMonospaceFallback,
|
||||
} from '../src/client/terminal-font.ts'
|
||||
|
||||
describe('clampTerminalFontSize', () => {
|
||||
@@ -132,12 +135,27 @@ describe('withIconFontFallbacks', () => {
|
||||
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; icon fonts top it up.
|
||||
expect(fontFamily).toBe(withIconFontFallbacks('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))
|
||||
@@ -156,7 +174,7 @@ describe('resolveTerminalFont', () => {
|
||||
}
|
||||
// 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'))
|
||||
.toBe(withIconFontFallbacks(withMonospaceFallback('var-theme-font')))
|
||||
})
|
||||
|
||||
it('prefers the custom family as the base and clamps the size', () => {
|
||||
@@ -177,6 +195,54 @@ describe('resolveTerminalFont', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user