# 字体方案:中文优先与 0.5 窄体等宽 > 依据《终端排版哲学:中文优先与 0.5 窄体等宽工程实战》落地。本文记录**本机实测数据**、 > 选型理由,以及插件里对应的实现位置 —— 换机器 / 换字族 / 官方升级后照本文即可复核。 ## 1. 问题:西文基准下的几何死结 网格排版里汉字固定是 2 个半角单元(1:2),而汉字本身是 `1.0 : 1.0` 的正方。若以西文为基准: - 西文单元宽 `W ≈ 0.6`(Menlo / JetBrains Mono / Fira Code / Maple Mono…) - 汉字容器被撑到 `2W ≈ 1.2` 于是汉字只有两种下场:**被横向拉宽 20%**(矮胖、字怀塌陷),或**两边留白**(中文词句涣散)。 反过来,只要把西文收窄到严格 `0.5`,就有 `2 × 0.5 = 1.0`,与正方汉字无缝咬合。 ## 2. 本机实测(unitsPerEm 归一化后的字形前沿宽度) 测法:`fontTools` 读 `cmap` + `hmtx`,取 `advance / unitsPerEm`。复现脚本见 §6。 | 字族 | 西文 `a` | 汉字 `中` | `CJK/Latin` | `—` U+2014 | `→` U+2192 | `★` U+2605 | |------|---------|----------|-------------|-----------|-----------|-----------| | **Sarasa Mono SC** | 0.500 | 1.000 | **2.000** | 1.000 | 1.000 | 1.000 | | **Sarasa Term SC** | 0.500 | 1.000 | **2.000** | 0.500 | 0.500 | 0.500 | | Sarasa Fixed SC | 0.500 | 1.000 | **2.000** | 0.500 | 0.500 | 0.500 | | Noto Sans Mono CJK SC | 0.500 | 1.000 | **2.000** | 0.894 | 1.000 | 1.000 | | Sarasa Gothic SC | 0.562 | 1.000 | 1.78 | 1.000 | 0.954 | 1.045 | | Sarasa UI SC | 0.562 | 1.000 | 1.78 | 0.894 | 0.954 | 1.045 | | Maple Mono NF CN | 0.600 | **1.200** | 2.000 | 0.600 | 0.600 | — | | Maple Mono(非 CN) | 0.600 | 无字形 | — | 0.600 | 0.600 | — | 结论: 1. **只有 Sarasa Mono/Term/Fixed 与 Noto Sans Mono CJK 满足 0.5 规则。** Sarasa Gothic / UI 是比例西文(0.562),不满足,故不作为方案。 2. **Maple Mono NF CN 的 2.000 是"假达标"**:它把汉字放大到 1.200 去迁就 0.600 的西文 —— 比值对上了,汉字却被拉宽 20%,正是文章要避开的那一类。 3. `Sarasa Term` 与 `Sarasa Mono` 的差别**只在东亚歧义字符**(U+2014/U+2026/U+2192/U+2605 等): Mono 保持全角(适合读 Markdown、中文标点),Term 压成半角(适合密集网格、跳格最稳)。 制表符 `┌│─` 两者都是单格,所以方框在哪一档都是严丝合缝的。 字面(AppKit `availableMembersOfFontFamily` 实测): | 字族 | 可用字重 | |------|----------| | Sarasa Mono / Term SC | ExtraLight, Light, **Regular**, **SemiBold**, **Bold**(+Italic) | | Maple Mono NF CN | Thin…Medium(500)、**SemiBold(600)**、Bold、ExtraBold(11) | | Noto Sans Mono CJK SC | **Regular**、**Bold**(仅此两个) | 所以「半粗」模式对 Sarasa / Maple 是真实的 SemiBold;**对 Noto 会直接落到 Bold**(偏重,但不合成)。 ## 3. 落地实现 | 位置 | 作用 | |------|------| | `lib/client.js` → `FONT_SCHEMES` | 方案表(含候选族名,探测用)与 `FONT_STACKS` 家族栈 | | `lib/client.js` → `FONT_CSS` | `data-dsh-ux-font` 闸门 → `--dshx-font-family`;`--dshx-w-a/b/c/d` 四档字重变量;官方字体令牌全部改写成 `var(...)`;`font-synthesis: none` | | `lib/client.js` → `familyInstalled` | canvas 量宽探测:以「不存在的族名 + monospace」为基线,拉丁/汉字各探一次取或 | | `lib/client.js` → `AlignSample` | 页面里的对齐自检块(文章 §5,已按 2:1 校齐列宽) | | `lib/index.js` → `ENUM_KEYS` | 首帧脚本按白名单恢复方案/字重,越界回落默认 | | `test/smoke.mjs` | 家族栈、字重变量、探测、白名单一致性、自检块列宽不变量 | 设计要点: - **默认栈挂在 `html:not([data-dsh-ux-font="off"])` 上**,方案栈写在后面按源序覆盖。 这样旧宿主半写下的 `data-dsh-ux-font="on"`(布尔语义)或任何未知取值都会落到「自动」栈, 而不是让 `--dshx-font-family` 悬空、把整条字体声明变成 invalid。 - **`font-synthesis: none`** 只在字体层打开时生效:文章 §3.1 的人工外扩加粗会挤占字怀、笔画黏连, 这里宁可换真实字面(§2 的表格保证 Sarasa / Maple 都有)。 - **字重四档**而不是两档:官方令牌里正文=400、强调=500、半粗=600、粗=700 四种取值, 半粗模式整体上抬一档(A→600、B→700、C→700、D→700),层级仍是「正文 < 强调」。 ## 4. 加新字族 / 换机器 1. 用 §6 的脚本量一遍候选字族的 `Latin` 与 `CJK` 宽度,**比值必须是 2.000**(即西文 0.5)。 2. 用 `osascript -l JavaScript` 确认 AppKit 暴露的族名(SuperTTC 的部分族名不会出现在 `system_profiler` 的输出里,必须用 AppKit 或浏览器实测,别靠猜): ```bash osascript -l JavaScript -e 'ObjC.import("AppKit"); ObjC.deepUnwrap($.NSFontManager.sharedFontManager.availableFontFamilies) .filter(function(n){return /Sarasa|Mono CJK/.test(n)}).join("\n")' ``` 3. 把族名加进 `FONT_SCHEMES` 的 `families` 与 `FONT_STACKS` 对应项,**两处白名单 (`lib/index.js` / `lib/client.js`)一起改**,然后 `node test/smoke.mjs` 应全绿。 4. 在设置页切到该方案,看对齐自检块:第 1 行 40 个半角字符与第 2 行 20 个汉字必须等宽, 方框必须是矩形。 ## 5. 已知边界 - 字体层依赖**本机已安装**这些字族;一款都没有时自动落回官方字体栈(功能不受影响,只是没了 0.5 网格)。 - 检测是启发式(canvas 量宽),字宽恰好与回退字体完全一致的极端情况会误判为"未安装" —— 页面上会显示 ✗,此时手工把族名加进方案即可。 - 汉字只覆盖简体(SC);日文/韩文汉字不在这些字族里时会回退到 PingFang / 系统字体,网格不再严格。 - 文章 §3.3 的"字号 13~14pt"对应终端场景;DSH 界面的字号由官方令牌决定,本插件只接管字族与字重, 不缩放字号 —— 想改字号是另一件事,动它会牵动整个界面几何。 ## 6. 复现测量 ```python # pip install fonttools; 用法: python3 measure.py <字体文件或 .ttc> from fontTools.ttLib import TTFont, TTCollection import sys PROBES = {'a':'latin','中':'cjk','—':'ambig','→':'ambig','★':'ambig'} def show(f, label): upem, cmap, hmtx = f['head'].unitsPerEm, f.getBestCmap(), f['hmtx'] w = {ch: (hmtx[cmap[ord(ch)]][0]/upem if ord(ch) in cmap else None) for ch in PROBES} ratio = (w['中']/w['a']) if w['a'] and w['中'] else None print(f"{label}: latin={w['a']} cjk={w['中']} ratio={ratio} dash={w['—']} arrow={w['→']} star={w['★']}") path = sys.argv[1] faces = TTCollection(path, lazy=True).fonts if path.lower().endswith('.ttc') else [TTFont(path, lazy=True)] for f in faces: n = f['name'] show(f, f"{n.getDebugName(16) or n.getDebugName(1)} / {n.getDebugName(17) or n.getDebugName(2)}") ```