Skip to main content

atmos/os_lib/css/
values.rs

1// 分割: css.rs より機械的に移動(2026-07-16 リファクタ フェーズ3)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5/// Replace all `var(--name)` and `var(--name, fallback)` occurrences in a value string.
6/// カスタムプロパティが別のカスタムプロパティを参照する間接参照
7/// (例: `--a: var(--b); --b: red;` のとき `var(--a)` は `red` に解決される)に対応するため、
8/// 1パスの置換結果にまだ `var(` が残っていれば再帰的に解決する。循環参照時に無限再帰へ
9/// 陥らないよう、最大再帰深度(8)を設けて打ち切る。
10pub fn resolve_css_var(value: &str, vars: &BTreeMap<String, String>) -> String {
11    resolve_css_var_depth(value, vars, 0)
12}
13
14pub(crate) fn resolve_css_var_depth(value: &str, vars: &BTreeMap<String, String>, depth: u32) -> String {
15    if depth >= 8 {
16        return String::from(value);
17    }
18    let mut result = String::new();
19    let mut rest = value;
20    while let Some(start) = rest.find("var(") {
21        result.push_str(rest.get(..start).unwrap_or(""));
22        let after = rest.get(start + 4..).unwrap_or("");
23        // find matching closing paren
24        let mut depth_paren = 1usize;
25        let mut end = 0;
26        for (i, c) in after.char_indices() {
27            match c {
28                '(' => depth_paren += 1,
29                ')' => {
30                    depth_paren -= 1;
31                    if depth_paren == 0 {
32                        end = i;
33                        break;
34                    }
35                }
36                _ => {}
37            }
38        }
39        let inner = after.get(..end).unwrap_or("");
40        // split on first comma for fallback
41        let (var_name, fallback) = if let Some(comma_pos) = inner.find(',') {
42            (
43                inner.get(..comma_pos).unwrap_or("").trim(),
44                Some(inner.get(comma_pos + 1..).unwrap_or("").trim()),
45            )
46        } else {
47            (inner.trim(), None)
48        };
49        let resolved = vars
50            .get(var_name)
51            .cloned()
52            .or_else(|| fallback.map(|s| s.to_string()))
53            .unwrap_or_default();
54        // 参照先の値自体が別の var(...) を含む場合(間接参照)は再帰的に解決する。
55        let resolved = if resolved.contains("var(") {
56            resolve_css_var_depth(&resolved, vars, depth + 1)
57        } else {
58            resolved
59        };
60        result.push_str(&resolved);
61        rest = after.get(end + 1..).unwrap_or("");
62    }
63    result.push_str(rest);
64    result
65}
66
67/// `env(safe-area-inset-*)`等(CSS Environment Variables)を解決する。
68/// この処理系にはノッチ/デバイスセーフエリアという概念自体が存在しない
69/// (実機はRaspberry Pi 3B、ヘッドレスQEMU)ため、`var()`のような真の
70/// 環境変数テーブルは持たず、既知の`safe-area-inset-*`/
71/// `viewport-segment-*`系は常に`"0px"`、フォールバック指定があればそちらを
72/// 優先する「正直な既定値ゼロ」として解決する(丸ごと未対応だった。
73/// 2026-07-18 発見・実装)。
74pub fn resolve_css_env(value: &str) -> String {
75    let mut result = String::new();
76    let mut rest = value;
77    while let Some(start) = rest.find("env(") {
78        result.push_str(rest.get(..start).unwrap_or(""));
79        let after = rest.get(start + 4..).unwrap_or("");
80        let mut depth_paren = 1usize;
81        let mut end = 0;
82        for (i, c) in after.char_indices() {
83            match c {
84                '(' => depth_paren += 1,
85                ')' => {
86                    depth_paren -= 1;
87                    if depth_paren == 0 {
88                        end = i;
89                        break;
90                    }
91                }
92                _ => {}
93            }
94        }
95        let inner = after.get(..end).unwrap_or("");
96        let (_env_name, fallback) = if let Some(comma_pos) = inner.find(',') {
97            (
98                inner.get(..comma_pos).unwrap_or("").trim(),
99                Some(inner.get(comma_pos + 1..).unwrap_or("").trim()),
100            )
101        } else {
102            (inner.trim(), None)
103        };
104        let resolved = fallback.unwrap_or("0px").to_string();
105        let resolved = if resolved.contains("env(") {
106            resolve_css_env(&resolved)
107        } else {
108            resolved
109        };
110        result.push_str(&resolved);
111        rest = after.get(end + 1..).unwrap_or("");
112    }
113    result.push_str(rest);
114    result
115}
116
117/// `calc()`内の単一トークン(`10px`/`50%`/`2rem`/裸の数値等)をpxへ変換する。
118fn parse_calc_unit(s: &str, containing_width: i32) -> Option<f32> {
119    let s = s.trim();
120    if s.is_empty() {
121        return None;
122    }
123    if let Some(n) = s.strip_suffix("px") {
124        return n.trim().parse::<f32>().ok();
125    }
126    // `vh`/`vw`/`vmin`/`vmax`/絶対長さ単位(丸ごと未対応だった。以前は
127    // `px`/`rem`/`em`/`%`の4種のみで、`calc(50vw + 10px)`のようなビュー
128    // ポート単位を含む式が丸ごと評価失敗(`None`)していた。
129    // `eval_length_token`(`min()`/`max()`/`clamp()`用の同種パーサ。
130    // 2026-07-17に同じ単位対応を追加済み)と揃える。`vmin`は`in`判定より
131    // 前に置く(末尾2文字が`"in"`と一致するため順序を誤ると誤吸収される)。
132    // 2026-07-17 発見・実装)。
133    if let Some(n) = s.strip_suffix("vh") {
134        let h = crate::os_lib::layout::VIEWPORT_HINT_H.load(core::sync::atomic::Ordering::Relaxed);
135        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * h as f32);
136    }
137    if let Some(n) = s.strip_suffix("vw") {
138        let w = crate::os_lib::layout::VIEWPORT_HINT_W.load(core::sync::atomic::Ordering::Relaxed);
139        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * w as f32);
140    }
141    if let Some(n) = s.strip_suffix("vmin") {
142        let w = crate::os_lib::layout::VIEWPORT_HINT_W.load(core::sync::atomic::Ordering::Relaxed);
143        let h = crate::os_lib::layout::VIEWPORT_HINT_H.load(core::sync::atomic::Ordering::Relaxed);
144        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * w.min(h) as f32);
145    }
146    if let Some(n) = s.strip_suffix("vmax") {
147        let w = crate::os_lib::layout::VIEWPORT_HINT_W.load(core::sync::atomic::Ordering::Relaxed);
148        let h = crate::os_lib::layout::VIEWPORT_HINT_H.load(core::sync::atomic::Ordering::Relaxed);
149        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * w.max(h) as f32);
150    }
151    if let Some(n) = s.strip_suffix("rem") {
152        return n.trim().parse::<f32>().ok().map(|v| v * 16.0);
153    }
154    if let Some(n) = s.strip_suffix("em") {
155        return n.trim().parse::<f32>().ok().map(|v| v * 16.0);
156    }
157    if let Some(n) = s.strip_suffix('%') {
158        return n
159            .trim()
160            .parse::<f32>()
161            .ok()
162            .map(|v| v / 100.0 * containing_width as f32);
163    }
164    if let Some(n) = s.strip_suffix("in") {
165        return n.trim().parse::<f32>().ok().map(|v| v * 96.0);
166    }
167    if let Some(n) = s.strip_suffix("cm") {
168        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 2.54);
169    }
170    if let Some(n) = s.strip_suffix("mm") {
171        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 25.4);
172    }
173    if let Some(n) = s.strip_suffix("pt") {
174        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 72.0);
175    }
176    if let Some(n) = s.strip_suffix("pc") {
177        return n.trim().parse::<f32>().ok().map(|v| v * 16.0);
178    }
179    if let Some(n) = s.strip_suffix('q') {
180        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 101.6);
181    }
182    s.parse::<f32>().ok()
183}
184
185/// バイト位置`*pos`以降の空白をスキップする。
186fn calc_skip_ws(bytes: &[u8], pos: &mut usize) {
187    while *pos < bytes.len() && bytes[*pos] == b' ' {
188        *pos += 1;
189    }
190}
191
192/// `factor := '(' expr ')' | <数値+単位トークン>`。
193/// 丸括弧が来れば対応する閉じ括弧まで走査し`parse_calc_expr`へ再帰する
194/// (`calc((100% - 20px) / 2)`のようなネストに対応するため)。
195fn parse_calc_factor(s: &str, pos: &mut usize, containing_width: i32) -> Option<f32> {
196    let bytes = s.as_bytes();
197    calc_skip_ws(bytes, pos);
198    if bytes.get(*pos) == Some(&b'(') {
199        *pos += 1;
200        let start = *pos;
201        let mut depth = 1i32;
202        while *pos < bytes.len() && depth > 0 {
203            match bytes[*pos] {
204                b'(' => depth += 1,
205                b')' => depth -= 1,
206                _ => {}
207            }
208            *pos += 1;
209        }
210        if depth != 0 {
211            return None;
212        }
213        // `*pos`は閉じ括弧の直後を指している。中身は`[start, *pos-1)`。
214        let inner = s.get(start..pos.saturating_sub(1)).unwrap_or("");
215        return parse_calc_expr(inner.trim(), containing_width);
216    }
217    let start = *pos;
218    while *pos < bytes.len() {
219        let c = bytes[*pos];
220        if c == b' ' || c == b'(' || c == b')' {
221            break;
222        }
223        // 先頭以外の `+`/`-` は次の演算子の開始とみなしトークンを打ち切る
224        // (先頭の `-` は `calc(-10px)` のような単項マイナスとして数値の
225        // 一部に含める)。
226        if (c == b'+' || c == b'-') && *pos > start {
227            break;
228        }
229        *pos += 1;
230    }
231    let token = s.get(start..*pos).unwrap_or("");
232    parse_calc_unit(token, containing_width)
233}
234
235/// `term := factor (('*' | '/') factor)*`。乗除算は加減算より優先度が高い。
236fn parse_calc_term(s: &str, pos: &mut usize, containing_width: i32) -> Option<f32> {
237    let bytes = s.as_bytes();
238    let mut value = parse_calc_factor(s, pos, containing_width)?;
239    loop {
240        calc_skip_ws(bytes, pos);
241        match bytes.get(*pos) {
242            Some(b'*') => {
243                *pos += 1;
244                calc_skip_ws(bytes, pos);
245                let rhs = parse_calc_factor(s, pos, containing_width)?;
246                value *= rhs;
247            }
248            Some(b'/') => {
249                *pos += 1;
250                calc_skip_ws(bytes, pos);
251                let rhs = parse_calc_factor(s, pos, containing_width)?;
252                if rhs == 0.0 {
253                    return None;
254                }
255                value /= rhs;
256            }
257            _ => break,
258        }
259    }
260    Some(value)
261}
262
263/// `expr := term (('+' | '-') term)*`。任意個数の項を左結合で畳み込む
264/// (以前は`+`/`-`/`*`をそれぞれ最大1回・2項までしか扱えず、`calc(100% -
265/// 20px - 10px)`のような3項式や`calc(100% / 3)`のような除算、
266/// `calc((100% - 20px) / 2)`のような括弧ネストが軒並み評価失敗していた。
267/// 2026-07-16 発見・修正)。
268fn parse_calc_expr(s: &str, containing_width: i32) -> Option<f32> {
269    let bytes = s.as_bytes();
270    let mut pos = 0usize;
271    let mut value = parse_calc_term(s, &mut pos, containing_width)?;
272    loop {
273        calc_skip_ws(bytes, &mut pos);
274        match bytes.get(pos) {
275            Some(b'+') => {
276                pos += 1;
277                calc_skip_ws(bytes, &mut pos);
278                let rhs = parse_calc_term(s, &mut pos, containing_width)?;
279                value += rhs;
280            }
281            Some(b'-') => {
282                pos += 1;
283                calc_skip_ws(bytes, &mut pos);
284                let rhs = parse_calc_term(s, &mut pos, containing_width)?;
285                value -= rhs;
286            }
287            _ => break,
288        }
289    }
290    calc_skip_ws(bytes, &mut pos);
291    if pos != bytes.len() {
292        // 末尾に解釈できない余りがある(構文エラー)。
293        return None;
294    }
295    Some(value)
296}
297
298/// `calc()`式をpxへ評価する。四則演算・任意個数の項・丸括弧によるネストに
299/// 対応する(`calc(Apx ± Bpx ± ...)`/`calc(A% * B)`/`calc(A / B)`/
300/// `calc((A - B) / C)`等)。以前は`+`/`-`/`*`を1回ずつ・2項までしか扱えず
301/// `/`(除算)が丸ごと未対応、括弧ネストも不可という限定的な実装だった。
302pub fn eval_calc(expr: &str, containing_width: i32) -> Option<i32> {
303    let inner = expr.trim();
304    let inner = inner
305        .strip_prefix("calc(")
306        .and_then(|s| s.strip_suffix(')'))?;
307    parse_calc_expr(inner.trim(), containing_width).map(|v| v as i32)
308}
309
310/// `min()` / `max()` / `clamp()` の引数を最上位のカンマだけで分割する。
311/// 引数内に `calc()`/`min()`/`max()`/`clamp()` がネストしていても、その内側の
312/// カンマは分割対象にしない(括弧の深さを追跡する)。
313pub(crate) fn split_top_level_commas(s: &str) -> alloc::vec::Vec<&str> {
314    let mut parts = alloc::vec::Vec::new();
315    let bytes = s.as_bytes();
316    let mut depth = 0i32;
317    let mut start = 0usize;
318    for (i, &b) in bytes.iter().enumerate() {
319        match b {
320            b'(' => depth += 1,
321            b')' => depth -= 1,
322            b',' if depth == 0 => {
323                parts.push(s.get(start..i).unwrap_or("").trim());
324                start = i + 1;
325            }
326            _ => {}
327        }
328    }
329    parts.push(s.get(start..).unwrap_or("").trim());
330    parts
331}
332
333/// 単一の `name: value` 宣言(`!important` 込み)をパースする。
334/// 空の name/value や区切りの `:` が無い場合は `None`。
335pub(crate) fn parse_one_declaration(raw: &str) -> Option<Declaration> {
336    let mut kv = raw.splitn(2, ':');
337    let name = kv.next()?.trim();
338    let raw_value = kv.next()?.trim();
339    let (value, important) = match raw_value.rfind('!') {
340        Some(pos)
341            if raw_value
342                .get(pos + 1..)
343                .unwrap_or("")
344                .trim()
345                .eq_ignore_ascii_case("important") =>
346        {
347            (raw_value.get(..pos).unwrap_or("").trim(), true)
348        }
349        _ => (raw_value, false),
350    };
351    if name.is_empty() || value.is_empty() {
352        return None;
353    }
354    Some(Declaration {
355        name: String::from(name),
356        value: String::from(value),
357        important,
358    })
359}
360
361/// CSS Nesting のルール本体をパースする。トップレベル(このネストの深さ)の `;` で
362/// 宣言を区切り、トップレベルの `{` はネストしたルールの開始とみなして対応する `}`
363/// までを取り出し再帰する。ネストしたセレクタの各カンマ枝は、`&` を含めば親セレクタの
364/// テキストで置換し、含まなければ親セレクタの後ろに半角スペースで連結する(子孫結合子
365/// 扱い)。戻り値はこのレベル自身の宣言一覧(ネストしたルールは `out_rules` に積む)。
366pub(crate) fn parse_nested_body(
367    parent_selector_texts: &[String],
368    body: &str,
369    out_rules: &mut Vec<Rule>,
370    layer: Option<usize>,
371) -> Vec<Declaration> {
372    let mut declarations = Vec::new();
373    let chars: Vec<char> = body.chars().collect();
374    let mut i = 0usize;
375    let mut buf = String::new();
376    while i < chars.len() {
377        match chars[i] {
378            '{' => {
379                let nested_selector_text = buf.trim().to_string();
380                buf.clear();
381                // 対応する `}` を探す(ネストの深さを追跡)。
382                let mut depth = 1i32;
383                let start = i + 1;
384                let mut j = start;
385                while j < chars.len() && depth > 0 {
386                    match chars[j] {
387                        '{' => depth += 1,
388                        '}' => depth -= 1,
389                        _ => {}
390                    }
391                    if depth > 0 {
392                        j += 1;
393                    }
394                }
395                let inner_body: String = chars[start..j.min(chars.len())].iter().collect();
396                i = j;
397                if !nested_selector_text.is_empty() {
398                    let mut resolved_texts: Vec<String> = Vec::new();
399                    for nb in split_top_level_commas(&nested_selector_text) {
400                        let nb = nb.trim();
401                        if nb.is_empty() {
402                            continue;
403                        }
404                        for parent in parent_selector_texts {
405                            let resolved = if nb.contains('&') {
406                                nb.replace('&', parent)
407                            } else {
408                                alloc::format!("{} {}", parent, nb)
409                            };
410                            resolved_texts.push(resolved);
411                        }
412                    }
413                    let nested_selectors: Vec<Selector> = resolved_texts
414                        .iter()
415                        .filter_map(|s| parse_selector(s.trim()))
416                        .collect();
417                    let nested_decls =
418                        parse_nested_body(&resolved_texts, &inner_body, out_rules, layer);
419                    if !nested_selectors.is_empty() && !nested_decls.is_empty() {
420                        out_rules.push(Rule {
421                            selectors: nested_selectors,
422                            declarations: nested_decls,
423                            layer,
424                            media_query: None,
425                            container_query: None,
426                        });
427                    }
428                }
429            }
430            ';' => {
431                if let Some(d) = parse_one_declaration(&buf) {
432                    declarations.push(d);
433                }
434                buf.clear();
435            }
436            c => buf.push(c),
437        }
438        i += 1;
439    }
440    // 末尾の `;` は省略可能なので、残った buf も最後の宣言として扱う。
441    if let Some(d) = parse_one_declaration(&buf) {
442        declarations.push(d);
443    }
444    declarations
445}
446
447/// 単一の長さ値(`10px` / `50%` / `calc(...)` など、四則演算を含まない1トークン、
448/// もしくは `calc()`/`min()`/`max()`/`clamp()` の再帰評価)を px へ解決する。
449pub(crate) fn eval_length_token(s: &str, containing_width: i32) -> Option<f32> {
450    let t = s.trim();
451    if t.starts_with("calc(") || t.starts_with("min(") || t.starts_with("max(") || t.starts_with("clamp(")
452    {
453        return eval_css_math(t, containing_width).map(|v| v as f32);
454    }
455    if let Some(n) = t.strip_suffix("px") {
456        return n.trim().parse::<f32>().ok();
457    }
458    // `vh`/`vw`/`vmin`/`vmax`(丸ごと未対応だった。`min()`/`max()`/`clamp()`の
459    // 引数解決だけを担うこの`eval_length_token`は`px`/`rem`/`em`/`%`の4種
460    // のみ対応で、`eval_calc`(`calc()`本体。2026-07-16に同種の単位対応を
461    // 追加済み)や`layout/values.rs`の`parse_px_like`とは別実装のため
462    // 追従していなかった。`min(50vw, 300px)`/`clamp(10px, 5vw, 50px)`の
463    // ような定番のレスポンシブサイズ指定で、ビューポート単位の引数が
464    // 未知トークンとして黙って`filter_map`に弾かれ計算から抜け落ちて
465    // いた(エラーにならず静かに1引数少ない`min`/`max`として計算される
466    // ため気づきにくい)。2026-07-16 発見・実装。`vmin`は`in`判定より前に
467    // 置く(`layout/values.rs`の`parse_px_like`と同じ順序制約)。
468    if let Some(n) = t.strip_suffix("vh") {
469        let h = crate::os_lib::layout::VIEWPORT_HINT_H.load(core::sync::atomic::Ordering::Relaxed);
470        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * h as f32);
471    }
472    if let Some(n) = t.strip_suffix("vw") {
473        let w = crate::os_lib::layout::VIEWPORT_HINT_W.load(core::sync::atomic::Ordering::Relaxed);
474        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * w as f32);
475    }
476    if let Some(n) = t.strip_suffix("vmin") {
477        let w = crate::os_lib::layout::VIEWPORT_HINT_W.load(core::sync::atomic::Ordering::Relaxed);
478        let h = crate::os_lib::layout::VIEWPORT_HINT_H.load(core::sync::atomic::Ordering::Relaxed);
479        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * w.min(h) as f32);
480    }
481    if let Some(n) = t.strip_suffix("vmax") {
482        let w = crate::os_lib::layout::VIEWPORT_HINT_W.load(core::sync::atomic::Ordering::Relaxed);
483        let h = crate::os_lib::layout::VIEWPORT_HINT_H.load(core::sync::atomic::Ordering::Relaxed);
484        return n.trim().parse::<f32>().ok().map(|v| v / 100.0 * w.max(h) as f32);
485    }
486    if let Some(n) = t.strip_suffix("rem") {
487        return n.trim().parse::<f32>().ok().map(|v| v * 16.0);
488    }
489    if let Some(n) = t.strip_suffix("em") {
490        return n.trim().parse::<f32>().ok().map(|v| v * 16.0);
491    }
492    if let Some(n) = t.strip_suffix('%') {
493        return n
494            .trim()
495            .parse::<f32>()
496            .ok()
497            .map(|v| v / 100.0 * containing_width as f32);
498    }
499    // 絶対長さ単位(丸ごと未対応だった。`parse_px_like`と同じ理由・同じ
500    // `96px = 1in`基準の換算。2026-07-16 発見・実装)。
501    if let Some(n) = t.strip_suffix("in") {
502        return n.trim().parse::<f32>().ok().map(|v| v * 96.0);
503    }
504    if let Some(n) = t.strip_suffix("cm") {
505        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 2.54);
506    }
507    if let Some(n) = t.strip_suffix("mm") {
508        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 25.4);
509    }
510    if let Some(n) = t.strip_suffix("pt") {
511        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 72.0);
512    }
513    if let Some(n) = t.strip_suffix("pc") {
514        return n.trim().parse::<f32>().ok().map(|v| v * 16.0);
515    }
516    if let Some(n) = t.strip_suffix('q') {
517        return n.trim().parse::<f32>().ok().map(|v| v * 96.0 / 101.6);
518    }
519    t.parse::<f32>().ok()
520}
521
522/// CSS の数式関数(`calc()` / `min()` / `max()` / `clamp()`)を px へ評価する。
523/// `min()`/`max()` は任意個数の引数、`clamp(min, val, pref)` は3引数固定。
524/// 引数はネストした calc()/min()/max()/clamp() も再帰的に評価する。
525pub fn eval_css_math(expr: &str, containing_width: i32) -> Option<i32> {
526    let t = expr.trim();
527    if t.starts_with("calc(") {
528        return eval_calc(t, containing_width);
529    }
530    if let Some(inner) = t.strip_prefix("min(").and_then(|s| s.strip_suffix(')')) {
531        let vals: alloc::vec::Vec<f32> = split_top_level_commas(inner)
532            .iter()
533            .filter_map(|a| eval_length_token(a, containing_width))
534            .collect();
535        return vals
536            .into_iter()
537            .fold(None, |acc, v| Some(acc.map_or(v, |m: f32| m.min(v))))
538            .map(|v| v as i32);
539    }
540    if let Some(inner) = t.strip_prefix("max(").and_then(|s| s.strip_suffix(')')) {
541        let vals: alloc::vec::Vec<f32> = split_top_level_commas(inner)
542            .iter()
543            .filter_map(|a| eval_length_token(a, containing_width))
544            .collect();
545        return vals
546            .into_iter()
547            .fold(None, |acc, v| Some(acc.map_or(v, |m: f32| m.max(v))))
548            .map(|v| v as i32);
549    }
550    if let Some(inner) = t.strip_prefix("clamp(").and_then(|s| s.strip_suffix(')')) {
551        let parts = split_top_level_commas(inner);
552        if parts.len() != 3 {
553            return None;
554        }
555        let min_v = eval_length_token(parts[0], containing_width)?;
556        let pref_v = eval_length_token(parts[1], containing_width)?;
557        let max_v = eval_length_token(parts[2], containing_width)?;
558        // clamp(MIN, PREF, MAX): MAX が MIN を下回る異常値でも破綻しないよう
559        // 実効上限を MIN 以上に補正してから挟み込む。
560        let effective_max = max_v.max(min_v);
561        return Some(pref_v.clamp(min_v, effective_max) as i32);
562    }
563    None
564}
565
566// 簡易CSSパーサ(tag / #id / .class と宣言ブロックをサポート)