Files
OpenTerminal/tests/font-stack.mjs
T
Bill cee616a23a
CI / typecheck + test + build (windows) (push) Canceled after 0s
feat: 字体与高亮系统改进(CJK 对齐、ReDoS 防护、链接规则、崩溃类设置修复)
基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。

## 修复的真实 bug

**设置越界导致窗格永久死亡(用户报告)**
行高设 0.95 → xterm 构造/热更抛错 → 错误边界卸载整个 dockview 窗格;坏值已
落盘,每次重启都崩。根因是一整类缺陷:UI 允许值 / 配置能写值 与 xterm 硬约束
不一致,且主进程不校验。同类还有 scrollback<0(抛错)、cursorStyle 非法值
(更隐蔽:构造期被吞掉、只有热更期抛错,导致「重试」按钮永远无效、窗格死循环)。

修复:FONT_LIMITS/CURSOR_STYLES/RENDERER_MODES 收拢到 shared 层单一来源,
主进程加载时钳制 + 白名单化,healClampedFontSettings() 回写自愈;设置页
InputNumber、字体热键共用同一常量(照 LOCK_AUTO_DELAYS 先例,杜绝再次漂移)。

**perm 预设锚定失效**
^[-dlbcps]... 的 ^ 锚定的是「转义序列之间的 text run 起点」而非行首,ls -l
多行输出只有第一行权限位上色;带颜色的 ls(run 以转义切开)完全不触发。
改为 (?<=^|\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])——用负向前瞻而不是
(?=\s|$) 是因为后者会让带 ACL `+` / macOS `@` 后缀的真实 ls -l 输出不再染色。
旧 pattern 登记进 LEGACY_BUILTIN_PATTERNS,存量安装下次启动自动升级。

**高亮 span 吃掉应用自身 SGR**
wrap 固定追加 \x1b[0m,把应用设的颜色一并清掉。改为 SgrState 跟踪并重放。
四条护栏(每条都踩过坑,详见 AGENTS.md):
- reset 判定解析参数:ESC[00m/ESC[0;0m/ESC[0;32m(Go/Java 日志库常写)原先
  被追加进累积器,逐行增长直到撞上限,之后所有 span 恢复永久失效
- 回放闸门按序列条数而非字节数:同一个"单个前景色"长度差近 4 倍,按字节卡
  会砍掉真彩色却放过索引色
- 恢复串只插在可见间隙和最后一个 span 后:每 span 都重放会让 Kimi 这类全彩
  TUI(~19 span/行)把 9.5KB 输出膨胀到 40.8KB(4.30x),渲染线程实测卡
  18.4 秒。现为 3.05x 且长间隙着色正常
- 基准测试守门(膨胀 < 3.2x),改参数必须重跑

**CSI 私有参数导致整行失去高亮**
ESCAPE_RE 漏了 <>= 前缀与中间字节,\x1b[>4;1m(modifyOtherKeys)、
\x1b[<0;1;2M(SGR 鼠标)、\x1b[=1;1$y(DECRQM) 让整行成含裸 ESC 的文本,该行
之后所有关键词失去高亮(vim/tmux 常发)。

**超长行拖垮整段高亮**:一条 >4KB 的行让整个 text run 失去高亮。改为逐行降级。

**统计「最后写入者赢」**:模块级 totals 被各 pane 覆盖,设置页数据在窗格间
跳动。改为按 pane 实例 UUID 聚合(不能用 sessionId——SSH 分屏同一 session 挂
两个 pane,正好复制该 bug)。

## 新增能力

- **CJK 对齐三件套**:@xterm/addon-unicode11@0.10.0-beta.301(peer 恰好接受
  锁定的 6.1.0-beta.304,升 xterm 必须同步重配)、rescaleOverlappingGlyphs、
  cjkFontFamily 回退槽位。默认字体栈没有任何 CJK 字体,中文靠 Chromium 回退
  到比例字体导致列错位
- **字体名引号包裹**:quoteFontStack() 逐段判定(含反斜杠转义——只转义 " 会
  让尾部反斜杠把闭合引号转义掉,CSS 字符串不终止)
- **等宽徽标**:canvas measureText 逐字符采样,等宽排前
- **规则按行匹配**(lineAnchor → m flag)
- **命中提醒**(notify + onMatch 事件出口,60s 冷却,只显示规则名不显示原文)
- **可点击链接规则**(WezTerm hyperlink_rules 式,安全闸门在生成 span 时就
  丢弃非 http/https 目标)

## 其它

- 主题语义变量改为单一命名体系:semanticVars() 键就是最终 CSS 属性名,字面量
  键联合让拼错成为编译错误;删除 cssVarName()/semanticCssVars() 中间层
  (自定义属性名大小写敏感,转换被绕过会静默回落到深色兜底值)
- 删除死代码:automaticFontFallback 与顶层 overviewRuler 在 6.1.0-beta.304
  中均不存在,且移除了让任何伪造键都能通过 tsc 的 as 断言
- ReDoS 防护(MAX_PATTERN_LEN + 编辑器 backtracksBadly 实时探测)——注意这
  只是止血,(a+)+$ 配 30 字符实测仍卡 8.2 秒,根治需把正则执行挪进 Worker

测试 29 → 32(新增 font-stack、link-rules),全部通过;AGENTS.md 已同步。
2026-10-17 20:23:56 +08:00

153 lines
5.5 KiB
JavaScript

/**
* Font-family quoting self-test (font-stack.mjs).
*
* The font enumerator hands back BARE family names (`disableQuoting: true`), and
* the picked name is stored verbatim in `settings.terminal.fontFamily` — one name
* or a hand-written stack. Passing that straight to xterm's `fontFamily` or to the
* settings preview's inline style only works because parsers are lenient: a name
* with `(` `)` `+`, one starting with a digit, or one that collides with a CSS
* keyword is not a valid CSS family. `quoteFontStack` fixes that at the consumer
* sites, so the contract pinned here is:
*
* - plain identifiers and the family keywords stay bare (quoting the keywords
* would turn `monospace` into a font literally named "monospace");
* - anything else is double-quoted, inner double quotes escaped;
* - splitting is on TOP-LEVEL commas only — a comma inside `'Name, with comma'`
* or `"Name"` is part of that family, not a separator;
* - already-quoted segments survive untouched, which makes the function
* idempotent: second pass over already-processed output is a no-op;
* - empty/blank input is the empty string (not `"\"\""`), so a cleared setting
* does not become a font family named "".
*
* Direct .ts import (no esbuild bundle needed): the module has no imports and no
* runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/font-stack.mjs (must exit 0)
*/
import { quoteFontStack } from '../src/renderer/src/terminal/fontStack.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/** Table-driven: [input, expected]. */
const table = (label, cases) => {
console.log(label)
for (const [input, expected] of cases) {
const got = quoteFontStack(input)
ok(
got === expected,
`${JSON.stringify(input)} -> ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`
)
}
}
table('plain identifiers and keywords are left alone', [
['Consolas', 'Consolas'],
['JetBrainsMono', 'JetBrainsMono'],
['DejaVu_Sans', 'DejaVu_Sans'],
['-apple-system', '-apple-system'],
['monospace', 'monospace'],
['serif', 'serif'],
['sans-serif', 'sans-serif'],
['cursive', 'cursive'],
['fantasy', 'fantasy'],
['system-ui', 'system-ui'],
['ui-monospace', 'ui-monospace'],
['default', 'default'],
['inherit', 'inherit'],
['initial', 'initial'],
['unset', 'unset'],
['Monospace', 'Monospace'],
// a keyword mixed into a stack stays bare while the neighbours are judged apart
['Consolas,monospace', 'Consolas, monospace']
])
table('names CSS cannot parse bare get double quotes', [
['Cascadia Code (Preview)', '"Cascadia Code (Preview)"'],
['Fira Code + Ligatures', '"Fira Code + Ligatures"'],
['4uto', '"4uto"'],
['.SF NS Mono', '".SF NS Mono"'],
['微軟雅黑', '"微軟雅黑"'],
['微软雅黑', '"微软雅黑"'],
['MS ゴシック', '"MS ゴシック"'],
['My Font!', '"My Font!"'],
['IntelliJ IDEA-like', '"IntelliJ IDEA-like"'],
['name{with}braces', '"name{with}braces"']
])
table('existing quoting is preserved', [
["'JetBrains Mono'", "'JetBrains Mono'"],
['"JetBrains Mono"', '"JetBrains Mono"'],
["Consolas, 'Fira Code', monospace", 'Consolas, \'Fira Code\', monospace'],
['"Noto Sans CJK SC", sans-serif', '"Noto Sans CJK SC", sans-serif']
])
table('only top-level commas separate families', [
["'Name, with comma'", "'Name, with comma'"],
['"Name, with comma", monospace', '"Name, with comma", monospace'],
['"A, B"', '"A, B"'],
["'A, B', 'C, D'", "'A, B', 'C, D'"],
// unquoted commas are separators, so this is two families, not one
['Name, with comma', 'Name, "with comma"']
])
table('segments are trimmed and empty ones dropped', [
[' Consolas , monospace ', 'Consolas, monospace'],
['Consolas,,monospace', 'Consolas, monospace'],
[',Consolas,', 'Consolas'],
['Consolas , , monospace', 'Consolas, monospace'],
[' Cascadia Code (Preview) ', '"Cascadia Code (Preview)"'],
['', ''],
[' ', ''],
[',', ''],
[' , , ', '']
])
table('inner double quotes are escaped, not nested', [
['Say "Hi"', '"Say \\"Hi\\""'],
['"Already" trailing', '"\\"Already\\" trailing"']
])
// Backslashes must be escaped too: escaping only `"` turned a name with a
// trailing backslash into `"a\"`, whose closing quote is itself escaped — the
// CSS string never terminates and the font silently falls back to the browser
// default. Every produced segment must therefore end with an EVEN run of
// backslashes before its closing quote.
table('backslashes are escaped so the CSS string stays terminated', [
['a\\', '"a\\\\"'],
['\\', '"\\\\"'],
['Font Name\\', '"Font Name\\\\"'],
['x\\"; color:red', '"x\\\\\\"; color:red"']
])
console.log('quoting is idempotent')
const stacks = [
'Consolas',
'Cascadia Code (Preview)',
'Consolas, Cascadia Code (Preview), monospace',
"Consolas, 'Fira Code', monospace",
'"Name, with comma", monospace',
'微软雅黑',
''
]
for (const stack of stacks) {
const once = quoteFontStack(stack)
ok(
quoteFontStack(once) === once,
`${JSON.stringify(stack)} survives a second pass (${JSON.stringify(once)})`
)
}
console.log('the stored setting is the caller\'s problem (input is never mutated)')
const original = 'Cascadia Code (Preview)'
quoteFontStack(original)
ok(original === 'Cascadia Code (Preview)', 'the input string is untouched')
if (failed > 0) {
console.error(`\n[font-stack] ${failed} check(s) FAILED`)
}
console.log('\n[font-stack] ALL CHECKS PASSED')
process.exit(failed ? 1 : 0)