Skip to main content

atmos/os_lib/layout/
values.rs

1// 分割: layout.rs より機械的に移動(2026-07-16 リファクタ フェーズ4)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5#[derive(Debug, Clone)]
6pub(crate) enum LengthValue {
7    Auto,
8    Px(i32),
9    Percent(f32),
10    /// min-content: 折返しを最大化した最小内容幅。
11    MinContent,
12    /// max-content: 折返しなしの最大内容幅。
13    MaxContent,
14    /// fit-content: min(max-content, available) を clamp。
15    FitContent,
16    /// fit-content(length): min(max-content, max(min-content, length))。
17    FitContentWith(alloc::boxed::Box<LengthValue>),
18    /// `calc()` / `min()` / `max()` / `clamp()`。containing width が必要なため
19    /// 式文字列のまま保持し、resolve_length_value で評価する。
20    MathExpr(String),
21}
22
23/// 長さのプロパティを読み、`em` を**要素自身の文字サイズ**で px へ置き換えて返す。
24///
25/// 【2026-10-04】`parse_px_like` は `em` を常に 16px で換算する。CSS Values 4 §6.1.1
26/// では font-size 以外のプロパティの `em` は要素自身の計算済み font-size 基準。
27/// width/height/min/max/gap を読む箇所はこれを通す
28/// (margin/padding/border は `style_box_edges` で同じ処理をしている)。
29/// 試験: `tests/src/test_layout_em_units.rs`
30pub(crate) fn len_value(node: &StyledNode, name: &str) -> Option<String> {
31    let v = node.value_ref(name)?;
32    // 小文字化した写しを作らずに "em" の有無を見る(レイアウトの最頻出の経路。2026-10-18)。
33    if !v.as_bytes().windows(2).any(|w| w.eq_ignore_ascii_case(b"em")) {
34        return Some(String::from(v));
35    }
36    let v = String::from(v);
37    let font_px = node
38        .value("font-size")
39        .and_then(|f| parse_font_size_u32(&f))
40        .unwrap_or(16) as f32;
41    Some(crate::os_lib::web_engine::css_units::resolve_em_lengths(&v, font_px))
42}
43
44pub(crate) fn parse_length_value<S: AsRef<str>>(value: Option<S>) -> Option<LengthValue> {
45    let v = value?;
46    let t = v.as_ref().trim();
47    if t.is_empty() {
48        return None;
49    }
50    if t.eq_ignore_ascii_case("auto") {
51        return Some(LengthValue::Auto);
52    }
53    if t.eq_ignore_ascii_case("min-content") {
54        return Some(LengthValue::MinContent);
55    }
56    if t.eq_ignore_ascii_case("max-content") {
57        return Some(LengthValue::MaxContent);
58    }
59    if t.eq_ignore_ascii_case("fit-content") {
60        return Some(LengthValue::FitContent);
61    }
62    if t.len() >= 13
63        && t.get(..12).is_some_and(|h| h.eq_ignore_ascii_case("fit-content("))
64        && t.ends_with(')')
65    {
66        let inner = t.get(12..t.len() - 1).unwrap_or("").trim();
67        if let Some(parsed) = parse_length_value(Some(inner)) {
68            return Some(LengthValue::FitContentWith(alloc::boxed::Box::new(parsed)));
69        }
70    }
71    if is_css_math_func(t) {
72        return Some(LengthValue::MathExpr(String::from(t)));
73    }
74    if let Some(num) = t.strip_suffix('%') {
75        if let Ok(pct) = num.trim().parse::<f32>() {
76            return Some(LengthValue::Percent(pct / 100.0));
77        }
78        return None;
79    }
80    parse_px_like(t).map(LengthValue::Px)
81}
82
83pub(crate) fn resolve_length_value(value: LengthValue, base: i32) -> Option<i32> {
84    resolve_length_value_with_font(value, base, 16.0)
85}
86
87pub(crate) fn resolve_length_value_with_font(value: LengthValue, base: i32, font_px: f32) -> Option<i32> {
88    match value {
89        LengthValue::Auto => None,
90        LengthValue::Px(px) => Some(px),
91        LengthValue::Percent(pct) => Some(round_f32_to_i32((base as f32) * pct)),
92        // 内容ベースのキーワードは base(コンテナ幅)からは解けない。呼び出し側で
93        // 内容計測して扱うため、min/max 制約としては None(無制約)扱いにする。
94        LengthValue::MinContent
95        | LengthValue::MaxContent
96        | LengthValue::FitContent
97        | LengthValue::FitContentWith(_) => None,
98        LengthValue::MathExpr(expr) => crate::os_lib::css::eval_css_math_with_font(&expr, base, font_px),
99    }
100}
101
102pub(crate) fn min_max(value: i32, min_v: Option<i32>, max_v: Option<i32>) -> i32 {
103    let mut v = value;
104    if let Some(min_v) = min_v {
105        v = v.max(min_v);
106    }
107    if let Some(max_v) = max_v {
108        v = v.min(max_v);
109    }
110    v
111}
112
113/// aspect-ratio 値を (num, den) の正の f32 ペアへ解釈する。`16/9` / `1.5` 形式。
114/// 不正・ゼロ・負は None。
115pub(crate) fn parse_aspect_ratio(s: &str) -> Option<(f32, f32)> {
116    let t = s.trim();
117    if t.is_empty() || t.eq_ignore_ascii_case("auto") {
118        return None;
119    }
120    let (num, den) = if let Some((ns, ds)) = t.split_once('/') {
121        (
122            ns.trim().parse::<f32>().ok()?,
123            ds.trim().parse::<f32>().ok()?,
124        )
125    } else {
126        (t.parse::<f32>().ok()?, 1.0)
127    };
128    if num > 0.0 && den > 0.0 {
129        Some((num, den))
130    } else {
131        None
132    }
133}
134
135pub(crate) fn round_f32_to_i32(value: f32) -> i32 {
136    if value >= 0.0 {
137        (value + 0.5) as i32
138    } else {
139        (value - 0.5) as i32
140    }
141}
142
143/// flex の gap を (row_gap, column_gap) px で返す。`gap` ショートハンド + `row-gap`/`column-gap`。
144/// `%` は `base`(コンテナ content 幅)基準で解決する。
145pub(crate) fn parse_flex_gap(node: &StyledNode, base: i32) -> (i32, i32) {
146    fn px(s: &str, base: i32) -> i32 {
147        match parse_length_value(Some(String::from(s.trim()))) {
148            Some(LengthValue::Px(p)) => p,
149            Some(LengthValue::Percent(pct)) => round_f32_to_i32(base as f32 * pct).max(0),
150            _ => 0,
151        }
152    }
153    let (mut row, mut col) = (0, 0);
154    if let Some(g) = len_value(node, "gap") {
155        let toks: Vec<&str> = g.split_whitespace().collect();
156        if toks.len() == 1 {
157            row = px(toks[0], base);
158            col = row;
159        } else if toks.len() >= 2 {
160            row = px(toks[0], base);
161            col = px(toks[1], base);
162        }
163    }
164    if let Some(rg) = len_value(node, "row-gap") {
165        row = px(&rg, base);
166    }
167    if let Some(cg) = len_value(node, "column-gap") {
168        col = px(&cg, base);
169    }
170    (row, col)
171}
172
173pub(crate) fn resolve_edge(s: &str, base: i32) -> Option<i32> {
174    match parse_length_value(Some(String::from(s))) {
175        Some(LengthValue::Px(px)) => Some(px),
176        Some(LengthValue::Percent(pct)) if base > 0 => Some(round_f32_to_i32(base as f32 * pct)),
177        _ => None,
178    }
179}
180
181pub(crate) fn is_css_math_func(s: &str) -> bool {
182    let t = s.trim();
183    t.starts_with("calc(") || t.starts_with("min(") || t.starts_with("max(") || t.starts_with("clamp(")
184        || t.starts_with("abs(") || t.starts_with("sign(") || t.starts_with("round(")
185        || t.starts_with("mod(") || t.starts_with("rem(")
186}
187
188pub(crate) fn apply_box_value(value: Option<String>, edges: &mut EdgeSizes, base: i32) {
189    let Some(v) = value else { return };
190    // calc()/min()/max()/clamp()/abs()/sign()/round()/mod()/rem() を単一値として扱う
191    let vt = v.trim();
192    if is_css_math_func(vt) {
193        if let Some(px) = crate::os_lib::css::eval_css_math(vt, base) {
194            edges.top = px;
195            edges.right = px;
196            edges.bottom = px;
197            edges.left = px;
198        }
199        return;
200    }
201    let parts: Vec<&str> = v.split_whitespace().collect();
202    if parts.is_empty() {
203        return;
204    }
205    // 【2026-10-04】値の位置を保つ。以前は解釈できない値(`auto`)を filter_map で
206    // 読み飛ばして詰めていたため、`margin:0 auto 1.5rem` が `[0, 24]` の 2 値扱い
207    // (上下 0・左右 24)になり、下マージンが消えていた(sugi-lab.net の研究テーマの
208    // カードで見出しがアイコンにくっついた)。`auto` の辺は触らない
209    // (左右の auto は中央寄せの処理が別に扱う)。
210    let vals: Vec<Option<i32>> = parts.iter().map(|p| resolve_edge(p, base)).collect();
211    if vals.iter().all(|v| v.is_none()) {
212        return;
213    }
214    let at = |i: usize| vals.get(i).copied().flatten();
215    let (top, right, bottom, left) = match vals.len() {
216        1 => (at(0), at(0), at(0), at(0)),
217        2 => (at(0), at(1), at(0), at(1)),
218        3 => (at(0), at(1), at(2), at(1)),
219        _ => (at(0), at(1), at(2), at(3)),
220    };
221    if let Some(v) = top {
222        edges.top = v;
223    }
224    if let Some(v) = right {
225        edges.right = v;
226    }
227    if let Some(v) = bottom {
228        edges.bottom = v;
229    }
230    if let Some(v) = left {
231        edges.left = v;
232    }
233}
234
235pub(crate) fn apply_edge_value(value: Option<String>, edge: &mut i32, base: i32) {
236    if let Some(v) = value {
237        let t = v.trim();
238        if is_css_math_func(t) {
239            if let Some(px) = crate::os_lib::css::eval_css_math(t, base) {
240                *edge = px;
241            }
242        } else if let Some(px) = resolve_edge(t, base) {
243            *edge = px;
244        }
245    }
246}
247
248pub(crate) fn parse_px_like(s: &str) -> Option<i32> {
249    let t = s.trim();
250    if t.is_empty() {
251        return None;
252    }
253    // `border-width`/`column-rule-width`等のキーワード値(丸ごと未対応
254    // だった)。`border`ショートハンド〔`css/shorthand.rs`の`parse_border_
255    // shorthand`〕は既にこれらを`1px`/`3px`/`5px`へ変換していたが、
256    // `border-width: thin;`のように longhand を単独指定した場合は
257    // `parse_length_value`(このヘルパを内部で使う)が`t.parse::<f32>()`
258    // 失敗で`None`を返し、幅が未設定のまま(暗黙の初期値へフォールバック)
259    // になっていた。`outline-width`(`web_engine/helpers.rs`の
260    // `parse_outline`)側は既にこの3キーワードへ対応済みで、`border-width`
261    // 側にだけ同型の兄弟ギャップが残っていた。2026-07-17 発見・実装。
262    match t {
263        "thin" => return Some(1),
264        "medium" => return Some(3),
265        "thick" => return Some(5),
266        _ => {}
267    }
268    if let Some(num) = t.strip_suffix("px") {
269        return num.trim().parse::<f32>().ok().map(round_f32_to_i32);
270    }
271    if let Some(num) = t.strip_suffix("vh") {
272        let h = VIEWPORT_HINT_H.load(AtomicOrdering::Relaxed);
273        return num
274            .trim()
275            .parse::<f32>()
276            .ok()
277            .map(|v| round_f32_to_i32((v / 100.0) * h as f32));
278    }
279    if let Some(num) = t.strip_suffix("vw") {
280        let w = VIEWPORT_HINT_W.load(AtomicOrdering::Relaxed);
281        return num
282            .trim()
283            .parse::<f32>()
284            .ok()
285            .map(|v| round_f32_to_i32((v / 100.0) * w as f32));
286    }
287    // `vmin`/`vmax`(丸ごと未対応だった。`vh`/`vw`は実装済みなのに、同じ
288    // ビューポート相対単位の仲間である`vmin`/`vmax`だけ抜けていた
289    // 「兄弟ギャップ」。レスポンシブデザインでよく使われる単位で、
290    // 未対応時は末尾の裸数値パースにフォールバックして失敗し`None`に
291    // なっていた。`in`(インチ、絶対単位)の`strip_suffix`判定より前に
292    // 置く必要がある——`vmin`/`vmax`は末尾2文字がそれぞれ`in`/`ax`であり、
293    // 特に`vmin`は`in`判定を先にすると`"50vmin"`が誤って`"50vm" + "in"`
294    // として吸収されてしまう)。2026-07-16 発見・実装。
295    if let Some(num) = t.strip_suffix("vmin") {
296        let w = VIEWPORT_HINT_W.load(AtomicOrdering::Relaxed);
297        let h = VIEWPORT_HINT_H.load(AtomicOrdering::Relaxed);
298        let m = w.min(h);
299        return num
300            .trim()
301            .parse::<f32>()
302            .ok()
303            .map(|v| round_f32_to_i32((v / 100.0) * m as f32));
304    }
305    if let Some(num) = t.strip_suffix("vmax") {
306        let w = VIEWPORT_HINT_W.load(AtomicOrdering::Relaxed);
307        let h = VIEWPORT_HINT_H.load(AtomicOrdering::Relaxed);
308        let m = w.max(h);
309        return num
310            .trim()
311            .parse::<f32>()
312            .ok()
313            .map(|v| round_f32_to_i32((v / 100.0) * m as f32));
314    }
315    if let Some(num) = t.strip_suffix("rem") {
316        return num
317            .trim()
318            .parse::<f32>()
319            .ok()
320            .map(|v| round_f32_to_i32(v * 16.0));
321    }
322    if let Some(num) = t.strip_suffix("em") {
323        return num
324            .trim()
325            .parse::<f32>()
326            .ok()
327            .map(|v| round_f32_to_i32(v * 16.0));
328    }
329    // 絶対長さ単位(`in`/`cm`/`mm`/`pt`/`pc`/`q`。丸ごと未対応だった。CSS
330    // Values and Units の規定どおり `96px = 1in` を基準に換算する
331    // (`font.rs`等では独自係数の`pt`扱いがあり得るが、こちらは`width`/
332    // `margin`等の一般長さ値のための正規換算)。2026-07-16 発見・実装。
333    if let Some(num) = t.strip_suffix("in") {
334        return num.trim().parse::<f32>().ok().map(|v| round_f32_to_i32(v * 96.0));
335    }
336    if let Some(num) = t.strip_suffix("cm") {
337        return num
338            .trim()
339            .parse::<f32>()
340            .ok()
341            .map(|v| round_f32_to_i32(v * 96.0 / 2.54));
342    }
343    if let Some(num) = t.strip_suffix("mm") {
344        return num
345            .trim()
346            .parse::<f32>()
347            .ok()
348            .map(|v| round_f32_to_i32(v * 96.0 / 25.4));
349    }
350    if let Some(num) = t.strip_suffix("pt") {
351        return num
352            .trim()
353            .parse::<f32>()
354            .ok()
355            .map(|v| round_f32_to_i32(v * 96.0 / 72.0));
356    }
357    if let Some(num) = t.strip_suffix("pc") {
358        return num.trim().parse::<f32>().ok().map(|v| round_f32_to_i32(v * 16.0));
359    }
360    if let Some(num) = t.strip_suffix('q') {
361        return num
362            .trim()
363            .parse::<f32>()
364            .ok()
365            .map(|v| round_f32_to_i32(v * 96.0 / 101.6));
366    }
367    t.parse::<f32>().ok().map(round_f32_to_i32)
368}
369
370/// letter-spacing 値を px(i32, 負も可) として解釈する。`normal`/未指定/不正は 0。
371/// `font_px` は要素自身の算出フォントサイズ(`em` 単位の計算に使用)。
372/// `calc()`/`min()`/`max()`/`clamp()` にも対応(`containing_width` は % 解決の基準、
373/// letter-spacing/word-spacing は通常 % を取らないため主に `calc(1px + 1px)` 等の用途)。
374pub(crate) fn parse_letter_spacing_with_font(val: Option<&str>, containing_width: i32, font_px: f32) -> i32 {
375    match val {
376        None => 0,
377        Some(s) => {
378            let t = s.trim();
379            if t.is_empty() || t.eq_ignore_ascii_case("normal") {
380                0
381            } else if is_css_math_func(t) {
382                crate::os_lib::css::eval_css_math_with_font(t, containing_width, font_px).unwrap_or(0)
383            } else if let Some(num) = t.strip_suffix("em") {
384                num.trim()
385                    .parse::<f32>()
386                    .ok()
387                    .map(|v| round_f32_to_i32(v * font_px))
388                    .unwrap_or(0)
389            } else {
390                parse_px_like(t).unwrap_or(0)
391            }
392        }
393    }
394}
395
396pub(crate) fn parse_letter_spacing_ctx(val: Option<&str>, containing_width: i32) -> i32 {
397    parse_letter_spacing_with_font(val, containing_width, 16.0)
398}
399
400/// 後方互換用(`containing_width` 不明な呼び出し元向け。calc() の % は解決できず0扱いになる)。
401pub(crate) fn parse_letter_spacing(val: Option<&str>) -> i32 {
402    parse_letter_spacing_ctx(val, 0)
403}
404
405/// word-spacing の値を px としてパース(letter-spacing と同じ規則)。
406pub(crate) fn parse_word_spacing_with_font(val: Option<&str>, containing_width: i32, font_px: f32) -> i32 {
407    parse_letter_spacing_with_font(val, containing_width, font_px)
408}
409
410/// word-spacing の値を px としてパース(letter-spacing と同じ規則)。
411pub(crate) fn parse_word_spacing_ctx(val: Option<&str>, containing_width: i32) -> i32 {
412    parse_letter_spacing_ctx(val, containing_width)
413}
414
415/// 後方互換用(`containing_width` 不明な呼び出し元向け)。
416pub(crate) fn parse_word_spacing(val: Option<&str>) -> i32 {
417    parse_letter_spacing_ctx(val, 0)
418}
419
420/// font-size の値を u32 としてパースする。
421///
422/// 【2026-07-28 二重実装を解消】以前はこの関数が `px` と `pt` しか解釈せず、
423/// `3rem` のような値で `None` を返して既定の 16px にフォールバックしていた。
424/// 一方で描画側は `css_units::parse_font_size`(px/pt/rem/em/単位なし対応)を
425/// 使っていたため、**レイアウトは 16px・描画は 48px** という食い違いが生じ、
426/// `.hero-title` が 1 行分(高さ 19px)しか場所を確保しないまま 3 行に
427/// 折り返されて、後続要素と重なっていた。
428///
429/// 同じ意味の解釈を 2 箇所に持つとこの種の不整合が必ず起きるため、
430/// レイアウト側も描画側と**同一の実装**へ委譲する。
431pub(crate) fn parse_font_size_u32(s: &str) -> Option<u32> {
432    crate::os_lib::web_engine::css_units::parse_font_size(s)
433}
434
435pub(crate) fn shift_layout_box(layout: &mut LayoutBox, dx: i32, dy: i32) {
436    layout.dimensions.content.x += dx;
437    layout.dimensions.content.y += dy;
438    for child in &mut layout.children {
439        shift_layout_box(child, dx, dy);
440    }
441}
442
443pub(crate) fn get_image_size(data: &[u8]) -> Option<(i32, i32)> {
444    if data.len() >= 24 && &data[0..8] == b"\x89PNG\r\n\x1a\n" && &data[12..16] == b"IHDR" {
445        let w = u32::from_be_bytes(data[16..20].try_into().ok()?) as i32;
446        let h = u32::from_be_bytes(data[20..24].try_into().ok()?) as i32;
447        return Some((w, h));
448    }
449    if data.len() >= 26 && &data[0..2] == b"BM" {
450        let w = i32::from_le_bytes(data[18..22].try_into().ok()?);
451        let h = i32::from_le_bytes(data[22..26].try_into().ok()?);
452        if w > 0 && h != 0 {
453            return Some((w, h.abs()));
454        }
455    }
456    if data.len() >= 4 && data[0] == 0xFF && data[1] == 0xD8 {
457        let mut i = 2;
458        while i + 8 < data.len() {
459            if data[i] != 0xFF {
460                i += 1;
461                continue;
462            }
463            let marker = data[i + 1];
464            if marker == 0xD9 || marker == 0xDA {
465                break;
466            }
467            if marker == 0x00 || (0xD0..=0xD7).contains(&marker) {
468                i += 2;
469                continue;
470            }
471            let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
472            if (0xC0..=0xCF).contains(&marker)
473                && marker != 0xC4
474                && marker != 0xC8
475                && marker != 0xCC
476                && i + 9 < data.len()
477            {
478                let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as i32;
479                let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as i32;
480                return Some((w, h));
481            }
482            i += 2 + len;
483        }
484    }
485    None
486}
487
488pub(crate) fn get_styled_node_text(node: &StyledNode) -> String {
489    let mut text = String::new();
490    if let NodeType::Text(t) = &node.node.node_type {
491        text.push_str(t);
492    }
493    for child in &node.children {
494        text.push_str(&get_styled_node_text(child));
495    }
496    text
497}
498
499pub(crate) fn parse_length_val_simple(s: &str) -> Option<i32> {
500    if let Some(LengthValue::Px(px)) = parse_length_value(Some(alloc::string::String::from(s))) {
501        Some(px)
502    } else {
503        None
504    }
505}
506
507// --- ビューポートヒント -----------------------------------------------------
508// 置換要素 (textarea/img/button) の `width/height: N%` を解決する基準値。
509// 本来 CSS の % は包含ブロック基準だが、置換要素の固有サイズ計算時点では
510// 包含ブロック高さが未確定のため、レイアウトパス開始時に web_engine が
511// 設定するビューポート (コンテンツ領域) サイズを近似基準として使う。
512// これにより `<textarea style="width:100%;height:100%">` がウィンドウへ
513// 自動フィットする。
514pub(crate) use core::sync::atomic::{AtomicBool, AtomicI32, Ordering as AtomicOrdering};
515pub static VIEWPORT_HINT_W: AtomicI32 = AtomicI32::new(800);
516pub static VIEWPORT_HINT_H: AtomicI32 = AtomicI32::new(600);
517
518/// `@container`(コンテナクエリ)評価時の**問い合わせコンテナ**の寸法。
519///
520/// `@media` はビューポート(上の `VIEWPORT_HINT_*`)を見ればよいが、
521/// `@container` は要素ごとに基準が変わる。カスケードは要素を辿りながら
522/// 走るので、レイアウト側が「いま評価している要素にとってのコンテナ」を
523/// ここへ入れてからカスケードを呼ぶ。
524///
525/// 【重要】コンテナ寸法はレイアウトが決まるまで分からないため、
526/// 初回カスケードでは前回のレイアウト結果(無ければビューポート幅)を
527/// 使う近似になる。CSS 仕様どおりの厳密な 2 パスではないが、
528/// 「解析時に静的評価して落とす」よりは実害が小さい
529/// (`@media` の静的評価でページが白紙化した 2026-07-24 の失敗を参照)。
530pub static CONTAINER_HINT_W: AtomicI32 = AtomicI32::new(0);
531pub static CONTAINER_HINT_H: AtomicI32 = AtomicI32::new(0);
532
533/// `@media (prefers-color-scheme: dark/light)` の判定に使うシステム設定のヒント。
534/// 既定は light(false)。実際のシステムテーマ選択への連動は未配線で、
535/// GUI 側が任意のタイミングで `store()` して更新できるようにするための土台。
536pub static PREFERS_DARK: AtomicBool = AtomicBool::new(false);
537
538/// `:target` 疑似クラスの判定に使う、現在の URL フラグメント(`#id` の `id` 部分、
539/// 無ければ空文字)。ページ遷移/`navigate_to_fragment` 時に web_engine 側が更新する。
540pub static CURRENT_TARGET_ID: spin::Mutex<alloc::string::String> =
541    spin::Mutex::new(alloc::string::String::new());
542
543/// `:focus-within` 疑似クラスの判定に使う、現在フォーカス中の要素自身とその祖先すべての
544/// ノードポインタアドレス(`&Node as *const Node as usize`)集合。id を持たない要素も
545/// 正しく判定できるよう、id ではなくノード実体の同一性で判定する(`:target` の
546/// `CURRENT_TARGET_ID` と同じ「セレクタマッチング関数からグローバルを読む」方式)。
547/// フォーカス変更のたびに web_engine 側が focused_id からルートまでの祖先パスを
548/// 再計算して更新する。
549pub static FOCUS_WITHIN_PTRS: spin::Mutex<alloc::collections::BTreeSet<usize>> =
550    spin::Mutex::new(alloc::collections::BTreeSet::new());
551
552/// `:lang()` 疑似クラスの判定に使う文書既定言語(`<html lang="...">` の値、小文字化済み)。
553/// 要素自身に `lang` 属性が無い場合のフォールバックとして使う(`lang` は本来祖先から
554/// 継承されるが、ここでは実用上ほとんどのケースをカバーする `<html>` の値のみを追跡する
555/// 簡略実装。`CURRENT_TARGET_ID` と同じ「セレクタマッチング関数からグローバルを読む」方式)。
556pub static DOCUMENT_LANG: spin::Mutex<alloc::string::String> =
557    spin::Mutex::new(alloc::string::String::new());
558
559// position:absolute の包含ブロック(最近傍 positioned 祖先の content box)。
560// レイアウト降下中に positioned 要素が自分へ切替え、子孫の absolute がこれを基準に配置される。
561/// 子の `height:%`(置換要素)の基準=包含ブロックの**確定した** content 高。
562/// -1 は「未確定」(仕様どおり `%` 高さは auto 扱い)。
563/// 【2026-09-26】ブロック(明示高さ・aspect-ratio)と flex(明示高さ)が子を組む間だけ
564/// 設定し、`PctBaseHGuard` の Drop で必ず戻す(途中 return があっても漏れない)。
565pub(crate) static PCT_BASE_H: AtomicI32 = AtomicI32::new(-1);
566
567/// `PCT_BASE_H` を一時的に差し替え、スコープを抜けると元へ戻す。
568pub(crate) struct PctBaseHGuard(i32);
569
570impl PctBaseHGuard {
571    pub(crate) fn set(h: i32) -> Self {
572        PctBaseHGuard(PCT_BASE_H.swap(h, AtomicOrdering::Relaxed))
573    }
574}
575
576impl Drop for PctBaseHGuard {
577    fn drop(&mut self) {
578        PCT_BASE_H.store(self.0, AtomicOrdering::Relaxed);
579    }
580}
581
582/// 次に組むブロックの content 高さを外から確定させる(-1 は無し)。`layout_block` の入口で
583/// 取り出して消費する(子孫へは伝わらない)。絶対配置の `top`+`bottom` から決めた高さで、
584/// 子の `height:%` もこれを基準に解決させるために使う(`tree.rs` の絶対配置)。
585pub(crate) static FORCED_BLOCK_H: AtomicI32 = AtomicI32::new(-1);
586
587pub(crate) static ABS_CB_X: AtomicI32 = AtomicI32::new(0);
588pub(crate) static ABS_CB_Y: AtomicI32 = AtomicI32::new(0);
589pub(crate) static ABS_CB_W: AtomicI32 = AtomicI32::new(800);
590pub(crate) static ABS_CB_H: AtomicI32 = AtomicI32::new(600);
591// position:fixed の基準(ビューポート原点)。
592pub(crate) static VP_ORIGIN_X: AtomicI32 = AtomicI32::new(0);
593pub(crate) static VP_ORIGIN_Y: AtomicI32 = AtomicI32::new(0);
594
595pub(crate) fn abs_cb_get() -> (i32, i32, i32, i32) {
596    (
597        ABS_CB_X.load(AtomicOrdering::Relaxed),
598        ABS_CB_Y.load(AtomicOrdering::Relaxed),
599        ABS_CB_W.load(AtomicOrdering::Relaxed),
600        ABS_CB_H.load(AtomicOrdering::Relaxed),
601    )
602}
603pub(crate) fn abs_cb_set(x: i32, y: i32, w: i32, h: i32) {
604    ABS_CB_X.store(x, AtomicOrdering::Relaxed);
605    ABS_CB_Y.store(y, AtomicOrdering::Relaxed);
606    ABS_CB_W.store(w, AtomicOrdering::Relaxed);
607    ABS_CB_H.store(h, AtomicOrdering::Relaxed);
608}
609pub(crate) fn vp_origin_get() -> (i32, i32) {
610    (
611        VP_ORIGIN_X.load(AtomicOrdering::Relaxed),
612        VP_ORIGIN_Y.load(AtomicOrdering::Relaxed),
613    )
614}
615
616/// レイアウトパス開始前に web_engine から呼び、% 解決の基準サイズを設定する。
617pub fn set_viewport_hint(w: i32, h: i32) {
618    VIEWPORT_HINT_W.store(w.max(1), AtomicOrdering::Relaxed);
619    VIEWPORT_HINT_H.store(h.max(1), AtomicOrdering::Relaxed);
620}
621
622/// px はそのまま、% は base 基準で解決する (auto 等は None)。
623pub(crate) fn resolve_len_or_pct(s: &str, base: i32) -> Option<i32> {
624    match parse_length_value(Some(alloc::string::String::from(s))) {
625        Some(LengthValue::Px(px)) => Some(px),
626        Some(LengthValue::Percent(pct)) => Some(round_f32_to_i32(base as f32 * pct)),
627        _ => None,
628    }
629}
630
631/// `a * num / den`(i64 で計算して丸める)。縦横比の換算用。den は 0 以外。
632fn scale_dim(a: i32, num: i32, den: i32) -> i32 {
633    ((a as i64 * num as i64 + den as i64 / 2) / den as i64) as i32
634}
635
636/// 置換要素の寸法(`%` はビューポート基準)。包含ブロック幅が分かる箇所では
637/// `get_replaced_element_size_in` を使うこと。
638pub(crate) fn get_replaced_element_size(node: &StyledNode) -> Option<(i32, i32)> {
639    get_replaced_element_size_in(node, Some(VIEWPORT_HINT_W.load(AtomicOrdering::Relaxed)))
640}
641
642/// 置換要素(img/video/input 等)の寸法。
643///
644/// `pct_base_w` は `width`/`max-width` の `%` の基準(包含ブロックの content 幅)。
645/// `None` は「基準が無い」(内在幅の計測中など)で、`%` 幅は auto 扱いになる。
646///
647/// 【2026-09-26 バグ修正】従来は `%` を常にビューポート基準で解決しており、
648/// `img{width:100%}` が親の幅ではなく画面幅いっぱい(1280px)になっていた
649/// (www.sugi-lab.net のギャラリー・研究紹介の画像)。併せて:
650/// - 幅か高さの片方だけ指定されたら、自然サイズの縦横比でもう片方を決める
651///   (従来は指定側だけ変わり画像が歪んだ)。
652/// - `max-width`/`max-height` を比率を保って適用する(`img{max-width:100%}` は
653///   レスポンシブ画像の定番)。
654/// - `height` の `%` は包含ブロックの高さが確定していれば(`PCT_BASE_H`)それ基準、
655///   未確定なら仕様どおり auto(従来はビューポート高さ基準で 800px 等になっていた)。
656pub(crate) fn get_replaced_element_size_in(
657    node: &StyledNode,
658    pct_base_w: Option<i32>,
659) -> Option<(i32, i32)> {
660    let NodeType::Element {
661        tag_name,
662        attributes,
663        ..
664    } = &node.node.node_type
665    else {
666        return None;
667    };
668    // 置換要素として扱うタグ以外は、寸法を調べる前に抜ける(下の match の `_ => None`。
669    // 【2026-10-18】全インライン要素で width/height/max-* の取得・解析をしてから None を返しており、
670    // YouTube の操作部のレイアウト時間の大きな割合を占めていた)。
671    if !matches!(tag_name.as_str(), "img" | "video" | "input" | "textarea" | "button") {
672        return None;
673    }
674
675    let len_w = |v: &str| -> Option<i32> {
676        match parse_length_value(Some(String::from(v))) {
677            Some(LengthValue::Px(px)) => Some(px),
678            Some(LengthValue::Percent(pct)) => pct_base_w.map(|b| round_f32_to_i32(b as f32 * pct)),
679            _ => None,
680        }
681    };
682    // `%` 高さは包含ブロックの高さが確定しているときだけ(`PCT_BASE_H`)。
683    let base_h = PCT_BASE_H.load(AtomicOrdering::Relaxed);
684    let len_h = |v: &str| -> Option<i32> {
685        match parse_length_value(Some(String::from(v))) {
686            Some(LengthValue::Px(px)) => Some(px),
687            Some(LengthValue::Percent(pct)) if base_h >= 0 => {
688                Some(round_f32_to_i32(base_h as f32 * pct))
689            }
690            _ => None,
691        }
692    };
693    let css_w = len_value(node, "width").and_then(|v| len_w(&v));
694    let css_h = len_value(node, "height").and_then(|v| len_h(&v));
695    let max_w = len_value(node, "max-width").and_then(|v| len_w(&v));
696    let max_h = len_value(node, "max-height").and_then(|v| len_h(&v));
697
698    let attr_w = attributes
699        .get("width")
700        .and_then(|v| v.trim().parse::<i32>().ok());
701    let attr_h = attributes
702        .get("height")
703        .and_then(|v| v.trim().parse::<i32>().ok());
704
705    let target_w = css_w.or(attr_w);
706    let target_h = css_h.or(attr_h);
707
708    match tag_name.as_str() {
709        "img" => {
710            if let (Some(w), Some(h)) = (target_w, target_h) {
711                return Some((w, h));
712            }
713            let mut resolved_w = 400;
714            let mut resolved_h = 200;
715            if let Some(src) = attributes.get("src") {
716                // 【2026-08-05】まず登録済みの自然サイズを見る。
717                //
718                // 以前は `src` が `/` 始まり(ローカル FS)のときしか
719                // 寸法を解決できず、ネットワーク画像は既定の 400×200 の
720                // ままだった。取得完了時に
721                // `register_image_natural_size` が登録するので、
722                // 次のレイアウトでここが拾う。
723                let registered = {
724                    let cache = IMAGE_SIZE_CACHE.lock();
725                    cache.as_ref().and_then(|c| c.get(src).copied())
726                };
727                if let Some((w, h)) = registered {
728                    resolved_w = w;
729                    resolved_h = h;
730                } else if src.starts_with('/') {
731                    {
732                        let mut cache = IMAGE_SIZE_CACHE.lock();
733                        if cache.is_none() {
734                            *cache = Some(BTreeMap::new());
735                        }
736                    }
737                    let mut cached = None;
738                    if let Some(ref cache) = *IMAGE_SIZE_CACHE.lock() {
739                        if let Some(&size) = cache.get(src) {
740                            cached = Some(size);
741                        }
742                    }
743                    if let Some((w, h)) = cached {
744                        resolved_w = w;
745                        resolved_h = h;
746                    } else {
747                        // 【2026-08-25 バグ修正】ここは **FS_LOCK を取らずに**
748                        // FS を読んでいた。レイアウトはブラウザ本体スレッドで
749                        // 走るが、画像・フォントのワーカースレッドは同時に
750                        // `FS_LOCK` を取ってキャッシュを**書いて**いる。
751                        // 書き込みの最中はビットマップとメタデータが
752                        // 一時的に矛盾するので、素で読むと壊れた中身が返る。
753                        //
754                        // 読み取りの間だけ保持し、`IMAGE_SIZE_CACHE` を
755                        // 触る前に手放す(ロックの入れ子を作らないため)。
756                        let content = {
757                            let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
758                            crate::kernel::fs::get_fs().read_file(src)
759                        };
760                        if let Some((_meta, content)) = content {
761                            if let Some((w, h)) = get_image_size(&content) {
762                                resolved_w = w;
763                                resolved_h = h;
764                                if let Some(ref mut cache) = *IMAGE_SIZE_CACHE.lock() {
765                                    cache.insert(String::from(src), (w, h));
766                                }
767                            }
768                        }
769                    }
770                }
771            }
772            // 片方だけ指定 → 自然サイズの比率でもう片方を決める。
773            let (mut w, mut h) = match (target_w, target_h) {
774                (Some(w), Some(h)) => (w, h),
775                (Some(w), None) if resolved_w > 0 => (w, scale_dim(w, resolved_h, resolved_w)),
776                (None, Some(h)) if resolved_h > 0 => (scale_dim(h, resolved_w, resolved_h), h),
777                (w, h) => (w.unwrap_or(resolved_w), h.unwrap_or(resolved_h)),
778            };
779            // max-width / max-height(明示されていない側は比率を保って縮める)。
780            if let Some(mw) = max_w {
781                if mw >= 0 && w > mw {
782                    if target_h.is_none() && w > 0 {
783                        h = scale_dim(mw, h, w);
784                    }
785                    w = mw;
786                }
787            }
788            if let Some(mh) = max_h {
789                if mh >= 0 && h > mh {
790                    if target_w.is_none() && h > 0 {
791                        w = scale_dim(mh, w, h);
792                    }
793                    h = mh;
794                }
795            }
796            Some((w.max(0), h.max(0)))
797        }
798        "video" => {
799            let default_w = 400;
800            let default_h = 240;
801            Some((target_w.unwrap_or(default_w), target_h.unwrap_or(default_h)))
802        }
803        "input" => {
804            let default_w = 300;
805            let default_h = 32;
806            Some((target_w.unwrap_or(default_w), target_h.unwrap_or(default_h)))
807        }
808        "textarea" => {
809            let default_w = 400;
810            let default_h = 200;
811            Some((target_w.unwrap_or(default_w), target_h.unwrap_or(default_h)))
812        }
813        "button" => {
814            let text = get_styled_node_text(node);
815            let font_size = node
816                .value("font-size")
817                .and_then(|v| parse_font_size_u32(&v))
818                .unwrap_or(16);
819            let text_w =
820                crate::kernel::vector_font::get_vector_string_width(&text, font_size) as i32;
821
822            let class_val = attributes.get("class").map(|s| s.as_str()).unwrap_or("");
823            let has_icon =
824                class_val == "save_btn" || class_val == "load_btn" || class_val == "wrap_btn";
825            let extra_w = if has_icon { 24 } else { 0 };
826            let button_w = (text_w + 24 + extra_w).max(120);
827            let button_h = 36;
828            Some((target_w.unwrap_or(button_w), target_h.unwrap_or(button_h)))
829        }
830        _ => None,
831    }
832}
833
834pub fn parse_line_height(val: Option<String>, font_size: u32) -> i32 {
835    let Some(s) = val else {
836        return (font_size as i32 + 4).max(12);
837    };
838    let t = s.trim();
839    if t.is_empty() || t == "normal" {
840        return (font_size as i32 + 4).max(12);
841    }
842    if let Some(num) = t.strip_suffix("px") {
843        return num
844            .trim()
845            .parse::<i32>()
846            .unwrap_or((font_size as i32 + 4).max(12));
847    }
848    if let Ok(factor) = t.parse::<f32>() {
849        return (font_size as f32 * factor) as i32;
850    }
851    (font_size as i32 + 4).max(12)
852}
853
854/// `border-radius`の1角分の半径値を解決する。
855/// `font_px` は要素自身の算出フォントサイズ(`em` 単位の計算に使用)。
856pub fn resolve_border_radius_with_font(s: &str, container_w: i32, font_px: f32) -> i32 {
857    let s = s.trim();
858    if let Some(num) = s.strip_suffix("px") {
859        return num.trim().parse::<f32>().map(|v| v as i32).unwrap_or(0);
860    }
861    if let Some(num) = s.strip_suffix('%') {
862        return num
863            .trim()
864            .parse::<f32>()
865            .map(|pct| ((pct / 100.0) * container_w as f32) as i32)
866            .unwrap_or(0);
867    }
868    if let Some(num) = s.strip_suffix("rem") {
869        return num.trim().parse::<f32>().map(|v| (v * 16.0) as i32).unwrap_or(0);
870    }
871    if let Some(num) = s.strip_suffix("em") {
872        return num.trim().parse::<f32>().map(|v| (v * font_px) as i32).unwrap_or(0);
873    }
874    s.parse::<f32>().map(|v| v as i32).unwrap_or(0)
875}
876
877pub fn resolve_border_radius(s: &str, container_w: i32) -> i32 {
878    resolve_border_radius_with_font(s, container_w, 16.0)
879}
880
881/// `top`/`left`/`right`/`bottom`/`inset` の長さ値をパースする。
882/// `font_px` は要素自身のフォントサイズ(`em` 単位の計算に使用)。`rem` は 16px 固定基準。
883pub fn parse_pos_len(v: &str, font_px: f32) -> Option<i32> {
884    let s = v.trim();
885    if let Some(num) = s.strip_suffix("px") {
886        num.trim().parse::<f32>().map(|v| v as i32).ok()
887    } else if let Some(num) = s.strip_suffix("rem") {
888        num.trim().parse::<f32>().map(|v| (v * 16.0) as i32).ok()
889    } else if let Some(num) = s.strip_suffix("em") {
890        num.trim().parse::<f32>().map(|v| (v * font_px) as i32).ok()
891    } else {
892        s.parse::<i32>().ok()
893    }
894}
895