Skip to main content

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}