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 がこれを基準に配置される。
510pub(crate) static ABS_CB_X: AtomicI32 = AtomicI32::new(0);
511pub(crate) static ABS_CB_Y: AtomicI32 = AtomicI32::new(0);
512pub(crate) static ABS_CB_W: AtomicI32 = AtomicI32::new(800);
513pub(crate) static ABS_CB_H: AtomicI32 = AtomicI32::new(600);
514// position:fixed の基準(ビューポート原点)。
515pub(crate) static VP_ORIGIN_X: AtomicI32 = AtomicI32::new(0);
516pub(crate) static VP_ORIGIN_Y: AtomicI32 = AtomicI32::new(0);
517
518pub(crate) fn abs_cb_get() -> (i32, i32, i32, i32) {
519    (
520        ABS_CB_X.load(AtomicOrdering::Relaxed),
521        ABS_CB_Y.load(AtomicOrdering::Relaxed),
522        ABS_CB_W.load(AtomicOrdering::Relaxed),
523        ABS_CB_H.load(AtomicOrdering::Relaxed),
524    )
525}
526pub(crate) fn abs_cb_set(x: i32, y: i32, w: i32, h: i32) {
527    ABS_CB_X.store(x, AtomicOrdering::Relaxed);
528    ABS_CB_Y.store(y, AtomicOrdering::Relaxed);
529    ABS_CB_W.store(w, AtomicOrdering::Relaxed);
530    ABS_CB_H.store(h, AtomicOrdering::Relaxed);
531}
532pub(crate) fn vp_origin_get() -> (i32, i32) {
533    (
534        VP_ORIGIN_X.load(AtomicOrdering::Relaxed),
535        VP_ORIGIN_Y.load(AtomicOrdering::Relaxed),
536    )
537}
538
539/// レイアウトパス開始前に web_engine から呼び、% 解決の基準サイズを設定する。
540pub fn set_viewport_hint(w: i32, h: i32) {
541    VIEWPORT_HINT_W.store(w.max(1), AtomicOrdering::Relaxed);
542    VIEWPORT_HINT_H.store(h.max(1), AtomicOrdering::Relaxed);
543}
544
545/// px はそのまま、% は base 基準で解決する (auto 等は None)。
546pub(crate) fn resolve_len_or_pct(s: &str, base: i32) -> Option<i32> {
547    match parse_length_value(Some(alloc::string::String::from(s))) {
548        Some(LengthValue::Px(px)) => Some(px),
549        Some(LengthValue::Percent(pct)) => Some(round_f32_to_i32(base as f32 * pct)),
550        _ => None,
551    }
552}
553
554pub(crate) fn get_replaced_element_size(node: &StyledNode) -> Option<(i32, i32)> {
555    let NodeType::Element {
556        tag_name,
557        attributes,
558        ..
559    } = &node.node.node_type
560    else {
561        return None;
562    };
563
564    // % はビューポートヒント基準で解決 (width:100% / height:100% の自動フィット対応)
565    let css_w = node
566        .value("width")
567        .and_then(|v| resolve_len_or_pct(&v, VIEWPORT_HINT_W.load(AtomicOrdering::Relaxed)));
568    let css_h = node
569        .value("height")
570        .and_then(|v| resolve_len_or_pct(&v, VIEWPORT_HINT_H.load(AtomicOrdering::Relaxed)));
571
572    let attr_w = attributes
573        .get("width")
574        .and_then(|v| v.trim().parse::<i32>().ok());
575    let attr_h = attributes
576        .get("height")
577        .and_then(|v| v.trim().parse::<i32>().ok());
578
579    let target_w = css_w.or(attr_w);
580    let target_h = css_h.or(attr_h);
581
582    match tag_name.as_str() {
583        "img" => {
584            if let (Some(w), Some(h)) = (target_w, target_h) {
585                return Some((w, h));
586            }
587            let mut resolved_w = 400;
588            let mut resolved_h = 200;
589            if let Some(src) = attributes.get("src") {
590                // 【2026-08-05】まず登録済みの自然サイズを見る。
591                //
592                // 以前は `src` が `/` 始まり(ローカル FS)のときしか
593                // 寸法を解決できず、ネットワーク画像は既定の 400×200 の
594                // ままだった。取得完了時に
595                // `register_image_natural_size` が登録するので、
596                // 次のレイアウトでここが拾う。
597                let registered = {
598                    let cache = IMAGE_SIZE_CACHE.lock();
599                    cache.as_ref().and_then(|c| c.get(src).copied())
600                };
601                if let Some((w, h)) = registered {
602                    resolved_w = w;
603                    resolved_h = h;
604                } else if src.starts_with('/') {
605                    {
606                        let mut cache = IMAGE_SIZE_CACHE.lock();
607                        if cache.is_none() {
608                            *cache = Some(BTreeMap::new());
609                        }
610                    }
611                    let mut cached = None;
612                    if let Some(ref cache) = *IMAGE_SIZE_CACHE.lock() {
613                        if let Some(&size) = cache.get(src) {
614                            cached = Some(size);
615                        }
616                    }
617                    if let Some((w, h)) = cached {
618                        resolved_w = w;
619                        resolved_h = h;
620                    } else {
621                        // 【2026-08-25 バグ修正】ここは **FS_LOCK を取らずに**
622                        // FS を読んでいた。レイアウトはブラウザ本体スレッドで
623                        // 走るが、画像・フォントのワーカースレッドは同時に
624                        // `FS_LOCK` を取ってキャッシュを**書いて**いる。
625                        // 書き込みの最中はビットマップとメタデータが
626                        // 一時的に矛盾するので、素で読むと壊れた中身が返る。
627                        //
628                        // 読み取りの間だけ保持し、`IMAGE_SIZE_CACHE` を
629                        // 触る前に手放す(ロックの入れ子を作らないため)。
630                        let content = {
631                            let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
632                            crate::kernel::fs::get_fs().read_file(src)
633                        };
634                        if let Some((_meta, content)) = content {
635                            if let Some((w, h)) = get_image_size(&content) {
636                                resolved_w = w;
637                                resolved_h = h;
638                                if let Some(ref mut cache) = *IMAGE_SIZE_CACHE.lock() {
639                                    cache.insert(String::from(src), (w, h));
640                                }
641                            }
642                        }
643                    }
644                }
645            }
646            Some((
647                target_w.unwrap_or(resolved_w),
648                target_h.unwrap_or(resolved_h),
649            ))
650        }
651        "video" => {
652            let default_w = 400;
653            let default_h = 240;
654            Some((target_w.unwrap_or(default_w), target_h.unwrap_or(default_h)))
655        }
656        "input" => {
657            let default_w = 300;
658            let default_h = 32;
659            Some((target_w.unwrap_or(default_w), target_h.unwrap_or(default_h)))
660        }
661        "textarea" => {
662            let default_w = 400;
663            let default_h = 200;
664            Some((target_w.unwrap_or(default_w), target_h.unwrap_or(default_h)))
665        }
666        "button" => {
667            let text = get_styled_node_text(node);
668            let font_size = node
669                .value("font-size")
670                .and_then(|v| parse_font_size_u32(&v))
671                .unwrap_or(16);
672            let text_w =
673                crate::kernel::vector_font::get_vector_string_width(&text, font_size) as i32;
674
675            let class_val = attributes.get("class").map(|s| s.as_str()).unwrap_or("");
676            let has_icon =
677                class_val == "save_btn" || class_val == "load_btn" || class_val == "wrap_btn";
678            let extra_w = if has_icon { 24 } else { 0 };
679            let button_w = (text_w + 24 + extra_w).max(120);
680            let button_h = 36;
681            Some((target_w.unwrap_or(button_w), target_h.unwrap_or(button_h)))
682        }
683        _ => None,
684    }
685}
686
687pub fn parse_line_height(val: Option<String>, font_size: u32) -> i32 {
688    let Some(s) = val else {
689        return (font_size as i32 + 4).max(12);
690    };
691    let t = s.trim();
692    if t.is_empty() || t == "normal" {
693        return (font_size as i32 + 4).max(12);
694    }
695    if let Some(num) = t.strip_suffix("px") {
696        return num
697            .trim()
698            .parse::<i32>()
699            .unwrap_or((font_size as i32 + 4).max(12));
700    }
701    if let Ok(factor) = t.parse::<f32>() {
702        return (font_size as f32 * factor) as i32;
703    }
704    (font_size as i32 + 4).max(12)
705}
706