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