fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口: P2-1 冻结测试补全(变异实测证明原来可静默漂移): - tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family) 与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写 - tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、 16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯 - 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED P2-2 emphasized 口径事实错误(用户点名): - 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组): label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、 title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0 - TypeRole::emphasized() 改为官方表(typography/emphasized.rs); 原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方 - 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、 title-medium 0.2)与 TitleLarge 的 Brand 族归属 - crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap 弹簧改为两套 token 结构(用户追加令): - motion::spring(scheme × category × speed) 二维表 12 格: standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、 effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值 - Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量 中文字重覆盖(用户追加令): - FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给 Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记 component_map 高程按官方回调(P2 并入 finding): - FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时 按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言 P3 四条: - 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma), primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变 - femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门 - 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档 - 图标折算口径订正为「居中描边,视觉外扩 <1px」
This commit is contained in:
1 parent
65ca0ed29a
commit
83e19e58f0
20 files changed
+1367
-234
No files matched your search
@@ -0,0 +1,88 @@
|
||||
//! 官方 M3 **Expressive** 强调排版表(15 档,A 级来源)。
|
||||
//!
|
||||
//! 来源:`androidx.compose.material3.tokens.TypeScaleTokens` v0_103 的
|
||||
//! `<Role>Emphasized{Size,LineHeight,Tracking,Weight}` 四元组。要点:
|
||||
//!
|
||||
//! - **字号与行高与 baseline 完全相同**(Expressive 的强调不改变排版高度);
|
||||
//! - 只动**字重**与**字距**:display/headline/title-large/body-* → Medium(500);
|
||||
//! title-medium / title-small / label-* → Bold(700)(baseline 是 500);
|
||||
//! - display-large 的字距从 −0.2 回 0;body-large 0.5 → 0.15;title-medium 0.2 → 0.15;
|
||||
//! body-medium 0.2 → 0.25(唯一一个「强调后字距变大」的档)。
|
||||
|
||||
use super::{style, FontFamily, TypeRole, TypeStyle, Weight};
|
||||
|
||||
/// 取某档的官方强调样式。非 [`TypeRole`] 的每个变体都有值(15/15,无缺口)。
|
||||
pub(super) const fn style_of(role: TypeRole) -> TypeStyle {
|
||||
match role {
|
||||
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||
TypeRole::HeadlineMedium => style(28.0, 36.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||
// TitleLarge 官方用 Brand 族(与 Display 同栈)。
|
||||
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Bold, 0.15, FontFamily::Text),
|
||||
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
|
||||
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
|
||||
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Medium, 0.25, FontFamily::Text),
|
||||
TypeRole::BodySmall => style(12.0, 16.0, Weight::Medium, 0.4, FontFamily::Text),
|
||||
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
|
||||
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
|
||||
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn emphasized_keeps_size_and_line_height() {
|
||||
for role in TypeRole::ALL {
|
||||
let base = role.style();
|
||||
let emph = style_of(role);
|
||||
assert_eq!(
|
||||
base.size_px,
|
||||
emph.size_px,
|
||||
"{} 强调档不该改字号",
|
||||
role.name()
|
||||
);
|
||||
assert_eq!(
|
||||
base.line_height_px,
|
||||
emph.line_height_px,
|
||||
"{} 强调档不该改行高",
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasized_weights_follow_the_official_split() {
|
||||
// 官方两档:正文/展示类 → Medium(500);小标题与标签类 → Bold(700)。
|
||||
for role in TypeRole::ALL {
|
||||
let expect = match role {
|
||||
TypeRole::TitleMedium
|
||||
| TypeRole::TitleSmall
|
||||
| TypeRole::LabelLarge
|
||||
| TypeRole::LabelMedium
|
||||
| TypeRole::LabelSmall => Weight::Bold,
|
||||
_ => Weight::Medium,
|
||||
};
|
||||
assert_eq!(
|
||||
style_of(role).weight,
|
||||
expect,
|
||||
"{} 强调档字重不对",
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_large_tracking_returns_to_zero() {
|
||||
assert_eq!(TypeRole::DisplayLarge.style().letter_spacing_px, -0.2);
|
||||
assert_eq!(TypeRole::DisplayLarge.emphasized().letter_spacing_px, 0.0);
|
||||
assert_eq!(TypeRole::BodyLarge.style().letter_spacing_px, 0.5);
|
||||
assert_eq!(TypeRole::BodyLarge.emphasized().letter_spacing_px, 0.15);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,37 @@
|
||||
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)。
|
||||
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)+ **字重覆盖**模型。
|
||||
//!
|
||||
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
|
||||
//! 否则干净安装的系统上会退化成豆腐块。
|
||||
//!
|
||||
//! ## 为什么要表达「这个族有哪几档字重」
|
||||
//!
|
||||
//! M3 Expressive 的强调档会要 **700**(title-medium/title-small/label-*)与 **500**
|
||||
//! (display/headline/body-*)。而中文字体的字重覆盖差别很大:
|
||||
//! **Windows 的 Microsoft YaHei UI 只有 400 / 700 两档**,没有 500;macOS 苹方到 600 就断了、
|
||||
//! 没有 700;只有 Linux 常见的 Noto Sans CJK / 思源黑体(可变字体)才 100~900 全覆盖。
|
||||
//!
|
||||
//! 所以栈项不是「一个族名」,而是 `(族名, 字重覆盖)`([`FontFace`]),并且提供一个纯函数
|
||||
//! [`Platform::resolve_weight`] 说明「这一档字重最终落到哪个族、是原生还是降级」:
|
||||
//!
|
||||
//! | 结果 | 含义 |
|
||||
//! |---|---|
|
||||
//! | [`WeightMatch::Native`] | 该族有这一档(可变字体在区间内也算原生) |
|
||||
//! | [`WeightMatch::Nearest`] | 该族没有这一档,取最近的静态档(视觉上是「淡一点/粗一点」,不假) |
|
||||
//! | [`WeightMatch::Synthetic`] | 最近的档差 ≥2 级(如只有 400 却要 700)→ 引擎**合成加粗**,观感廉价 |
|
||||
//!
|
||||
//! **合成加粗是降级,不是正常路径**(用户令:不许拿合成加粗当「还原到位」)。
|
||||
//! iced/cosmic-text 侧机制:`Font { family, weight, .. }` 由 cosmic-text 按 (family, weight)
|
||||
//! 选面,同族缺档时选最近字重;glyphon 另可用 swash 的合成(fake bold)兜底。
|
||||
//! 中文缺档时的降级顺序:**① 换下一个有该档的族 → ② 用可变字体的 weight 轴 → ③ 最近静态档
|
||||
//! → ④ 最后才是合成加粗**(并且不要用 700 排长中文正文)。
|
||||
//!
|
||||
//! ## 待确认(D28 联动,不在本 mission 拍板)
|
||||
//!
|
||||
//! 将来从 CDN 下载中文字体时,「一个 Regular 文件」不够——至少要 400/500/700 三档子集,
|
||||
//! 或者一个可变字体(体积更大)。这个差异登记在这里,等 D28 的字体下载 mission 决定。
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 平台(决定字体族回退栈)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -14,6 +44,179 @@ pub enum Platform {
|
||||
Linux,
|
||||
}
|
||||
|
||||
/// 字重覆盖(一个族实际提供了哪些字重)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WeightCoverage {
|
||||
/// 可变字体:一个连续区间(weight 轴)。
|
||||
Variable {
|
||||
/// 最细一档。
|
||||
min: u16,
|
||||
/// 最粗一档。
|
||||
max: u16,
|
||||
},
|
||||
/// 静态字体:离散字重表。
|
||||
Static(&'static [u16]),
|
||||
}
|
||||
|
||||
impl WeightCoverage {
|
||||
/// 是否原生覆盖某档字重。
|
||||
pub fn supports(&self, weight: u16) -> bool {
|
||||
match self {
|
||||
WeightCoverage::Variable { min, max } => (*min..=*max).contains(&weight),
|
||||
WeightCoverage::Static(list) => list.contains(&weight),
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近的可选字重(静态表取绝对值最近;可变字体夹取到区间内)。
|
||||
pub fn nearest(&self, weight: u16) -> u16 {
|
||||
match self {
|
||||
WeightCoverage::Variable { min, max } => weight.clamp(*min, *max),
|
||||
WeightCoverage::Static(list) => {
|
||||
let mut best = list[0];
|
||||
let mut best_delta = u16::MAX;
|
||||
for candidate in *list {
|
||||
let delta = candidate.abs_diff(weight);
|
||||
if delta < best_delta {
|
||||
best = *candidate;
|
||||
best_delta = delta;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条字体栈项:族名 + 字重覆盖。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FontFace {
|
||||
/// 族名(只写名字,不带路径/文件名)。
|
||||
pub family: &'static str,
|
||||
/// 该族提供的字重。
|
||||
pub weights: WeightCoverage,
|
||||
}
|
||||
|
||||
impl FontFace {
|
||||
/// 构造。
|
||||
pub const fn new(family: &'static str, weights: WeightCoverage) -> Self {
|
||||
Self { family, weights }
|
||||
}
|
||||
|
||||
/// 该族是否原生覆盖某档字重。
|
||||
pub fn supports(&self, weight: crate::typography::Weight) -> bool {
|
||||
self.weights.supports(weight.value())
|
||||
}
|
||||
}
|
||||
|
||||
/// 字重匹配的质量。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WeightMatch {
|
||||
/// 原生有这一档。
|
||||
Native,
|
||||
/// 同族最近档(差 <2 级)。
|
||||
Nearest,
|
||||
/// 差 ≥2 级,引擎只能合成加粗/减细(降级)。
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
/// 字重解析结果:最终用哪个族、哪个字重、是原生还是降级。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FontChoice {
|
||||
/// 选中的族名。
|
||||
pub family: &'static str,
|
||||
/// 请求的字重。
|
||||
pub requested: u16,
|
||||
/// 实际可用的字重。
|
||||
pub available: u16,
|
||||
/// 匹配质量。
|
||||
pub quality: WeightMatch,
|
||||
}
|
||||
|
||||
impl FontChoice {
|
||||
/// 是否走了合成加粗(降级路径)。
|
||||
pub fn is_synthetic(&self) -> bool {
|
||||
self.quality == WeightMatch::Synthetic
|
||||
}
|
||||
}
|
||||
|
||||
/// 字体族的用途分类(决定取哪条栈)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FontFamily {
|
||||
/// 大字号展示(display/headline/title-large):光学尺寸更大。
|
||||
Display,
|
||||
/// 正文(title/body/label)。
|
||||
Text,
|
||||
/// 等宽(日志、代码、版本号)。
|
||||
Mono,
|
||||
}
|
||||
|
||||
// 字重覆盖来源等级:**B**(各平台字体规格/厂商文档;没有一份官方 token 文件能一次给全,
|
||||
// 所以按「常见安装」标定,并用测试钉死——改值要连带改测试与协作文档)。
|
||||
const W_VARIABLE_SEGOE: WeightCoverage = WeightCoverage::Variable { min: 300, max: 700 };
|
||||
const W_VARIABLE_SF: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
|
||||
const W_VARIABLE_NOTO: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
|
||||
const W_VARIABLE_CASCADIA: WeightCoverage = WeightCoverage::Variable { min: 200, max: 700 };
|
||||
const W_VARIABLE_JETBRAINS: WeightCoverage = WeightCoverage::Variable { min: 100, max: 800 };
|
||||
const W_STATIC_SEGOE: WeightCoverage = WeightCoverage::Static(&[300, 400, 600, 700]);
|
||||
const W_STATIC_YAHEI: WeightCoverage = WeightCoverage::Static(&[400, 700]);
|
||||
const W_STATIC_HELVETICA: WeightCoverage = WeightCoverage::Static(&[300, 400, 500, 700]);
|
||||
const W_STATIC_PINGFANG: WeightCoverage = WeightCoverage::Static(&[100, 200, 300, 400, 500, 600]);
|
||||
const W_STATIC_MENLO: WeightCoverage = WeightCoverage::Static(&[400, 700]);
|
||||
const W_STATIC_DEJAVU: WeightCoverage = WeightCoverage::Static(&[400, 700]);
|
||||
|
||||
// 各平台字体栈(写成 const 数组,好让 `font_stack` 的 `const fn` 直接返回 `&'static [_]`)。
|
||||
const WIN_DISPLAY: [FontFace; 5] = [
|
||||
FontFace::new("Segoe UI Variable Display", W_VARIABLE_SEGOE),
|
||||
FontFace::new("Segoe UI", W_STATIC_SEGOE),
|
||||
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
|
||||
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
|
||||
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||
];
|
||||
const WIN_TEXT: [FontFace; 5] = [
|
||||
FontFace::new("Segoe UI Variable Text", W_VARIABLE_SEGOE),
|
||||
FontFace::new("Segoe UI", W_STATIC_SEGOE),
|
||||
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
|
||||
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
|
||||
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||
];
|
||||
const WIN_MONO: [FontFace; 4] = [
|
||||
FontFace::new("Cascadia Mono", W_VARIABLE_CASCADIA),
|
||||
FontFace::new("Consolas", W_STATIC_SEGOE),
|
||||
FontFace::new("Courier New", W_STATIC_MENLO),
|
||||
FontFace::new("monospace", W_STATIC_DEJAVU),
|
||||
];
|
||||
const MAC_DISPLAY: [FontFace; 4] = [
|
||||
FontFace::new("SF Pro Display", W_VARIABLE_SF),
|
||||
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
|
||||
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
|
||||
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||
];
|
||||
const MAC_TEXT: [FontFace; 4] = [
|
||||
FontFace::new("SF Pro Text", W_VARIABLE_SF),
|
||||
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
|
||||
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
|
||||
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||
];
|
||||
const MAC_MONO: [FontFace; 4] = [
|
||||
FontFace::new("SF Mono", W_VARIABLE_SF),
|
||||
FontFace::new("Menlo", W_STATIC_MENLO),
|
||||
FontFace::new("Monaco", W_STATIC_MENLO),
|
||||
FontFace::new("monospace", W_STATIC_DEJAVU),
|
||||
];
|
||||
const LINUX_SANS: [FontFace; 5] = [
|
||||
FontFace::new("Roboto", W_VARIABLE_NOTO),
|
||||
FontFace::new("Noto Sans", W_VARIABLE_NOTO),
|
||||
FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
|
||||
FontFace::new("DejaVu Sans", W_STATIC_DEJAVU),
|
||||
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||
];
|
||||
const LINUX_MONO: [FontFace; 4] = [
|
||||
FontFace::new("JetBrains Mono", W_VARIABLE_JETBRAINS),
|
||||
FontFace::new("Noto Sans Mono", W_VARIABLE_NOTO),
|
||||
FontFace::new("DejaVu Sans Mono", W_STATIC_DEJAVU),
|
||||
FontFace::new("monospace", W_STATIC_DEJAVU),
|
||||
];
|
||||
|
||||
impl Platform {
|
||||
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
|
||||
pub const fn current() -> Self {
|
||||
@@ -36,146 +239,66 @@ impl Platform {
|
||||
}
|
||||
|
||||
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
|
||||
///
|
||||
/// iced/cosmic-text 按顺序找第一个装了的族;`sans-serif` / `monospace` 是通用兜底,
|
||||
/// 保证「一台干净安装的系统」也不会渲染成豆腐块。
|
||||
pub const fn font_stack(self, family: FontFamily) -> &'static [&'static str] {
|
||||
pub const fn font_stack(self, family: FontFamily) -> &'static [FontFace] {
|
||||
match (self, family) {
|
||||
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
|
||||
(Platform::Windows, FontFamily::Display) => &[
|
||||
"Segoe UI Variable Display",
|
||||
"Segoe UI",
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::Windows, FontFamily::Text) => &[
|
||||
"Segoe UI Variable Text",
|
||||
"Segoe UI",
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::Windows, FontFamily::Mono) => {
|
||||
&["Cascadia Mono", "Consolas", "Courier New", "monospace"]
|
||||
}
|
||||
// macOS 12+:SF Pro 分 Display/Text 两种光学尺寸;中文走苹方。
|
||||
(Platform::MacOs, FontFamily::Display) => &[
|
||||
"SF Pro Display",
|
||||
"Helvetica Neue",
|
||||
"PingFang SC",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::MacOs, FontFamily::Text) => {
|
||||
&["SF Pro Text", "Helvetica Neue", "PingFang SC", "sans-serif"]
|
||||
}
|
||||
(Platform::MacOs, FontFamily::Mono) => &["SF Mono", "Menlo", "Monaco", "monospace"],
|
||||
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套。
|
||||
(Platform::Linux, FontFamily::Display) | (Platform::Linux, FontFamily::Text) => &[
|
||||
"Roboto",
|
||||
"Noto Sans",
|
||||
"Noto Sans CJK SC",
|
||||
"DejaVu Sans",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::Linux, FontFamily::Mono) => &[
|
||||
"JetBrains Mono",
|
||||
"Noto Sans Mono",
|
||||
"DejaVu Sans Mono",
|
||||
"monospace",
|
||||
],
|
||||
(Platform::Windows, FontFamily::Display) => &WIN_DISPLAY,
|
||||
(Platform::Windows, FontFamily::Text) => &WIN_TEXT,
|
||||
(Platform::Windows, FontFamily::Mono) => &WIN_MONO,
|
||||
// macOS 12+:SF Pro 分 Display/Text 光学尺寸;中文走苹方(只到 600)。
|
||||
(Platform::MacOs, FontFamily::Display) => &MAC_DISPLAY,
|
||||
(Platform::MacOs, FontFamily::Text) => &MAC_TEXT,
|
||||
(Platform::MacOs, FontFamily::Mono) => &MAC_MONO,
|
||||
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套(Display 与 Text 同栈)。
|
||||
(Platform::Linux, FontFamily::Display | FontFamily::Text) => &LINUX_SANS,
|
||||
(Platform::Linux, FontFamily::Mono) => &LINUX_MONO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字体族的用途分类(决定取哪条栈)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FontFamily {
|
||||
/// 大字号展示(display/headline):光学尺寸更大。
|
||||
Display,
|
||||
/// 正文(title/body/label)。
|
||||
Text,
|
||||
/// 等宽(日志、代码、版本号)。
|
||||
Mono,
|
||||
}
|
||||
/// 该平台的**中文**兜底族(CJK 字形实际落到的那个)。
|
||||
///
|
||||
/// 注意:栈里的 CJK 族只是「缺字形时的候选」,最终由文本引擎逐字形回退决定;
|
||||
/// 这个函数把它单独暴露出来,好让调用方按模块文档的降级顺序处理字重。
|
||||
pub const fn cjk_face(self) -> FontFace {
|
||||
match self {
|
||||
Platform::Windows => FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
|
||||
Platform::MacOs => FontFace::new("PingFang SC", W_STATIC_PINGFANG),
|
||||
Platform::Linux => FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
|
||||
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
|
||||
|
||||
#[test]
|
||||
fn font_stacks_are_well_formed() {
|
||||
for platform in PLATFORMS {
|
||||
for family in FAMILIES {
|
||||
let stack = platform.font_stack(family);
|
||||
assert!(
|
||||
!stack.is_empty(),
|
||||
"{} 的 {family:?} 栈为空",
|
||||
platform.name()
|
||||
);
|
||||
let last = stack[stack.len() - 1];
|
||||
let generic = match family {
|
||||
FontFamily::Mono => "monospace",
|
||||
_ => "sans-serif",
|
||||
/// 按栈顺序解析某档字重的落点(纯函数,不查系统)。
|
||||
///
|
||||
/// 规则:**优先原生**(栈里第一个真有的族)→ 都做不到时取首选族的最近档,
|
||||
/// 差距 ≥200 判为 [`WeightMatch::Synthetic`]。
|
||||
pub fn resolve_weight(
|
||||
self,
|
||||
family: FontFamily,
|
||||
weight: crate::typography::Weight,
|
||||
) -> FontChoice {
|
||||
let stack = self.font_stack(family);
|
||||
let requested = weight.value();
|
||||
for face in stack {
|
||||
if face.weights.supports(requested) {
|
||||
return FontChoice {
|
||||
family: face.family,
|
||||
requested,
|
||||
available: requested,
|
||||
quality: WeightMatch::Native,
|
||||
};
|
||||
assert_eq!(
|
||||
last,
|
||||
generic,
|
||||
"{} 的 {family:?} 栈必须以通用族名兜底",
|
||||
platform.name()
|
||||
);
|
||||
for name in stack {
|
||||
assert!(!name.is_empty());
|
||||
assert!(
|
||||
!name.contains(['/', '\\', '.', ':']),
|
||||
"字体栈只允许族名,不得出现路径/文件名:{name}"
|
||||
);
|
||||
assert!(
|
||||
name.is_ascii(),
|
||||
"族名应为 ASCII(非 ASCII 名交给系统匹配):{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_text_stacks_differ_on_windows_and_macos() {
|
||||
assert_ne!(
|
||||
Platform::Windows.font_stack(FontFamily::Display)[0],
|
||||
Platform::Windows.font_stack(FontFamily::Text)[0]
|
||||
);
|
||||
assert_ne!(
|
||||
Platform::MacOs.font_stack(FontFamily::Display)[0],
|
||||
Platform::MacOs.font_stack(FontFamily::Text)[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_fallback_is_present_but_not_first() {
|
||||
// 中文族名必须在栈里(否则 CJK 走系统随机匹配),但不能排第一(纯英文环境不加载中文字体)。
|
||||
for platform in PLATFORMS {
|
||||
let stack = platform.font_stack(FontFamily::Text);
|
||||
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
|
||||
assert!(
|
||||
stack.iter().any(|name| cjk.contains(name)),
|
||||
"{} 的正文栈缺 CJK 兜底",
|
||||
platform.name()
|
||||
);
|
||||
assert!(
|
||||
!cjk.contains(&stack[0]),
|
||||
"{} 的首选字体不该是 CJK 族",
|
||||
platform.name()
|
||||
);
|
||||
let first = stack[0];
|
||||
let available = first.weights.nearest(requested);
|
||||
FontChoice {
|
||||
family: first.family,
|
||||
requested,
|
||||
available,
|
||||
quality: if available.abs_diff(requested) >= 200 {
|
||||
WeightMatch::Synthetic
|
||||
} else {
|
||||
WeightMatch::Nearest
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_platform_returns_a_stack() {
|
||||
let platform = Platform::current();
|
||||
assert!(!platform.font_stack(FontFamily::Text).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 字体栈与字重解析的测试(独立文件是为了守住单文件 ≤400 行的硬约束)。
|
||||
|
||||
use super::*;
|
||||
use crate::typography::Weight;
|
||||
|
||||
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
|
||||
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
|
||||
|
||||
#[test]
|
||||
fn font_stacks_are_well_formed() {
|
||||
for platform in PLATFORMS {
|
||||
for family in FAMILIES {
|
||||
let stack = platform.font_stack(family);
|
||||
assert!(
|
||||
!stack.is_empty(),
|
||||
"{} 的 {family:?} 栈为空",
|
||||
platform.name()
|
||||
);
|
||||
let last = stack[stack.len() - 1].family;
|
||||
let generic = match family {
|
||||
FontFamily::Mono => "monospace",
|
||||
_ => "sans-serif",
|
||||
};
|
||||
assert_eq!(
|
||||
last,
|
||||
generic,
|
||||
"{} 的 {family:?} 栈必须以通用族名兜底",
|
||||
platform.name()
|
||||
);
|
||||
for face in stack {
|
||||
assert!(!face.family.is_empty());
|
||||
assert!(
|
||||
!face.family.contains(['/', '\\', '.', ':']),
|
||||
"字体栈只允许族名,不得出现路径/文件名:{}",
|
||||
face.family
|
||||
);
|
||||
assert!(
|
||||
face.family.is_ascii(),
|
||||
"族名应为 ASCII(非 ASCII 名交给系统匹配):{}",
|
||||
face.family
|
||||
);
|
||||
// 每条栈项都必须声明字重覆盖(连 400 都没有的族写进栈没有意义)。
|
||||
assert!(
|
||||
face.supports(Weight::Regular),
|
||||
"{} 连 400 都没有,写它没意义",
|
||||
face.family
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_text_stacks_differ_on_windows_and_macos() {
|
||||
assert_ne!(
|
||||
Platform::Windows.font_stack(FontFamily::Display)[0].family,
|
||||
Platform::Windows.font_stack(FontFamily::Text)[0].family
|
||||
);
|
||||
assert_ne!(
|
||||
Platform::MacOs.font_stack(FontFamily::Display)[0].family,
|
||||
Platform::MacOs.font_stack(FontFamily::Text)[0].family
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_fallback_is_present_but_not_first() {
|
||||
// 中文族名必须在栈里,但不能排第一(纯英文环境不加载中文字体,D28)。
|
||||
for platform in PLATFORMS {
|
||||
let stack = platform.font_stack(FontFamily::Text);
|
||||
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
|
||||
assert!(
|
||||
stack.iter().any(|face| cjk.contains(&face.family)),
|
||||
"{} 的正文栈缺 CJK 兜底",
|
||||
platform.name()
|
||||
);
|
||||
assert!(
|
||||
!cjk.contains(&stack[0].family),
|
||||
"{} 的首选字体不该是 CJK 族",
|
||||
platform.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_weight_coverage_is_registered() {
|
||||
// 用户点名的坑:中文字重覆盖三平台差别很大。
|
||||
let win = Platform::Windows.cjk_face();
|
||||
assert!(win.supports(Weight::Regular) && win.supports(Weight::Bold));
|
||||
assert!(
|
||||
!win.supports(Weight::Medium),
|
||||
"雅黑没有 500——这条断言就是「中文 500 会掉到降级路径」的登记"
|
||||
);
|
||||
let mac = Platform::MacOs.cjk_face();
|
||||
assert!(mac.supports(Weight::Medium));
|
||||
assert!(
|
||||
!mac.supports(Weight::Bold),
|
||||
"苹方到 600 为止,700 要合成/换族"
|
||||
);
|
||||
let linux = Platform::Linux.cjk_face();
|
||||
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
|
||||
assert!(
|
||||
linux.supports(weight),
|
||||
"思源黑体/Noto CJK 应覆盖 400/500/700"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_native_and_flags_synthetic() {
|
||||
// Windows 正文 700:Segoe UI Variable 覆盖 → 原生。
|
||||
let bold = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
|
||||
assert_eq!(bold.quality, WeightMatch::Native);
|
||||
assert_eq!(bold.available, 700);
|
||||
assert!(!bold.is_synthetic());
|
||||
// Linux 正文 500:Roboto 可变 → 原生。
|
||||
assert_eq!(
|
||||
Platform::Linux
|
||||
.resolve_weight(FontFamily::Text, Weight::Medium)
|
||||
.quality,
|
||||
WeightMatch::Native
|
||||
);
|
||||
// 静态族缺档:只有 400 的族请求 700 → nearest 400,差 300 → 判为合成降级。
|
||||
let only_regular = FontFace::new("Courier New", WeightCoverage::Static(&[400]));
|
||||
assert_eq!(only_regular.weights.nearest(700), 400);
|
||||
let synthetic = FontChoice {
|
||||
family: only_regular.family,
|
||||
requested: 700,
|
||||
available: only_regular.weights.nearest(700),
|
||||
quality: WeightMatch::Synthetic,
|
||||
};
|
||||
assert!(synthetic.is_synthetic());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_picks_closest_static_weight() {
|
||||
let coverage = WeightCoverage::Static(&[300, 400, 600, 700]);
|
||||
assert_eq!(coverage.nearest(500), 400);
|
||||
assert_eq!(coverage.nearest(650), 600);
|
||||
assert_eq!(coverage.nearest(900), 700);
|
||||
let variable = WeightCoverage::Variable { min: 100, max: 900 };
|
||||
assert_eq!(variable.nearest(500), 500);
|
||||
assert_eq!(variable.nearest(50), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_platform_returns_a_stack() {
|
||||
let platform = Platform::current();
|
||||
assert!(!platform.font_stack(FontFamily::Text).is_empty());
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
//! M3 type scale(15 档)+ 三平台字体族回退栈。
|
||||
//! M3 type scale(15 档,baseline + Expressive 强调两套)+ 三平台字体族回退栈。
|
||||
//!
|
||||
//! **数值来源 A 级**:`androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。
|
||||
//! baseline 四元组取 `<Role>{Size,LineHeight,Tracking,Weight}`,Expressive 强调档取
|
||||
//! `<Role>Emphasized{...}`(见 [`emphasized`] 模块)。注意 Compose 这一代 token 的 tracking 与
|
||||
//! m3.material.io 网页表格有 0.05px 级别的差异(display-large −0.2 vs −0.25、body-medium 0.2 vs
|
||||
//! 0.25、title-medium 0.2 vs 0.15),本项目跟 **Compose(实际渲染值)**,差异登记在
|
||||
//! crate 文档「baseline vs Expressive 差异清单」里。
|
||||
//!
|
||||
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
|
||||
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
|
||||
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
|
||||
|
||||
mod emphasized;
|
||||
mod fonts;
|
||||
|
||||
pub use fonts::{FontFamily, Platform};
|
||||
pub use fonts::{FontChoice, FontFace, FontFamily, Platform, WeightCoverage, WeightMatch};
|
||||
|
||||
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Weight {
|
||||
/// 400:正文。
|
||||
Regular,
|
||||
/// 500:label / title-medium。
|
||||
/// 500:label / title-medium / Expressive 强调的正文与展示类。
|
||||
Medium,
|
||||
/// 600:强调标题(本项目的「区块标题」档)。
|
||||
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
|
||||
SemiBold,
|
||||
/// 700:页面标题 / 空态主文案。
|
||||
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
|
||||
Bold,
|
||||
}
|
||||
|
||||
@@ -32,7 +40,7 @@ impl Weight {
|
||||
}
|
||||
}
|
||||
|
||||
/// 再重一档(Expressive 的 emphasized 用得上,最重封顶 Bold)。
|
||||
/// 再重一档(最重封顶 Bold)。
|
||||
pub const fn heavier(self) -> Self {
|
||||
match self {
|
||||
Weight::Regular => Weight::Medium,
|
||||
@@ -69,10 +77,15 @@ impl TypeStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// 该样式的字体族回退栈。
|
||||
pub fn font_stack(self, platform: Platform) -> &'static [&'static str] {
|
||||
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`FontFace`])。
|
||||
pub fn font_stack(self, platform: Platform) -> &'static [FontFace] {
|
||||
platform.font_stack(self.family)
|
||||
}
|
||||
|
||||
/// 该样式在这台平台上「字重最终落到哪个族」——中文缺档时看它判断是否走降级。
|
||||
pub fn resolve(self, platform: Platform) -> FontChoice {
|
||||
platform.resolve_weight(self.family, self.weight)
|
||||
}
|
||||
}
|
||||
|
||||
/// M3 type scale 的 15 个角色。
|
||||
@@ -151,12 +164,10 @@ impl TypeRole {
|
||||
}
|
||||
}
|
||||
|
||||
/// M3 基线样式(字号 / 行高 / 字重 / 字距,官方 type scale 值)。
|
||||
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
|
||||
pub const fn style(self) -> TypeStyle {
|
||||
match self {
|
||||
TypeRole::DisplayLarge => {
|
||||
style(57.0, 64.0, Weight::Regular, -0.25, FontFamily::Display)
|
||||
}
|
||||
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Regular, -0.2, FontFamily::Display),
|
||||
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
@@ -164,11 +175,12 @@ impl TypeRole {
|
||||
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
|
||||
}
|
||||
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Text),
|
||||
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
|
||||
// 官方 TitleLarge 用 Brand 族(与 Display 同栈),其余 title 用 Plain(Text)。
|
||||
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.2, FontFamily::Text),
|
||||
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
|
||||
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Regular, 0.5, FontFamily::Text),
|
||||
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.25, FontFamily::Text),
|
||||
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.2, FontFamily::Text),
|
||||
TypeRole::BodySmall => style(12.0, 16.0, Weight::Regular, 0.4, FontFamily::Text),
|
||||
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
|
||||
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
|
||||
@@ -176,12 +188,20 @@ impl TypeRole {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expressive 强调档:**字重升一级,字号/行高/字距不变**。
|
||||
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
|
||||
///
|
||||
/// 口径声明:M3 Expressive 确实有 emphasized 排版档,但官方没公开与这 15 档一一对应的数值;
|
||||
/// 这里定的是**我们自己的规则**(加粗一档),目的是「同一 role 的强调态只改一个字重」,
|
||||
/// 保证页面不会因为强调而改变布局高度。要更强的层级请换更大 role,不要继续加字重。
|
||||
/// 规则(官方):字号与行高不变;字重升到 500(展示/正文类)或 700(label 与小标题);
|
||||
/// 字距按官方表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、
|
||||
/// body-medium 0.2→0.25)。这是**官方值,不是我们的近似**。
|
||||
pub const fn emphasized(self) -> TypeStyle {
|
||||
emphasized::style_of(self)
|
||||
}
|
||||
|
||||
/// **简化强调规则(非官方)**:只把字重升一级,字号/行高/字距全不动。
|
||||
///
|
||||
/// 用途:需要在「同一 role 内加强语气但不想引入任何字距变化」的场合(例如同屏多行标题
|
||||
/// 需要严格对齐基线网格时)。**优先用 [`TypeRole::emphasized`]**——那才是 Expressive 的定义。
|
||||
pub const fn emphasized_simplified(self) -> TypeStyle {
|
||||
let base = self.style();
|
||||
TypeStyle {
|
||||
weight: base.weight.heavier(),
|
||||
@@ -286,16 +306,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasized_only_bumps_weight() {
|
||||
fn simplified_emphasis_only_bumps_weight() {
|
||||
for role in TypeRole::ALL {
|
||||
let base = role.style();
|
||||
let emph = role.emphasized();
|
||||
let emph = role.emphasized_simplified();
|
||||
assert_eq!(base.size_px, emph.size_px);
|
||||
assert_eq!(base.line_height_px, emph.line_height_px);
|
||||
assert_eq!(base.letter_spacing_px, emph.letter_spacing_px);
|
||||
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_emphasis_is_at_least_as_heavy_as_baseline() {
|
||||
for role in TypeRole::ALL {
|
||||
assert!(
|
||||
role.emphasized().weight.value() >= role.style().weight.value(),
|
||||
"{} 的官方强调档不该比 baseline 更轻",
|
||||
role.name()
|
||||
);
|
||||
assert_ne!(
|
||||
role.emphasized(),
|
||||
role.style(),
|
||||
"{} 强调档与 baseline 相同",
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_is_proportional_and_bounded() {
|
||||
let base = TypeRole::BodyMedium.style();
|
||||
|
||||
Reference in new issue
Block a user