atmos/os_lib/web_engine/font_budget.rs
1//! Web フォント取得の可否判定(描画をブロックしないための予算)。
2//!
3//! 仕様・不変条件・実測データは `spec/webfont_loading.md` を参照。
4//!
5//! ## なぜ必要か
6//! L1 の排他制御を直してネットワークが安定した結果、
7//! 5.3MB の Noto Sans JP を実際にダウンロードするようになり、
8//! その完了を待って描画が始まらなくなった(実測)。
9//! 実サイトは `font-display: swap` を指定しており、
10//! **読み込みを待たずに代替フォントで描画する**のが意図である。
11//!
12//! グローバル状態にもハードウェアにも依存しない純粋ロジックのみで構成する。
13
14extern crate alloc;
15
16use alloc::string::String;
17
18/// 1 フォントあたりの取得サイズ上限。
19/// これを超えるものは諦めて内蔵フォントで描く。
20/// 実サイトの Noto Sans JP は 5.3MB あり、この OS で同期的に扱うのは非現実的。
21pub const MAX_FONT_BYTES: usize = 1024 * 1024;
22
23/// 1 ページで取得を試みる Web フォントの本数上限。
24/// 上限を超えたら以降は代替フォントを使う。
25pub const MAX_FONTS_PER_PAGE: u32 = 4;
26
27/// 取得を試みるかどうかの判定結果。
28#[derive(Debug, PartialEq, Eq, Clone, Copy)]
29pub enum FontDecision {
30 /// 取得してよい(非同期で最後まで取得する)。
31 Fetch,
32 /// 取得しない。代替フォントで描く。理由を伴う。
33 UseFallback(FallbackReason),
34}
35
36/// 代替フォントを使う理由。黙って無視せず記録するために型で持つ。
37#[derive(Debug, PartialEq, Eq, Clone, Copy)]
38pub enum FallbackReason {
39 /// URL が空など、そもそも取得できない。
40 InvalidUrl,
41}
42
43/// 引数が不正だった場合のエラー。
44#[derive(Debug, PartialEq, Eq, Clone)]
45pub enum BudgetError {
46 /// フォント名が空。`@font-face` の解析結果が壊れている。
47 EmptyFamily(String),
48}
49
50/// このフォントを取得しに行くか判定する。
51///
52/// 本物のブラウザ挙動に従い、フォントのサイズ上限や本数上限による打ち切りは行わない。
53/// 時間がかかっても非同期で最後まで取得し、取得完了時に画面へ反映する。
54pub fn decide(
55 family: &str,
56 url: &str,
57 _already_fetched: u32,
58) -> Result<FontDecision, BudgetError> {
59 if family.trim().is_empty() {
60 crate::error!("[FONT][BUDGET] font-family 名が空です url={:?}", url);
61 return Err(BudgetError::EmptyFamily(String::from(family)));
62 }
63 if url.trim().is_empty() {
64 return Ok(FontDecision::UseFallback(FallbackReason::InvalidUrl));
65 }
66 Ok(FontDecision::Fetch)
67}
68
69/// 取得したバイト数が上限を超えているか。
70///
71/// 【仕様撤回】本物のブラウザ挙動に従い、サイズを理由に取得を打ち切らないため常に false を返す。
72pub fn exceeds_size_limit(_bytes: usize) -> bool {
73 false
74}
75
76/// 代替へ倒す理由を人が読める文字列にする(ログ用)。
77pub fn describe(reason: FallbackReason) -> &'static str {
78 match reason {
79 FallbackReason::InvalidUrl => "取得先 URL が空",
80 }
81}
82
83/// フォント取得の結果。
84///
85/// 【2026-07-31】非同期化により「取得中」という状態が生まれた。
86/// これを**取得失敗と混同**すると、`MAX_CONSECUTIVE_FAILURES` の
87/// サーキットブレーカーが即座に発動して以降のフォントを全部諦めてしまう
88/// (実測でその回帰を踏んだ)。
89#[derive(Debug, PartialEq, Eq, Clone, Copy)]
90pub enum FetchOutcome {
91 /// キャッシュに載っていて即座に使える。
92 Ready,
93 /// 非同期キューへ積んだ。まだ失敗していない。
94 Pending,
95 /// 本物の失敗(ネットワークエラー等)。
96 Failed,
97}
98
99/// この結果を「連続失敗」としてカウントすべきか。
100///
101/// **`Pending` は失敗ではない。** 取得中なだけなので、
102/// サーキットブレーカーを進めてはいけない。
103pub fn should_count_as_failure(outcome: FetchOutcome) -> bool {
104 matches!(outcome, FetchOutcome::Failed)
105}