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}