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_with_parent(s: &str, parent_font_px: f32) -> Option<u32> {
23    let t = s.trim();
24    if t.is_empty() {
25        return None;
26    }
27    let parent = if parent_font_px > 0.0 { parent_font_px } else { ROOT_FONT_SIZE_PX };
28
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        (n, parent)
33    } else if let Some(n) = t.strip_suffix('%') {
34        (n, parent / 100.0)
35    } else if let Some(n) = t.strip_suffix("px") {
36        (n, 1.0)
37    } else if let Some(n) = t.strip_suffix("pt") {
38        (n, 4.0 / 3.0)
39    } else {
40        (t, 1.0)
41    };
42
43    let val = num_str.trim().parse::<f32>().ok()?;
44    if !val.is_finite() || val < 0.0 {
45        return None;
46    }
47    Some((val * scale) as u32)
48}
49
50pub fn parse_font_size(s: &str) -> Option<u32> {
51    let t = s.trim();
52    if t.is_empty() {
53        return None;
54    }
55
56    let (num_str, scale) = if let Some(n) = t.strip_suffix("rem") {
57        (n, ROOT_FONT_SIZE_PX)
58    } else if let Some(n) = t.strip_suffix("em") {
59        (n, ROOT_FONT_SIZE_PX)
60    } else if let Some(n) = t.strip_suffix("px") {
61        (n, 1.0)
62    } else if let Some(n) = t.strip_suffix("pt") {
63        (n, 4.0 / 3.0)
64    } else {
65        (t, 1.0)
66    };
67
68    let val = num_str.trim().parse::<f32>().ok()?;
69    if !val.is_finite() || val < 0.0 {
70        return None;
71    }
72    Some((val * scale) as u32)
73}
74
75/// `text-indent` の長さ解決。px, %, rem, em, 裸数値を要素のフォントサイズや
76/// コンテナ幅に基づいて px 値(i32)へ変換する。
77pub fn resolve_text_indent(s: &str, container_width: i32, font_size: u32) -> i32 {
78    let t = s.trim();
79    let px = if let Some(num) = t.strip_suffix("px") {
80        num.trim().parse::<f32>().ok()
81    } else if let Some(num) = t.strip_suffix('%') {
82        num.trim()
83            .parse::<f32>()
84            .ok()
85            .map(|v| v / 100.0 * container_width as f32)
86    } else if let Some(num) = t.strip_suffix("rem") {
87        num.trim().parse::<f32>().ok().map(|v| v * ROOT_FONT_SIZE_PX)
88    } else if let Some(num) = t.strip_suffix("em") {
89        num.trim().parse::<f32>().ok().map(|v| v * font_size as f32)
90    } else {
91        t.parse::<f32>().ok()
92    };
93    px.map(|f| f as i32).unwrap_or(0)
94}
95
96/// `open_idx` にある `(` に対応する閉じ括弧の位置を返す(入れ子対応)。
97///
98/// 【2026-07-28】`linear-gradient(135deg,rgba(52,152,219,.4) 0,...)` のように
99/// 引数の中に `rgba(...)` が入る場合、単純な `find(')')` では
100/// **rgba の閉じ括弧**を拾ってしまい、グラデーション定義が壊れる。
101/// 実際 `.hero` の多重背景がこれで解釈できていなかった。
102///
103/// `open_idx` が `(` を指していない場合は黙って戻らず、
104/// エラーログを出して `None` を返す(呼び出し側は解釈を諦めること)。
105pub fn find_balanced_close(s: &str, open_idx: usize) -> Option<usize> {
106    let bytes = s.as_bytes();
107    if bytes.get(open_idx) != Some(&b'(') {
108        crate::error!(
109            "[CSS][PAREN] 開き括弧の位置が不正です: idx={} len={}",
110            open_idx,
111            s.len()
112        );
113        return None;
114    }
115    let mut depth = 0i32;
116    for (i, b) in bytes.iter().enumerate().skip(open_idx) {
117        match b {
118            b'(' => depth += 1,
119            b')' => {
120                depth -= 1;
121                if depth == 0 {
122                    return Some(i);
123                }
124            }
125            _ => {}
126        }
127    }
128    // 閉じ括弧が無い(壊れた CSS)。黙って途中まで解釈せず None を返す。
129    None
130}
131
132/// CSS 値をトップレベルのカンマだけで分割する(括弧内のカンマは無視)。
133///
134/// 【2026-07-28】`linear-gradient(135deg,rgba(52,152,219,.4) 0,rgba(44,62,80,.4) 100%)`
135/// の引数を単純な `split(',')` で割ると **`rgba(...)` の内側のカンマ**でも割れてしまい、
136/// 色停止点が解釈できない。`find_balanced_close` と対で使うこと。
137///
138/// 引用符(`'` / `"`)の内側のカンマも分割対象外にする
139/// (`font-family: "Foo, Bar", sans-serif` のようなケース)。
140pub fn split_top_level_commas(s: &str) -> alloc::vec::Vec<&str> {
141    let mut out = alloc::vec::Vec::new();
142    let mut depth = 0i32;
143    let mut quote: Option<u8> = None;
144    let mut start = 0usize;
145    for (i, b) in s.as_bytes().iter().enumerate() {
146        match (*b, quote) {
147            (q, Some(active)) if q == active => quote = None,
148            (_, Some(_)) => {}
149            (b'\'', None) | (b'"', None) => quote = Some(*b),
150            (b'(', None) => depth += 1,
151            (b')', None) => depth -= 1,
152            (b',', None) if depth == 0 => {
153                out.push(s.get(start..i).unwrap_or(""));
154                start = i + 1;
155            }
156            _ => {}
157        }
158    }
159    out.push(s.get(start..).unwrap_or(""));
160    out
161}
162
163/// 長さの値の中の `em` を、要素自身の文字サイズ `font_px` で px に置き換える。
164///
165/// 【2026-10-04】配置エンジンは `em` を常に 16px で換算していた
166/// (`layout/values.rs` の `parse_px_like`)。CSS Values 4 §6.1.1 では、
167/// font-size 以外のプロパティの `em` は**その要素自身の**計算済み font-size。
168/// sugi-lab.net/smarthome.htm の `.page-hero-content h1 { margin-top: 1em }`
169/// (h1 は 40px)が 16px になり、見出し帯が Chrome より 24px 短かった。
170///
171/// - `rem` は対象外(ルート基準で、別経路で 16px として正しく扱われている)
172/// - 省略記法(`1em 0 2em`)や `calc(1em + 2px)` の中も置き換える
173/// - 数値の直後が `em` で、その後ろが英数字でないときだけ単位とみなす
174/// - 端数は四捨五入。符号(`-`/`+`)は保つ
175///
176/// 文字単位で走査する(このクレートは `&str` のバイト添字スライスを禁止している)。
177pub fn resolve_em_lengths(value: &str, font_px: f32) -> alloc::string::String {
178    let chars: alloc::vec::Vec<char> = value.chars().collect();
179    let n = chars.len();
180    let mut out = alloc::string::String::with_capacity(value.len());
181    let mut i = 0;
182    while i < n {
183        let c = chars[i];
184        let at_boundary = i == 0 || {
185            let p = chars[i - 1];
186            !(p.is_ascii_alphanumeric() || p == '.' || p == '_')
187        };
188        let sign = c == '-' || c == '+';
189        let starts_number = c.is_ascii_digit()
190            || c == '.'
191            || (sign && i + 1 < n && (chars[i + 1].is_ascii_digit() || chars[i + 1] == '.'));
192        if at_boundary && starts_number {
193            let start = i;
194            let mut j = if sign { i + 1 } else { i };
195            let mut saw_digit = false;
196            while j < n && (chars[j].is_ascii_digit() || chars[j] == '.') {
197                saw_digit |= chars[j].is_ascii_digit();
198                j += 1;
199            }
200            let is_em = saw_digit
201                && chars.get(j).is_some_and(|u| u.eq_ignore_ascii_case(&'e'))
202                && chars.get(j + 1).is_some_and(|u| u.eq_ignore_ascii_case(&'m'))
203                && chars.get(j + 2).is_none_or(|u| !u.is_ascii_alphanumeric());
204            let num: alloc::string::String = chars[start..j].iter().collect();
205            if is_em {
206                if let Ok(v) = num.parse::<f32>() {
207                    let px = libm::roundf(v * font_px) as i64;
208                    if c == '+' {
209                        out.push('+');
210                    }
211                    out.push_str(&alloc::format!("{}px", px));
212                    i = j + 2;
213                    continue;
214                }
215            }
216            out.push_str(&num);
217            i = j;
218            continue;
219        }
220        out.push(c);
221        i += 1;
222    }
223    out
224}
225
226/// CSS 角度値(`<angle>`)を度(deg)単位の f32 に変換する。
227/// W3C CSS Values and Units Level 3 §6.1 準拠:
228/// - `deg`: 360deg = 1周
229/// - `grad`: 400grad = 360deg (1grad = 0.9deg)
230/// - `rad`: 2π rad = 360deg (1rad = 180 / π deg ≈ 57.2957795deg)
231/// - `turn`: 1turn = 360deg
232/// - 単位なし(0 または裸数値): 0 の場合は 0deg、それ以外も寛容に度としてパース。
233pub fn parse_angle_to_deg(s: &str) -> Option<f32> {
234    let t = s.trim();
235    if t.is_empty() {
236        return None;
237    }
238    let lower = t.to_ascii_lowercase();
239    let (num_str, scale) = if let Some(n) = lower.strip_suffix("deg") {
240        (n, 1.0)
241    } else if let Some(n) = lower.strip_suffix("grad") {
242        (n, 0.9)
243    } else if let Some(n) = lower.strip_suffix("turn") {
244        (n, 360.0)
245    } else if let Some(n) = lower.strip_suffix("rad") {
246        (n, 57.295_779_5)
247    } else {
248        (lower.as_str(), 1.0)
249    };
250    let val = num_str.trim().parse::<f32>().ok()?;
251    if !val.is_finite() {
252        return None;
253    }
254    Some(val * scale)
255}
256
257/// CSS 角度値(`<angle>`)をラジアン(rad)単位の f32 に変換する。
258pub fn parse_angle_to_rad(s: &str) -> Option<f32> {
259    parse_angle_to_deg(s).map(|deg| deg * (core::f32::consts::PI / 180.0))
260}
261
262/// CSS の `translate` 系プロパティの1成分(X軸またはY軸)を px(i32)へ解決する。
263/// - `px`: そのまま
264/// - `%`: `ref_size`(対象軸の要素サイズ)基準
265/// - `em`: `font_px`(要素のフォントサイズ)基準
266/// - `rem`: `ROOT_FONT_SIZE_PX` (16px) 基準
267/// - 裸数値: px として扱う
268pub fn resolve_translate_component(s: &str, ref_size: f32, font_px: f32) -> i32 {
269    let t = s.trim();
270    if t.is_empty() {
271        return 0;
272    }
273    let font = if font_px > 0.0 { font_px } else { ROOT_FONT_SIZE_PX };
274    let px = if let Some(n) = t.strip_suffix("px") {
275        n.trim().parse::<f32>().ok()
276    } else if let Some(n) = t.strip_suffix('%') {
277        n.trim().parse::<f32>().ok().map(|v| v / 100.0 * ref_size)
278    } else if let Some(n) = t.strip_suffix("rem") {
279        n.trim().parse::<f32>().ok().map(|v| v * ROOT_FONT_SIZE_PX)
280    } else if let Some(n) = t.strip_suffix("em") {
281        n.trim().parse::<f32>().ok().map(|v| v * font)
282    } else {
283        t.parse::<f32>().ok()
284    };
285    px.map(|v| libm::roundf(v) as i32).unwrap_or(0)
286}
287
288/// CSS transform 文字列をパースし、
289/// (tx, ty, scale_x, scale_y, rotate_deg, skew_x, skew_y)
290/// を返す。
291/// 要素の幅・高さ・フォントサイズを受け取り、パーセンテージ(要素サイズ基準)や em、
292/// および角度単位(deg, grad, rad, turn)を正確に解決する。
293pub fn parse_transform_with_size(
294    s: Option<&str>,
295    elem_w: f32,
296    elem_h: f32,
297    font_px: f32,
298) -> (i32, i32, f32, f32, f32, f32, f32) {
299    let s = match s {
300        Some(v) if v.trim() != "none" => v.trim(),
301        _ => return (0, 0, 1.0, 1.0, 0.0, 0.0, 0.0),
302    };
303    let mut tx = 0i32;
304    let mut ty = 0i32;
305    let mut scale_x = 1.0f32;
306    let mut scale_y = 1.0f32;
307    let mut rotate = 0.0f32;
308    let mut skew_x = 0.0f32;
309    let mut skew_y = 0.0f32;
310
311    let mut rest = s;
312    while !rest.is_empty() {
313        rest = rest.trim_start();
314        if let Some(after) = rest.strip_prefix("translateX(") {
315            if let Some((inside, after_rest)) = after.split_once(')') {
316                tx += resolve_translate_component(inside.trim(), elem_w, font_px);
317                rest = after_rest;
318            } else {
319                break;
320            }
321        } else if let Some(after) = rest.strip_prefix("translateY(") {
322            if let Some((inside, after_rest)) = after.split_once(')') {
323                ty += resolve_translate_component(inside.trim(), elem_h, font_px);
324                rest = after_rest;
325            } else {
326                break;
327            }
328        } else if let Some(after) = rest.strip_prefix("translate(") {
329            if let Some((args, after_rest)) = after.split_once(')') {
330                let mut it = args.splitn(2, ',');
331                let x_str = it.next().unwrap_or("").trim();
332                let y_str = it.next().unwrap_or("0").trim();
333                tx += resolve_translate_component(x_str, elem_w, font_px);
334                ty += resolve_translate_component(y_str, elem_h, font_px);
335                rest = after_rest;
336            } else {
337                break;
338            }
339        } else if let Some(after) = rest.strip_prefix("translate3d(") {
340            if let Some((args, after_rest)) = after.split_once(')') {
341                let mut it = args.splitn(3, ',');
342                let x_str = it.next().unwrap_or("").trim();
343                let y_str = it.next().unwrap_or("0").trim();
344                tx += resolve_translate_component(x_str, elem_w, font_px);
345                ty += resolve_translate_component(y_str, elem_h, font_px);
346                rest = after_rest;
347            } else {
348                break;
349            }
350        } else if let Some(after) = rest.strip_prefix("scale(") {
351            if let Some((inside, after_rest)) = after.split_once(')') {
352                let mut it = inside.split(',');
353                let vx = it.next().unwrap_or("1").trim().parse::<f32>().unwrap_or(1.0);
354                let vy = it
355                    .next()
356                    .map(|v| v.trim().parse::<f32>().unwrap_or(vx))
357                    .unwrap_or(vx);
358                scale_x *= vx;
359                scale_y *= vy;
360                rest = after_rest;
361            } else {
362                break;
363            }
364        } else if let Some(after) = rest.strip_prefix("scaleX(") {
365            if let Some((inside, after_rest)) = after.split_once(')') {
366                scale_x *= inside.trim().parse::<f32>().unwrap_or(1.0);
367                rest = after_rest;
368            } else {
369                break;
370            }
371        } else if let Some(after) = rest.strip_prefix("scaleY(") {
372            if let Some((inside, after_rest)) = after.split_once(')') {
373                scale_y *= inside.trim().parse::<f32>().unwrap_or(1.0);
374                rest = after_rest;
375            } else {
376                break;
377            }
378        } else if let Some(after) = rest.strip_prefix("rotate(") {
379            if let Some((inside, after_rest)) = after.split_once(')') {
380                if let Some(deg) = parse_angle_to_deg(inside.trim()) {
381                    rotate += deg;
382                }
383                rest = after_rest;
384            } else {
385                break;
386            }
387        } else if let Some(after) = rest.strip_prefix("matrix(") {
388            if let Some((args_str, after_rest)) = after.split_once(')') {
389                let args: alloc::vec::Vec<&str> = args_str.split(',').collect();
390                tx += args
391                    .get(4)
392                    .and_then(|v| v.trim().parse::<f32>().ok())
393                    .map(|v| v as i32)
394                    .unwrap_or(0);
395                ty += args
396                    .get(5)
397                    .and_then(|v| v.trim().parse::<f32>().ok())
398                    .map(|v| v as i32)
399                    .unwrap_or(0);
400                rest = after_rest;
401            } else {
402                break;
403            }
404        } else if let Some(after) = rest.strip_prefix("skewX(") {
405            if let Some((inside, after_rest)) = after.split_once(')') {
406                if let Some(deg) = parse_angle_to_deg(inside.trim()) {
407                    skew_x += deg;
408                }
409                rest = after_rest;
410            } else {
411                break;
412            }
413        } else if let Some(after) = rest.strip_prefix("skewY(") {
414            if let Some((inside, after_rest)) = after.split_once(')') {
415                if let Some(deg) = parse_angle_to_deg(inside.trim()) {
416                    skew_y += deg;
417                }
418                rest = after_rest;
419            } else {
420                break;
421            }
422        } else if let Some(after) = rest.strip_prefix("skew(") {
423            if let Some((args, after_rest)) = after.split_once(')') {
424                let mut it = args.splitn(2, ',');
425                let ax_str = it.next().unwrap_or("").trim();
426                if let Some(deg) = parse_angle_to_deg(ax_str) {
427                    skew_x += deg;
428                }
429                if let Some(ay_str) = it.next() {
430                    if let Some(deg) = parse_angle_to_deg(ay_str.trim()) {
431                        skew_y += deg;
432                    }
433                }
434                rest = after_rest;
435            } else {
436                break;
437            }
438        } else {
439            // 未知の関数: 閉じ括弧まで skip
440            if let Some((_, after_rest)) = rest.split_once(')') {
441                rest = after_rest;
442            } else {
443                break;
444            }
445        }
446    }
447
448    (tx, ty, scale_x, scale_y, rotate, skew_x, skew_y)
449}
450