atmos/kernel/font_select.rs
1//! ウェイト付きフォントを選んでよいかの判定(純粋モジュール)。
2//!
3//! 仕様は `spec/webfont_loading.md`。
4//!
5//! Google Fonts は `unicode-range` でファイルを分割して配信するため、
6//! 「Noto Sans JP のウェイト 700」が Latin だけのサブセットということがある。
7//! 太字だからと無条件にそのファイルを選ぶと、収録されていない文字が欠ける。
8//!
9//! フォントもハードウェアも要らない純粋な判定なので、QEMU 抜きで検証できる。
10
11/// `s` に含まれる文字を**すべて**描けるなら `true`。
12///
13/// `has_glyph` は「そのフォント自身が字形を持っているか」を返す述語
14/// (代替チェーンを辿った結果ではない点が要点。辿ってしまうと
15/// 「持っている」と誤判定してサブセットを選んでしまう)。
16///
17/// # 先頭 1 文字では足りない
18///
19/// 【2026-08-05】当初は先頭の非空白文字 1 つだけを見ていた。
20/// 「同じ文字体系が続くはず」という前提を置いたが、**これは誤り**。
21/// 実サイトには
22///
23/// ```text
24/// IoT、情報家電、AI技術を駆使して
25/// ```
26///
27/// のように 1 つの文字列で書体が混ざる例がふつうにある。
28/// 先頭が `I` なので Latin サブセットが選ばれ、後続の日本語が
29/// 豆腐(■)になっていた。全文字を確かめる必要がある。
30///
31/// 空白は書体を問わないので除く。空文字列・空白のみは `true`
32/// (描くものが無いので、どのフォントを選んでも結果は変わらない)。
33///
34/// 計算量: **O(L)**(L は文字数)。文字列は短く、
35/// 1 回の描画につきウェイト候補の数だけしか呼ばれない。
36pub fn covers_all(s: &str, has_glyph: impl Fn(char) -> bool) -> bool {
37 s.chars().filter(|c| !c.is_whitespace()).all(has_glyph)
38}
39
40/// 登録済みウェイトの中から、描画に使う 1 つを選ぶ。
41///
42/// - `want`: 希望順のウェイト(太字なら `[900, 700]`、通常なら `[400]`)。
43/// - `available`: この family で登録済みの `(ウェイト, その文字列を全部描けるか)`。
44///
45/// 1. 希望ウェイトのうち、登録済みで文字列を描けるものを希望順に選ぶ。
46/// 2. 無ければ、文字列を描ける登録済みウェイトのうち**先頭の希望に最も近いもの**
47/// (CSS Fonts の「最も近いウェイト」照合の簡略版。同距離なら重い方)。
48/// 3. どれも描けなければ `None`(呼び出し側は既定の経路へ落とす)。
49///
50/// 【2026-09-26】従来は 1 が外れると「family 名だけのキー=最初に届いた書体」で
51/// 描いていた。Font Awesome 7 Free は 400(regular)と 900(solid)が同名で、
52/// 先に届く 400 にはマイクロチップ等の solid 専用アイコンが無い。
53/// `::before` が太字と見なされないとウェイト 400 で引かれ、**字形の無い書体で
54/// 描いてアイコンが消えていた**(www.sugi-lab.net の実画面で全アイコン欠落)。
55///
56/// 計算量: **O(W)**(W は登録ウェイト数。高々数個)。
57pub fn pick_weight(want: &[u16], available: &[(u16, bool)]) -> Option<u16> {
58 for w in want {
59 if available.iter().any(|&(aw, ok)| aw == *w && ok) {
60 return Some(*w);
61 }
62 }
63 let target = want.first().copied().unwrap_or(400) as i32;
64 available
65 .iter()
66 .filter(|&&(_, ok)| ok)
67 .min_by_key(|&&(aw, _)| ((aw as i32 - target).abs(), -(aw as i32)))
68 .map(|&(aw, _)| aw)
69}
70
71/// 総称フォント名(`sans-serif` 等)に割り当てる候補の family 名(小文字、優先順)。
72/// 総称名でなければ空。
73///
74/// AtmOS の内蔵フォントは等幅(M+ 1code)なので、総称名を内蔵フォントへ落とすと、
75/// Chrome(OS のプロポーショナル書体を使う)と見た目も字幅も大きく違う。そこで
76/// **そのページで読み込まれた Web フォントのうち、この表に載っているもの**を使う。
77/// 【2026-09-26】英語版トップ(`'Noto Sans JP', sans-serif` 指定で Noto を読まず Inter を読む)が
78/// 全文等幅で表示されていた。
79pub fn generic_family_candidates(name: &str) -> &'static [&'static str] {
80 const SANS: &[&str] = &[
81 "inter", "noto sans jp", "noto sans", "roboto", "open sans", "lato", "source sans pro",
82 "helvetica neue", "helvetica", "arial",
83 ];
84 const SERIF: &[&str] = &["noto serif jp", "noto serif", "source serif pro", "georgia", "times new roman"];
85 match name.trim().to_ascii_lowercase().as_str() {
86 "sans-serif" | "system-ui" | "-apple-system" | "blinkmacsystemfont" | "ui-sans-serif" => SANS,
87 "serif" | "ui-serif" => SERIF,
88 _ => &[],
89 }
90}
91
92/// グリフの送り幅(フォント単位)を px へ換算する。**四捨五入**する。
93///
94/// 【2026-10-03】従来は `as u32` で切り捨てており、1 文字あたり平均 0.5px ずつ縮んでいた。
95/// 16px の「English」が Chrome の 54.3px に対して 51px になり、sugi-lab.net のナビ全体が約 30px 狭く
96/// 計算されて、ロゴの折り返し(Chrome は 3 行)が 2 行になっていた。計測と描画は同じ値を使うので、
97/// ここを直せば両方そろう。
98pub fn scaled_advance(units: u16, units_per_em: u16, size_px: u32) -> u32 {
99 if units_per_em == 0 {
100 return size_px / 2;
101 }
102 ((units as u64 * size_px as u64 * 2 + units_per_em as u64) / (units_per_em as u64 * 2)) as u32
103}
104
105/// `font-weight` の値を数値へ(`normal`=400、`bold`=700、数値はそのまま 1〜1000 に丸める)。
106/// `bolder`/`lighter` は親の値が要るが、ここでは CSS Fonts 4 の表で親 400 とみなした値(700/100)。
107pub fn parse_font_weight(v: &str) -> u16 {
108 let t = v.trim();
109 match t.to_ascii_lowercase().as_str() {
110 "" | "normal" => 400,
111 "bold" => 700,
112 "bolder" => 700,
113 "lighter" => 100,
114 _ => t.parse::<f32>().ok().filter(|w| w.is_finite()).map(|w| w.clamp(1.0, 1000.0) as u16).unwrap_or(400),
115 }
116}
117
118/// 読み込み済みのウェイトから、`desired` に使うものを CSS Fonts 4 §5.2 の照合規則で選ぶ。
119/// `available` は (ウェイト, 文字列を描けるか)。描けないものは候補にしない。
120///
121/// - 400〜500: desired 以上 500 までを昇順 → desired 未満を降順 → 500 超を昇順
122/// - 400 未満: desired 以下を降順 → desired 超を昇順
123/// - 500 超: desired 以上を昇順 → desired 未満を降順
124///
125/// 【2026-10-03】従来は「太字か否か」の 2 値で選んでおり、`font-weight:500`(sugi-lab.net のナビ)が
126/// 400 の書体で描かれ、Chrome より字幅が狭かった。
127pub fn pick_weight_css(desired: u16, available: &[(u16, bool)]) -> Option<u16> {
128 let mut ws: alloc::vec::Vec<u16> = available.iter().filter(|&&(_, ok)| ok).map(|&(w, _)| w).collect();
129 ws.sort_unstable();
130 ws.dedup();
131 if ws.contains(&desired) {
132 return Some(desired);
133 }
134 let up = |lo: u16, hi: u16| ws.iter().copied().find(|&w| w > lo && w <= hi);
135 let down = |hi: u16| ws.iter().rev().copied().find(|&w| w < hi);
136 let above = |lo: u16| ws.iter().copied().find(|&w| w > lo);
137 if (400..=500).contains(&desired) {
138 up(desired, 500).or_else(|| down(desired)).or_else(|| above(500))
139 } else if desired < 400 {
140 down(desired).or_else(|| above(desired))
141 } else {
142 above(desired).or_else(|| down(desired))
143 }
144}