Skip to main content

atmos/os_lib/web_engine/
css_units.rs

1// web_engine/css_units.rs - CSS の長さ単位の解釈(純粋関数)
2//
3// `helpers.rs` は `super::` の型(`ListStyleType` 等)に依存するためホスト側
4// ユニットテストから丸ごと取り込めない。単位換算は外部依存の無い純粋計算なので、
5// `url_resolve.rs` と同様に自己完結モジュールとして切り出し、
6// `tests/src/test_css_units.rs` から直接検証できるようにした。
7
8/// ルート要素の既定フォントサイズ(`rem` / `em` の換算基準, px)。
9pub const ROOT_FONT_SIZE_PX: f32 = 16.0;
10
11/// CSS の `font-size` 値を px へ変換する。解釈できない場合は `None`
12/// (呼び出し側は既定サイズにフォールバックする)。
13///
14/// 【2026-07-26 バグ修正】従来は次の 3 点が壊れていた:
15///  1. `strip_suffix("em")` が **`rem` にもマッチ**してしまい、`3rem` は
16///     `"3r"` のパースに失敗して `None`(=指定が無視され既定の 16px)になっていた。
17///     実サイトは見出しに `font-size: 3rem` 等を多用しており、
18///     **ページ全体のタイポグラフィがほぼ効いていない**状態だった。
19///  2. 単位無し・`px` の場合に `parse::<u32>()` していたため、`1.3px` の
20///     ような小数値がすべて失敗していた。
21///  3. `pt` はサフィックスを外すだけで px へ換算していなかった(1pt = 4/3 px)。
22pub fn parse_font_size(s: &str) -> Option<u32> {
23    let t = s.trim();
24    if t.is_empty() {
25        return None;
26    }
27
28    // 長い単位から順に判定する(`rem` を `em` より先に見ないと誤判定する)。
29    let (num_str, scale) = if let Some(n) = t.strip_suffix("rem") {
30        (n, ROOT_FONT_SIZE_PX)
31    } else if let Some(n) = t.strip_suffix("em") {
32        // 本来は親要素基準だが、本エンジンでは簡易的にルート基準で換算する。
33        (n, ROOT_FONT_SIZE_PX)
34    } else if let Some(n) = t.strip_suffix("px") {
35        (n, 1.0)
36    } else if let Some(n) = t.strip_suffix("pt") {
37        (n, 4.0 / 3.0) // 1pt = 1/72inch, 1px = 1/96inch
38    } else {
39        (t, 1.0)
40    };
41
42    let val = num_str.trim().parse::<f32>().ok()?;
43    if !val.is_finite() || val < 0.0 {
44        return None;
45    }
46    Some((val * scale) as u32)
47}
48
49/// `open_idx` にある `(` に対応する閉じ括弧の位置を返す(入れ子対応)。
50///
51/// 【2026-07-28】`linear-gradient(135deg,rgba(52,152,219,.4) 0,...)` のように
52/// 引数の中に `rgba(...)` が入る場合、単純な `find(')')` では
53/// **rgba の閉じ括弧**を拾ってしまい、グラデーション定義が壊れる。
54/// 実際 `.hero` の多重背景がこれで解釈できていなかった。
55///
56/// `open_idx` が `(` を指していない場合は黙って戻らず、
57/// エラーログを出して `None` を返す(呼び出し側は解釈を諦めること)。
58pub fn find_balanced_close(s: &str, open_idx: usize) -> Option<usize> {
59    let bytes = s.as_bytes();
60    if bytes.get(open_idx) != Some(&b'(') {
61        crate::error!(
62            "[CSS][PAREN] 開き括弧の位置が不正です: idx={} len={}",
63            open_idx,
64            s.len()
65        );
66        return None;
67    }
68    let mut depth = 0i32;
69    for (i, b) in bytes.iter().enumerate().skip(open_idx) {
70        match b {
71            b'(' => depth += 1,
72            b')' => {
73                depth -= 1;
74                if depth == 0 {
75                    return Some(i);
76                }
77            }
78            _ => {}
79        }
80    }
81    // 閉じ括弧が無い(壊れた CSS)。黙って途中まで解釈せず None を返す。
82    None
83}
84
85/// CSS 値をトップレベルのカンマだけで分割する(括弧内のカンマは無視)。
86///
87/// 【2026-07-28】`linear-gradient(135deg,rgba(52,152,219,.4) 0,rgba(44,62,80,.4) 100%)`
88/// の引数を単純な `split(',')` で割ると **`rgba(...)` の内側のカンマ**でも割れてしまい、
89/// 色停止点が解釈できない。`find_balanced_close` と対で使うこと。
90///
91/// 引用符(`'` / `"`)の内側のカンマも分割対象外にする
92/// (`font-family: "Foo, Bar", sans-serif` のようなケース)。
93pub fn split_top_level_commas(s: &str) -> alloc::vec::Vec<&str> {
94    let mut out = alloc::vec::Vec::new();
95    let mut depth = 0i32;
96    let mut quote: Option<u8> = None;
97    let mut start = 0usize;
98    for (i, b) in s.as_bytes().iter().enumerate() {
99        match (*b, quote) {
100            (q, Some(active)) if q == active => quote = None,
101            (_, Some(_)) => {}
102            (b'\'', None) | (b'"', None) => quote = Some(*b),
103            (b'(', None) => depth += 1,
104            (b')', None) => depth -= 1,
105            (b',', None) if depth == 0 => {
106                out.push(s.get(start..i).unwrap_or(""));
107                start = i + 1;
108            }
109            _ => {}
110        }
111    }
112    out.push(s.get(start..).unwrap_or(""));
113    out
114}