Skip to main content

atmos/os_lib/web_engine/
layout.rs

1// web_engine/layout.rs - parse_and_layout and flatten_layout impl blocks
2#![allow(dead_code, unused_variables, unused_imports)]
3
4use crate::kernel::draw;
5use crate::kernel::vector_font;
6use alloc::string::String;
7use alloc::vec::Vec;
8
9use super::draw_helpers::{extract_border_color, extract_border_style};
10use super::helpers::{
11    ascii_eq_ignore_case, extract_all_url_paths, extract_list_style, extract_list_style_image,
12    extract_list_style_inside, extract_url_path,
13    get_base_path,
14    parse_bg_position, parse_bg_repeat, parse_bg_size, parse_font_size, parse_object_fit,
15    parse_object_position, parse_outline, parse_px_val, parse_text_decoration_shorthand,
16    parse_transform, parse_transform_origin, resolve_dom_paths, resolve_relative_path,
17};
18use super::{HyperlinkArea, RenderElement, MAX_HTML_BYTES, MAX_RENDER_ELEMENTS};
19use crate::os_lib::css::parse_color;
20
21/// `line-height`(丸ごと未対応だった単位あり。以前は `px`(絶対値)と裸数値
22/// (`font-size`への倍率。仕様どおり子要素へも同じ倍率が継承される)の2種
23/// のみ対応で、`%`(`line-height:150%`)/`em`(`line-height:1.5em`)は
24/// `t.parse::<f32>()`が単位付き文字列を数値としてパースできず静かに失敗し、
25/// `(font_size + 4)`という汎用フォールバック値へ落ちていた(エラーになら
26/// ず一見動いているように見えるため気づきにくいバグだった)。`%`/`em`は
27/// どちらも意味上「font-sizeへの倍率」で裸数値と等価のため、同じ倍率計算
28/// 式へ合流させる。2026-07-17 発見・実装(自己テストのため`parse_and_
29/// layout`内のネスト関数からモジュールトップレベルへ移動。ロジック不変)。
30pub(crate) fn parse_line_height(s: &str, font_size: u32) -> i32 {
31    let t = s.trim();
32    if let Some(num) = t.strip_suffix("px") {
33        if let Ok(px) = num.trim().parse::<f32>() {
34            return px as i32;
35        }
36    }
37    if let Some(num) = t.strip_suffix('%') {
38        if let Ok(pct) = num.trim().parse::<f32>() {
39            return (font_size as f32 * pct / 100.0) as i32;
40        }
41    }
42    if let Some(num) = t.strip_suffix("em") {
43        if let Ok(ratio) = num.trim().parse::<f32>() {
44            return (font_size as f32 * ratio) as i32;
45        }
46    }
47    if let Ok(ratio) = t.parse::<f32>() {
48        return (font_size as f32 * ratio) as i32;
49    }
50    (font_size + 4) as i32
51}
52
53/// `text-indent`(丸ごと未対応だった単位あり。以前は`px`と裸数値〔`px`
54/// 扱い〕のみ対応で、`%`(包含ブロック幅基準)/`em`(font-size基準)は
55/// `t.parse::<f32>()`が単位付き文字列を数値としてパースできず静かに
56/// 失敗し、インデント無し(0)へフォールバックしていた。古典的な段落
57/// インデントの定番`text-indent:2em`や、レスポンシブな`text-indent:5%`
58/// が効かなくなっていた(エラーにならず一見正常に描画されるため気づき
59/// にくいバグだった)。2026-07-17 発見・実装(自己テストのためモジュール
60/// トップレベル関数として新設。呼び出し元は1箇所のみ)。
61pub(crate) fn resolve_text_indent(s: &str, container_width: i32, font_size: u32) -> i32 {
62    let t = s.trim();
63    let px = if let Some(num) = t.strip_suffix("px") {
64        num.trim().parse::<f32>().ok()
65    } else if let Some(num) = t.strip_suffix('%') {
66        num.trim()
67            .parse::<f32>()
68            .ok()
69            .map(|v| v / 100.0 * container_width as f32)
70    } else if let Some(num) = t.strip_suffix("em") {
71        num.trim().parse::<f32>().ok().map(|v| v * font_size as f32)
72    } else {
73        t.parse::<f32>().ok()
74    };
75    px.map(|f| f as i32).unwrap_or(0)
76}
77
78/// `box-shadow`/`text-shadow`のオフセット/ぼかし長さトークンを解決する
79/// (丸ごと未対応だった単位あり。以前は`px`のみ対応で、`rem`(`box-shadow:
80/// 0.5rem 0.5rem 1rem red`のような相対単位指定)は`token.strip_suffix
81/// ("px")`に一致せず数値パースにも失敗するため、呼び出し元の位置カウンタ
82/// `px_count`が進まないまま静かに無視されていた——エラーにはならず`color`
83/// だけ正しく拾われるため、「オフセット0・ぼかし0の見えない影」という
84/// 気づきにくい壊れ方をしていた。`em`は要素自身の算出フォントサイズが
85/// この関数の引数だけでは解決できないため対象外のまま(`resolve_
86/// translate_len`と同じ判断)。2026-07-17 発見・実装(自己テストのため
87/// モジュールトップレベル関数として新設。呼び出し元は`box-shadow`/
88/// `text-shadow`の2箇所)。
89pub(crate) fn parse_shadow_len_tok(token: &str) -> Option<i32> {
90    if let Some(n) = token.strip_suffix("px") {
91        return n.parse::<f32>().ok().map(|v| v as i32);
92    }
93    if let Some(n) = token.strip_suffix("rem") {
94        return n.parse::<f32>().ok().map(|v| (v * 16.0) as i32);
95    }
96    None
97}
98
99/// `aspect-ratio`(CSS Box Sizing Level 4。`16 / 9`, `4/3`, `1`, `auto 16/9` 等)を解釈する。
100pub(crate) fn parse_aspect_ratio(val: &str) -> Option<f32> {
101    let s = val.trim().to_lowercase();
102    if s.is_empty() || s == "auto" || s == "none" {
103        return None;
104    }
105    let s_ref = if s.starts_with("auto ") {
106        s.trim_start_matches("auto ").trim()
107    } else {
108        s.as_str()
109    };
110    if let Some((num_str, den_str)) = s_ref.split_once('/') {
111        let num: f32 = num_str.trim().parse().ok()?;
112        let den: f32 = den_str.trim().parse().ok()?;
113        if den != 0.0 {
114            return Some(num / den);
115        }
116    } else if let Ok(v) = s_ref.parse::<f32>() {
117        if v > 0.0 {
118            return Some(v);
119        }
120    }
121    None
122}
123
124/// `border-radius`の1角分の半径値を解決する(丸ごと未対応だった単位あり。
125/// 以前は`px`/`%`のみ対応で、`rem`(`border-radius:1rem`のような相対単位
126/// 指定)は単位付き文字列のパース失敗で静かに`0`〔角丸無し〕へフォール
127/// バックしていた。`box-shadow`/`text-shadow`の`parse_shadow_len_tok`と
128/// 同型のバグ。`em`は要素自身の算出フォントサイズがこの関数の引数だけ
129/// では解決できないため対象外のまま(`resolve_translate_len`と同じ判断)。
130/// 2026-07-17 発見・実装(自己テストのためクロージャからモジュール
131/// トップレベル関数へ変更。呼び出し元は1箇所のみ)。
132pub(crate) fn resolve_border_radius(s: &str, container_w: i32) -> i32 {
133    let s = s.trim();
134    if let Some(num) = s.strip_suffix("px") {
135        return num.trim().parse::<f32>().map(|v| v as i32).unwrap_or(0);
136    }
137    if let Some(num) = s.strip_suffix('%') {
138        return num
139            .trim()
140            .parse::<f32>()
141            .map(|pct| (pct / 100.0 * container_w as f32) as i32)
142            .unwrap_or(0);
143    }
144    if let Some(num) = s.strip_suffix("rem") {
145        return num.trim().parse::<f32>().map(|v| (v * 16.0) as i32).unwrap_or(0);
146    }
147    s.parse::<f32>().map(|v| v as i32).unwrap_or(0)
148}
149
150/// DOM ツリーを走査して全 `<style>` 要素の textContent を連結して返す。
151/// 元の HTML 文字列ではなく実際のツリーを見ることで、`document.createElement('style')` 等で
152/// 動的に追加された `<style>` 要素のスタイルも取得できる。
153fn collect_style_blocks_from_dom(node: &crate::os_lib::dom::Node) -> String {
154    let mut out = String::new();
155    fn walk(node: &crate::os_lib::dom::Node, out: &mut String) {
156        if let crate::os_lib::dom::NodeType::Element { tag_name, .. } = &node.node_type {
157            if tag_name == "style" {
158                for child in &node.children {
159                    if let crate::os_lib::dom::NodeType::Text(t) = &child.node_type {
160                        out.push_str(t);
161                        out.push('\n');
162                    }
163                }
164                // <style> は子要素として再帰する必要が無い(中身はテキストのみ)。
165                return;
166            }
167        }
168        for child in &node.children {
169            walk(child, out);
170        }
171    }
172    walk(node, &mut out);
173    out
174}
175
176/// DOM ツリーを走査して外部 `<link rel="stylesheet" href="...">` の href を集める。
177/// href は resolve_dom_paths 済み(絶対パス or 完全URL)。以前はインライン `<style>`
178/// しか集めておらず、外部 CSS ファイルを一切取得・適用していなかったため、CSS を
179/// 全て外部ファイルに置くサイト(一般的な構成)ではスタイルが丸ごと無視され、
180/// 素の HTML 同然の表示になっていた。`rel` に `stylesheet` を含むもの、または
181/// `rel` 未指定で href が `.css` で終わるものを対象とする(`preload`/`icon` 等は除外)。
182fn collect_external_css_urls(node: &crate::os_lib::dom::Node) -> alloc::vec::Vec<String> {
183    let mut out = alloc::vec::Vec::new();
184    fn walk(node: &crate::os_lib::dom::Node, out: &mut alloc::vec::Vec<String>) {
185        if let crate::os_lib::dom::NodeType::Element {
186            tag_name,
187            attributes,
188            ..
189        } = &node.node_type
190        {
191            if tag_name == "link" {
192                let rel = attributes
193                    .get("rel")
194                    .map(|s| s.to_lowercase())
195                    .unwrap_or_default();
196                let href = attributes.get("href").cloned().unwrap_or_default();
197                let is_css = rel.contains("stylesheet")
198                    || (rel.is_empty() && href.to_lowercase().ends_with(".css"));
199                if is_css && !href.is_empty() {
200                    out.push(href);
201                }
202            }
203        }
204        for child in &node.children {
205            walk(child, out);
206        }
207    }
208    walk(node, &mut out);
209    out
210}
211
212/// DOM ツリーを走査して全 `<script>` 要素を集める。`src` 属性があれば
213/// (Some(resolved_src), "") を、無ければインライン本文 (None, text) を返す。
214/// 以前は生 HTML 文字列を正規表現的にスキャンするだけで、`<script src="...">` の
215/// 外部スクリプトは本文が空のため完全に無視されていた(fetch すら行われない)。
216fn collect_scripts_from_dom(node: &crate::os_lib::dom::Node) -> alloc::vec::Vec<(Option<String>, String)> {
217    let mut out = alloc::vec::Vec::new();
218    fn walk(
219        node: &crate::os_lib::dom::Node,
220        out: &mut alloc::vec::Vec<(Option<String>, String)>,
221    ) {
222        if let crate::os_lib::dom::NodeType::Element {
223            tag_name,
224            attributes,
225            ..
226        } = &node.node_type
227        {
228            if tag_name == "script" {
229                if let Some(src) = attributes.get("src") {
230                    if !src.is_empty() {
231                        out.push((Some(src.clone()), String::new()));
232                        return;
233                    }
234                }
235                let mut text = String::new();
236                for child in &node.children {
237                    if let crate::os_lib::dom::NodeType::Text(t) = &child.node_type {
238                        text.push_str(t);
239                    }
240                }
241                out.push((None, text));
242                return;
243            }
244        }
245        for child in &node.children {
246            walk(child, out);
247        }
248    }
249    walk(node, &mut out);
250    out
251}
252
253impl super::WebEngine {
254    /// 計算量: **O(H + R×Sel + N×(C×M + K) + N×D)**
255    ///
256    /// - H: HTML バイト数(パース)
257    /// - R×Sel: CSS ルール索引の構築
258    /// - N×(C×M + K): `style_tree`(下記参照)
259    /// - N×D: `layout_tree`
260    ///
261    /// 下層の積み上げ(規約は `spec/complexity_annotation.md`):
262    /// `rule_index::active_candidates` O(1+C) → `style_tree` O(R×Sel + N×(C×M+K))
263    /// → ここ。
264    ///
265    /// 実測(sugi-lab.net: N=358, R=2403、計 607 tick ≒ 6 秒):
266    ///
267    /// | 段 | tick |
268    /// |----|------|
269    /// | CSS 取得(キャッシュ命中時) | 23 |
270    /// | `style_tree` | 291 |
271    /// | `layout_tree` | 60 |
272    ///
273    /// **`style_tree` の中ではセレクタ照合ではなくノードごとの固定コスト K
274    /// (継承マップ等の複製)が支配的**。照合は索引で全体の 2.0% まで落ちている。
275    pub fn parse_and_layout(
276        &mut self,
277        html: &str,
278        pre_parsed_dom: Option<crate::os_lib::dom::Node>,
279        top_offset: i32,
280        win_w: u32,
281        win_h: u32,
282    ) {
283        crate::debug!("[GPU] TRACE: parse_and_layout enter. len={}", html.len());
284        {
285            let mut head = 200.min(html.len());
286            while head > 0 && !html.is_char_boundary(head) {
287                head -= 1;
288            }
289            crate::println!("[CSSDBG] recv HTML len={} head200={:?}", html.len(), html.get(..head).unwrap_or(""));
290        }
291
292        // 上限を超える巨大ページは UTF-8 境界で安全に切り捨てる(ヒープ枯渇の予防)。
293        let html = if html.len() > MAX_HTML_BYTES {
294            let mut cut = MAX_HTML_BYTES;
295            while cut > 0 && !html.is_char_boundary(cut) {
296                cut -= 1;
297            }
298            crate::info!("[GPU] WEB_ENGINE: HTML truncated {} -> {} bytes", html.len(), cut);
299            html.get(..cut).unwrap_or(html)
300        } else {
301            html
302        };
303
304        crate::debug!("[GPU] TRACE: parse_and_layout allocating self.last_html");
305        // 新ページ読込(dom_needs_rebuild)でのみ画像キャッシュを破棄する。
306        // JS による textContent/style 変更などのインクリメンタル再レイアウトでは
307        // 画像を保持し、毎回の再フェッチ暴走(同じ src の再ダウンロード)を防ぐ。
308        let is_new_page = self.dom_needs_rebuild || self.last_html != html;
309        // 【2026-08-03】スクロール位置をリセットしてよいのは
310        // **文書そのものが変わったとき**だけ。
311        //
312        // `is_new_page` は `dom_needs_rebuild` でも真になる。しかし
313        // `dom_needs_rebuild` は `:target` の再判定やフォント到着など
314        // 「同じ文書の作り直し」でも立つ。これでスクロールを 0 に戻すと、
315        // `#id` アンカーへスクロールした直後に先頭へ引き戻されてしまう
316        // (実測: `[NAV] フラグメントへスクロール #research` は発火するのに
317        //  画面は先頭のままだった)。
318        let is_document_changed = self.last_html != html;
319        self.last_html = String::from(html);
320        // 【2026-08-05 修正】キャッシュを捨ててよいのは
321        // **文書そのものが変わったとき**だけ。
322        //
323        // `is_new_page` は `dom_needs_rebuild` でも真になるが、
324        // このフラグはフォント/画像の到着による「同じ文書の作り直し」でも立つ。
325        // それでキャッシュを消すと、届いた画像が捨てられて無限に取り直しになる
326        // (実測: 同じ画像が 2 回デコードされ、`#projects` の画像が
327        //  alt テキストのままだった)。
328        //
329        // 逆にキャッシュを守るために `dom_needs_rebuild` を立てないようにすると、
330        // DOM ブリッジが組み直されず**要素の位置が壊れる**
331        // (実測: ヒーローのグラデーションが全ページに掛かった)。
332        //
333        // 正しくは「DOM は組み直す・キャッシュは残す」。両者を分ける。
334        if is_document_changed {
335            crate::debug!("[GPU] TRACE: parse_and_layout clearing image_cache (new page)");
336            self.image_cache = alloc::collections::BTreeMap::new();
337            // 共有の置き場も一緒に捨てる(同じ画素を指しているため)。
338            super::image::clear_shared_images();
339            self.image_attempted = alloc::collections::BTreeSet::new();
340            self.scroll_regions = alloc::collections::BTreeMap::new();
341            // 【2026-07-24発見・修正】`external_css_cache`はページ遷移開始時
342            // (`load_url`/`load_html`)に`None`へリセットされるが、実際の
343            // ページ内容が届くまでの待機中に旧ページ(プレースホルダー等)の
344            // 内容で毎フレーム`parse_and_layout`が呼ばれ続けるため、実ページの
345            // HTMLが届く前に`Some(旧ページのCSS)`で埋まってしまい、実ページの
346            // 実際の`<link>`が一度も取得されないまま丸ごとスキップされる重大な
347            // バグだった(`is_new_page`(内容変化検知)の度に確実に空へ戻す)。
348            self.external_css_cache = None;
349        }
350        // scroll_containers はページ再レイアウトのたびに再構築する。
351        self.scroll_containers = alloc::vec::Vec::new();
352        crate::debug!("[GPU] TRACE: parse_and_layout clearing elements");
353        self.elements = Vec::new();
354        self.links = Vec::new();
355        self.page_bg_color = None;
356        self.page_text_color = None;
357        crate::debug!("[GPU] TRACE: parse_and_layout setting title");
358        self.title = String::from("Untitled Page");
359        self.focused_id = None;
360        self.active_text_box = None;
361        self.win_w = win_w;
362        self.win_h = win_h;
363        self.top_offset = top_offset;
364
365        crate::debug!("[GPU] TRACE: parse_and_layout start");
366        // 1. DOM パーサ。
367        // DOM ブリッジが既に構築済み(built)かつ JS 変更で dirty な場合、最終的な
368        // 描画ツリーはブリッジの to_dom_node() から作る。その場合 parse_html の結果は
369        // 破棄されるため、再パース自体を省略して確保コストを削る。
370        // ブリッジ未構築 or 新ページ(再構築要求)時のみ HTML を実際にパースする。
371        let bridge_is_source = {
372            let dom = self.js_runtime.dom.clone();
373            let b = dom.borrow();
374            b.built && b.dirty && !self.dom_needs_rebuild
375        };
376        // 【2026-08-05 計測】再レイアウト 1 回が実測 4.6 秒かかる。
377        // `style_tree` は 24〜200 tick なので主役ではない。
378        // どの段が重いのかを段ごとに測る(推測で手を入れないため)。
379        // 目的: 再レイアウトの律速の特定。破棄条件: 特定でき次第削除。
380        let t_stage0 = crate::kernel::timer::get_ticks();
381        let mut root_node = if bridge_is_source {
382            // ブリッジから後で差し替えるので、ここでは空ルートで十分。
383            crate::os_lib::dom::Node::empty_root()
384        } else if let Some(dom) = pre_parsed_dom {
385            dom
386        } else {
387            crate::os_lib::dom::parse_html(html)
388        };
389        let t_html_end = crate::kernel::timer::get_ticks();
390        crate::debug!(
391            "[GPU] TRACE: parse_html done (bridge_source={})",
392            bridge_is_source
393        );
394
395        // 相対パスの解決(実際に HTML をパースした時のみ意味がある)。
396        if !bridge_is_source {
397            let base_path = get_base_path(&self.current_path);
398            resolve_dom_paths(&mut root_node, &base_path);
399        }
400
401        // DOM ブリッジ: 新ページなら再構築。以降は **ブリッジを真実源**として描画ツリーを
402        // 生成する(textContent/style/class の変更に加え、createElement/appendChild 等の
403        // 動的ノードも反映される)。
404        {
405            let dom = self.js_runtime.dom.clone();
406            let mut b = dom.borrow_mut();
407            // 【2026-07-24発見・修正】コメントは「新ページなら再構築」と書いてあった
408            // にもかかわらず、実際の条件は`is_new_page`を一切見ていなかった。
409            // このため、プレースホルダー(ロード中表示)ページで一度ブリッジが
410            // 構築された(`built=true`)後、実ページのHTMLが届いて`root_node`が
411            // 正しく最新の内容(例: 365要素)に切り替わっても、`dom_needs_rebuild`
412            // が別途明示的に立てられない限りブリッジは古いプレースホルダーの
413            // ノード(例: 33ノード)のまま据え置かれていた。結果、`document.body`/
414            // `querySelector`等JS側から見えるDOMは常に古いページの内容のままとなり、
415            // 実サイトの`js/modern-script.js`が`document.querySelector('.nav-list')`
416            // で`null`を受け取り`Cannot read properties of null (reading 'style')`
417            // で即座に停止する原因になっていた(レンダリング用ツリーは正しかった
418            // ため、この不一致は画面には出ず、JS実行時エラーとしてのみ表れていた)。
419            // `external_css_cache`(同セッションで既に発見・修正済み)と全く同じ
420            // 「プレースホルダー状態のキャッシュが実ページ到着後も無効化されない」
421            // というバグクラスの再発。
422            if self.dom_needs_rebuild || !b.built || is_new_page {
423                b.build_from(&root_node); // この後 dirty=false
424                self.dom_needs_rebuild = false;
425                // 新ページ: アニメーション状態を全てリセット。
426                self.anim_engine.clear();
427                self.anim_specs.clear();
428                self.anim_prev_values.clear();
429            }
430            // JS が DOM を実際に変更した時だけブリッジから全ツリーを再構築する。
431            // 静的ページや、JS 変更の無いサイズ変更再レイアウトでは to_dom_node の
432            // ツリー全クローン(重い)を省略でき、確保回数を大幅に削減する。
433            if b.built && b.dirty {
434                root_node = b.to_dom_node();
435                // dirty をここでクリア(遅延クリア)。relayout_if_dom_dirty が
436                // dirty を消す前に parse_and_layout へ到達できるよう、to_dom_node()
437                // 呼び出し後にクリアして無限ループを防ぐ。
438                b.dirty = false;
439            }
440        }
441
442        // 2. CSS スタイリング(<style> ... </style> を抽出して適用)。
443        // root_node(ページ初回は静的パース結果、以降は DOM ブリッジ再構築後のツリー)を
444        // 直接走査して <style> の textContent を集める。生 HTML 文字列の再スキャンではなく
445        // 実際のツリーを見ることで、`document.createElement('style')` 等で動的に追加された
446        // <style> 要素のスタイルも反映される(以前は元の HTML 文字列しか見ておらず、
447        // 動的なスタイル注入が完全に無視されるバグだった)。
448        // インクリメンタル再レイアウトでキャッシュを効かせるため、抽出結果を文字列キーにする
449        // 点は従来どおり(同一なら parse_css を丸ごと省略)。
450        // 外部 <link rel=stylesheet> の CSS を初回だけ取得してキャッシュする
451        // (毎フレームの再取得を防ぐ。詳細は external_css_cache のコメント参照)。
452        if self.external_css_cache.is_none() {
453            fn dbg_count(node: &crate::os_lib::dom::Node, tag: &str) -> usize {
454                let mut n = 0;
455                if let crate::os_lib::dom::NodeType::Element { tag_name, .. } = &node.node_type {
456                    if tag_name == tag {
457                        n += 1;
458                    }
459                }
460                for c in &node.children {
461                    n += dbg_count(c, tag);
462                }
463                n
464            }
465            crate::println!(
466                "[CSSDBG] tags html={} head={} body={} link={} script={} style={}",
467                dbg_count(&root_node, "html"),
468                dbg_count(&root_node, "head"),
469                dbg_count(&root_node, "body"),
470                dbg_count(&root_node, "link"),
471                dbg_count(&root_node, "script"),
472                dbg_count(&root_node, "style"),
473            );
474            let urls = collect_external_css_urls(&root_node);
475            crate::println!("[CSSDBG] external <link> css found: {}", urls.len());
476            let mut ext = String::new();
477            // 【2026-08-25 バグ修正】1 本でも取れなかったら**保存しない**。
478            // 保存条件を厳しくする代わりに、呼び出し側の無条件破棄をやめる。
479            let mut all_fetched = true;
480            for url in &urls {
481                crate::println!("[CSSDBG] fetching external css {}", url);
482                if let Some(text) = self.fetch_script_text(url) {
483                    crate::println!("[CSSDBG] fetched {} bytes", text.len());
484                    let abs_url = self.resolve_page_url(url);
485                    let rewritten = super::url_resolve::rewrite_css_urls(&text, &abs_url);
486                    // 【2026-07-25発見・修正】TLS読み取りエラー等でCSSが波括弧の
487                    // 途中で打ち切られたまま後続CSSと連結されると、閉じられなかった
488                    // `{`のせいでパーサーが以降の本物のルールを大量に読み飛ばす
489                    // 重大バグだった(`rules.len()`が2000超のはずが122まで激減する
490                    // 事例で発見)。バランスの取れた最後のトップレベルルールまでに
491                    // 安全側で切り詰めてから連結する。
492                    let balanced = super::url_resolve::truncate_to_balanced_css(&rewritten);
493                    if balanced.len() != rewritten.len() {
494                        // 【2026-08-03 復旧】CSS を途中で切り詰めた事実は
495                        // **異常として残す**(`spec/logging_policy.md` L-1)。
496                        // 一括でログ整理したとき誤って消してしまい、
497                        // 空の if だけが残っていた。
498                        // 切り詰めが起きるとルールが丸ごと欠落し、
499                        // 「なぜかスタイルが当たらない」という形で表面化する。
500                        crate::warn!(
501                            "[CSS] 不完全な CSS を切り詰めた url={} {} -> {} バイト",
502                            url,
503                            rewritten.len(),
504                            balanced.len()
505                        );
506                    }
507                    ext.push_str(balanced);
508                    ext.push('\n');
509                } else {
510                    crate::println!("[CSSDBG] fetch FAILED {}", url);
511                    all_fetched = false;
512                }
513            }
514            // 【2026-08-05】CSS 非同期化の調査用(R-10: 変更より先に計装)。
515            // 「外部 CSS をいくつ見つけて、何バイト取れたか」。
516            // 目的: 非同期化時に「取れているのに当たらない」を切り分ける。
517            // 破棄条件: 非同期化が入るか、断念が確定したら削除。
518            crate::warn!("[CSSTRACE] 抽出 ext_len={}", ext.len());
519            // 【2026-08-25 バグ修正】以前はここで**無条件に**保存していた。
520            //
521            // 非同期取得では初回に間に合わない CSS があり、空や欠けた
522            // 結果がそのまま居座る。その対策として呼び出し側
523            // (`render.rs` の到着による再レイアウト)が毎回
524            // `external_css_cache = None` で捨てていたが、これは
525            // **全部取り直す**という意味になる。実際、フォントや画像が
526            // 届くたびに外部 CSS を再取得してページを組み直しており、
527            // 背景写真と本文フォントが間に合う前に描き直されていた
528            // (実測 4 回中 2 回、`total=13` に届かず `total=9` で静止)。
529            //
530            // 直し方は `cache::write_cache` と同じで、
531            // **捨てる側ではなく入れる側を守る**。
532            // 全部揃ったときだけ保存すれば、欠けたものは
533            // 次の機会に自然に取り直され、揃ったものは使い回される。
534            if all_fetched {
535                self.external_css_cache = Some(ext);
536            } else {
537                crate::warn!(
538                    "[CSS] 取得できない外部CSSがあるので保存しない(次回取り直す) 総数={}",
539                    urls.len()
540                );
541                self.external_css_cache = None;
542            }
543        }
544        // カスケード順は文書順(<link> は通常 <head>、<style> はその後)に合わせ、
545        // 外部 CSS を先、インライン <style> を後に連結する。
546        let inline_css = {
547            let mut s = self.external_css_cache.clone().unwrap_or_default();
548            s.push_str(&collect_style_blocks_from_dom(&root_node));
549            s
550        };
551        crate::warn!(
552            "[CSSTRACE] inline_len={} 解析キャッシュ命中={} 既存ルール数={}",
553            inline_css.len(),
554            self.cached_stylesheet.is_some() && self.cached_css_key == inline_css,
555            self.cached_stylesheet.as_ref().map(|s| s.rules.len()).unwrap_or(0)
556        );
557        let _pk = super::perf::start();
558        let key_hit = self.cached_stylesheet.is_some() && self.cached_css_key == inline_css;
559        super::perf::add(super::perf::Slot::CssKeyCmp, _pk);
560        let stylesheet = if key_hit {
561            crate::debug!("[GPU] TRACE: parse_css cache hit");
562            match &self.cached_stylesheet {
563                Some(s) => s.clone(),
564                None => crate::os_lib::css::parse_css(&inline_css),
565            }
566        } else {
567            let vw_before_parse = crate::os_lib::layout::VIEWPORT_HINT_W
568                .load(core::sync::atomic::Ordering::Relaxed);
569            let _pp = super::perf::start();
570            let s = crate::os_lib::css::parse_css(&inline_css);
571            super::perf::add(super::perf::Slot::CssParse, _pp);
572            let preview: String = inline_css.chars().take(300).collect();
573            self.cached_css_key = inline_css;
574            self.cached_stylesheet = Some(s.clone());
575            s
576        };
577
578        // 【2026-08-03 発見・修正】`@font-face` の取り込みを
579        // **CSS キャッシュがミスしたときだけ**に限定していた。
580        //
581        // 「ネットワーク往復を伴うから」という理由で 2026-07-22 に入れた制限だが、
582        // その後フォント取得は非同期(`font_queue`)+キャッシュ優先になり、
583        // 前提が崩れていた。結果として:
584        //
585        // 1. 初回レイアウト: フォントは未取得なので `Pending`(登録できない)
586        // 2. 背景スレッドが取得完了してキャッシュへ保存
587        // 3. 再レイアウト: **CSS はキャッシュ命中するのでここが呼ばれない**
588        //
589        // → 取得できているのに**同一ブート内で永久に登録されない**。
590        //    実測: 8 本取得成功・`registered=` は 0 件。
591        //
592        // 毎レイアウト呼んでも安全かつ安価:
593        // - 登録済みファミリは `has_custom_font` で即スキップ
594        // - 未取得のものは `font_queue` の `seen` で重複投入されない
595        // - キャッシュ命中したものだけが実際に登録される
596        if !stylesheet.font_faces.is_empty() {
597            // 【2026-08-05 計測】`css` 区間が最大 2315tick(23 秒)を占める。
598            // この区間には「外部 CSS の同期取得」と「@font-face の登録」の
599            // 両方が入っている。どちらが重いのかを分ける。
600            // 目的: レイアウトの律速の特定。破棄条件: 特定でき次第削除。
601            let t_ff = crate::kernel::timer::get_ticks();
602            self.fetch_and_register_font_faces(&stylesheet.font_faces);
603            // 【2026-08-05 試して戻した】「全部積んでからまとめて起こす」
604            // (`enqueue_kind_deferred` + `spawn_workers`)を試したが**回帰した**。
605            //
606            // 狙いは、まとめ取得へ載る数を増やすこと
607            // (1 本目で起こすと残りが並ぶ前に取得が始まる)。
608            // ハンドシェイクは 12 → 11 に減ったが、
609            // **フォントも背景写真も届かなくなった**
610            // (`total=11`、`[FQDIAG] iters=0 spawn=0` で
611            //   ワーカーが一度も起動していない)。
612            //
613            // 起こす契機を 1 箇所に集めたため、その 1 回が
614            // `fetch_limit` の空き待ちに当たると誰も起こし直さない。
615            super::perf::add(super::perf::Slot::FontFace, t_ff);
616            let ff_ticks = crate::kernel::timer::get_ticks().wrapping_sub(t_ff);
617            if ff_ticks > 20 {
618                crate::warn!(
619                    "[PERF][LAYOUT] font_face登録={}tick 件数={}",
620                    ff_ticks,
621                    stylesheet.font_faces.len()
622                );
623            }
624        }
625        crate::debug!("[GPU] TRACE: parse_css done");
626        let t_after_css = crate::kernel::timer::get_ticks();
627
628        // `:focus-within` 判定用: 現在フォーカス中の要素自身とその祖先すべてのノード
629        // ポインタアドレスを再計算する(id ではなくノード実体の同一性で判定するため、
630        // id を持たない要素も正しく機能する)。
631        {
632            fn collect_focus_within_path(
633                node: &crate::os_lib::dom::Node,
634                focused_id: &str,
635                path: &mut alloc::vec::Vec<usize>,
636            ) -> bool {
637                path.push(node as *const _ as usize);
638                if let crate::os_lib::dom::NodeType::Element { id, .. } = &node.node_type {
639                    if id.as_deref() == Some(focused_id) {
640                        return true;
641                    }
642                }
643                for child in &node.children {
644                    if collect_focus_within_path(child, focused_id, path) {
645                        return true;
646                    }
647                }
648                path.pop();
649                false
650            }
651            let mut set = crate::os_lib::layout::FOCUS_WITHIN_PTRS.lock();
652            set.clear();
653            if let Some(fid) = &self.focused_id {
654                let mut path = alloc::vec::Vec::new();
655                if collect_focus_within_path(&root_node, fid, &mut path) {
656                    set.extend(path);
657                }
658            }
659        }
660
661        // `:lang()` 判定用: <html lang="..."> の値を文書既定言語として記録する
662        // (要素自身に lang 属性が無い場合のフォールバックとして使う)。
663        {
664            fn find_html_lang(node: &crate::os_lib::dom::Node) -> Option<alloc::string::String> {
665                if let crate::os_lib::dom::NodeType::Element {
666                    tag_name,
667                    attributes,
668                    ..
669                } = &node.node_type
670                {
671                    if tag_name == "html" {
672                        if let Some(l) = attributes.get("lang") {
673                            return Some(l.to_lowercase());
674                        }
675                    }
676                }
677                for child in &node.children {
678                    if let Some(l) = find_html_lang(child) {
679                        return Some(l);
680                    }
681                }
682                None
683            }
684            let mut doc_lang = crate::os_lib::layout::DOCUMENT_LANG.lock();
685            *doc_lang = find_html_lang(&root_node).unwrap_or_default();
686        }
687
688        // 【2026-07-31計測】レイアウト 1 回が 32〜40 秒かかる原因を切り分ける。
689        // どの段が重いのか推測で手を入れないため、段ごとの tick を測る。
690        let t_css_end = crate::kernel::timer::get_ticks();
691        let t_style_start = crate::kernel::timer::get_ticks();
692        // 【2026-08-05】非同期版で `style_tree` が完了しない件の切り分け。
693        // 同期版と**渡る木そのものが違う**のではないかを確かめる。
694        // 木が壊れて(循環して)いれば走査は終わらない。
695        // 深さは上限を設けて数える(循環していても数え終わる)。
696        {
697            fn count(n: &crate::os_lib::dom::Node, depth: usize, limit: usize) -> (usize, usize) {
698                if depth >= limit {
699                    return (1, depth);
700                }
701                let mut total = 1usize;
702                let mut maxd = depth;
703                for c in &n.children {
704                    let (t, d) = count(c, depth + 1, limit);
705                    total += t;
706                    if d > maxd {
707                        maxd = d;
708                    }
709                }
710                (total, maxd)
711            }
712            let (nodes, depth) = count(&root_node, 0, 200);
713            crate::warn!(
714                "[CSSTRACE] カスケード開始 ルール数={} ノード数={} 深さ={}",
715                stylesheet.rules.len(),
716                nodes,
717                depth
718            );
719        }
720        let styled_tree = crate::os_lib::css::style_tree(&root_node, &stylesheet);
721        let t_style_end = crate::kernel::timer::get_ticks();
722        crate::warn!("[CSSTRACE] カスケード完了");
723        // 【2026-08-05】計測はしていたのに**どこにも出していなかった**ため、
724        // 手を入れた効果を確かめられなかった(`spec/logging_policy.md` L-4)。
725        // 目的: カスケードの高速化の効果測定。1 レイアウトに 1 回だけで毎フレームではない。
726        // 破棄条件: style_tree が 100 tick を安定して下回ったら削除する。
727
728
729        // scroll-behavior: smooth はルート要素(<html>)への指定のみ対応(簡略実装)。
730        self.scroll_smooth = styled_tree
731            .value("scroll-behavior")
732            .map(|v| v.trim().eq_ignore_ascii_case("smooth"))
733            .unwrap_or(false);
734
735        // `scrollbar-width: none` はルート要素(<html>)への指定のみ対応(簡略実装。
736        // `scroll-behavior`と同じ方式)。2026-07-18 発見・実装。
737        self.scrollbar_width_none = styled_tree
738            .value("scrollbar-width")
739            .map(|v| v.trim().eq_ignore_ascii_case("none"))
740            .unwrap_or(false);
741
742        // ルート要素の margin 指定をチェック
743        let mut margin_x = 40;
744        let mut margin_y = 20;
745        if let Some(m) = styled_tree.value("margin") {
746            if m == "0" {
747                margin_x = 0;
748                margin_y = 0;
749            }
750        }
751
752        // 3. レイアウト構築
753        let content_w = (win_w as i32 - (margin_x * 2)).max(400);
754        // 置換要素の width/height % 解決用ビューポートヒント
755        // (バー類と余白を除いた実コンテンツ領域)
756        let viewport_h_hint = (win_h as i32 - top_offset - margin_y * 2).max(100);
757        crate::os_lib::layout::set_viewport_hint(content_w, viewport_h_hint);
758        let initial_containing_block = crate::os_lib::layout::Dimensions {
759            content: crate::os_lib::layout::Rect {
760                x: margin_x,
761                y: margin_y,
762                width: content_w,
763                height: 0,
764            },
765            ..Default::default()
766        };
767        let t_layout_start = crate::kernel::timer::get_ticks();
768        let layout_tree =
769            crate::os_lib::layout::layout_tree(&styled_tree, initial_containing_block);
770        let t_layout_end = crate::kernel::timer::get_ticks();
771        crate::warn!("[CSSTRACE] レイアウト木完了");
772        crate::debug!("[GPU] TRACE: layout_tree done");
773
774        // 4. フラットな RenderElement リストへの変換 (レガシー描画部との統合)
775        let t_flatten_start = crate::kernel::timer::get_ticks();
776        let initial_text_color = self.page_text_color;
777        self.flatten_layout(
778            &layout_tree,
779            "",
780            "GET",
781            "",
782            false,
783            false,
784            false,
785            false,
786            initial_text_color,
787            16,
788            "",
789            None,
790            "",
791            None,
792            String::new(),
793            1,
794            0,
795            false,
796            false,
797        );
798        let t_flatten_end = crate::kernel::timer::get_ticks();
799        crate::warn!("[CSSTRACE] flatten 完了");
800        // 【一時】ヘッダ帯の要素を列挙して、ナビ項目がどの経路で
801        // どんな矩形になっているかを見る。特定でき次第削除する。
802        crate::warn!(
803            "[PERF][LAYOUT] html={} css={} style={} layout={} flatten={} 合計={}tick 要素={}",
804            t_html_end.wrapping_sub(t_stage0),
805            t_css_end.wrapping_sub(t_html_end),
806            t_style_end.wrapping_sub(t_style_start),
807            t_layout_end.wrapping_sub(t_layout_start),
808            t_flatten_end.wrapping_sub(t_flatten_start),
809            t_flatten_end.wrapping_sub(t_stage0),
810            self.elements.len()
811        );
812
813        // 要素矩形を DOM ブリッジへ反映(getBoundingClientRect / offsetWidth 等が参照)。
814        // element_id→node idx の解決に immutable borrow が要るので、先に収集してから書き込む。
815        let id_rects: alloc::vec::Vec<(usize, i32, i32, i32, i32, i32, i32, i32, i32)> = {
816            let dom = self.js_runtime.dom.borrow();
817            // 【2026-08-05】ここは要素 1 個ごとに `get_element_by_id` を呼んでいた。
818            //
819            // `get_element_by_id` は**全 DOM ノードの線形走査**(しかも各ノードで
820            // `is_attached` が親を辿る)。要素は実測 369 個、ノードも同程度あるので
821            // O(要素 × ノード × 深さ) になっていた。
822            // さらに id を持たない要素では毎回 `format!("_ta_{}", idx)` で
823            // **文字列を確保**してから、まず一致しない検索を走らせていた。
824            //
825            // id → ノード添字の対応を**1 回だけ**作って引く。
826            // 計算量は O(要素 × ノード) から O(要素 + ノード) へ。
827            let id_map = dom.build_id_index();
828            self.elements
829                .iter()
830                .enumerate()
831                .filter_map(|(idx, e)| {
832                    let node_idx = if let Some(i) = e.node_idx {
833                        Some(i)
834                    } else if !e.element_id.is_empty() {
835                        id_map.get(e.element_id.as_str()).copied()
836                    } else {
837                        // textarea だけが `_ta_<idx>` という合成 id を持つ。
838                        // 持たない要素で毎回 `format!` するのは無駄なので、
839                        // 索引が空でない場合だけ組み立てる。
840                        if id_map.is_empty() {
841                            None
842                        } else {
843                            id_map
844                                .get(alloc::format!("_ta_{}", idx).as_str())
845                                .copied()
846                        }
847                    };
848                    node_idx.map(|i| {
849                        (
850                            i,
851                            e.x_offset,
852                            e.y_offset,
853                            e.width,
854                            e.height,
855                            e.border_top_width,
856                            e.border_right_width,
857                            e.border_bottom_width,
858                            e.border_left_width,
859                        )
860                    })
861                })
862                .collect()
863        };
864        {
865            let mut dom = self.js_runtime.dom.borrow_mut();
866            dom.clear_rects();
867            for (i, x, y, w, h, bt, br, bb, bl) in id_rects {
868                dom.set_rect(i, x, y, w, h);
869                dom.set_border_widths(i, bt, br, bb, bl);
870            }
871        }
872
873        // getComputedStyle 用に算出スタイル(全 Element ノードの specified_values)を反映。
874        // 以前は `id` 属性を持つ要素(または transition/animation を持ち合成 id が
875        // 登録された要素)にしか computed_styles が反映されず、`id` の無い要素の
876        // `getComputedStyle()` は `n.style`(インライン `style=""` のみ)にしか
877        // フォールバックできず、実際にカスケードされたスタイルシート由来の値を
878        // 一切反映していなかった(現実の大多数のHTML要素は `id` を持たないため
879        // 影響範囲が広い重大な既知ギャップだった)。`dom_idx` は元々
880        // DomBridge の pre-order 走査インデックスと1:1で同期していたため、
881        // id 文字列経由の間接的な解決を経由せず直接 idx をキーに使うよう修正。
882        // 2026-07-18 発見・実装。
883        {
884            fn collect_styles(
885                node: &crate::os_lib::css::StyledNode,
886                out: &mut alloc::vec::Vec<(usize, alloc::vec::Vec<(String, String)>)>,
887                // anim_out: transition/animation を持つ全要素。
888                // id あり → そのまま id を使う。id なし → "_ta_<pos>" の合成 id を DomBridge に登録。
889                anim_out: &mut alloc::vec::Vec<(String, alloc::vec::Vec<(String, String)>)>,
890                counter: &mut usize,
891                dom_nodes: &mut alloc::vec::Vec<crate::os_lib::js::dom_bridge::DomNode>,
892            ) {
893                // DomBridge の pre-order 走査インデックスと同期するためカウントアップ(テキストノード含む)。
894                let dom_idx = *counter;
895                *counter += 1;
896                if let crate::os_lib::dom::NodeType::Element { id, .. } = &node.node.node_type {
897                    let props: alloc::vec::Vec<(String, String)> = node
898                        .specified_values
899                        .iter()
900                        .map(|(k, v)| (k.clone(), v.clone()))
901                        .collect();
902                    // `transition-property`/`animation-name` はショートハンド無しの個別
903                    // ロングハンド指定を検知するための足がかり(`synthesize_transition_shorthand`/
904                    // `synthesize_animation_shorthand` と対になる。以前は `transition`/`animation`
905                    // ショートハンドの有無しか見ておらず、ロングハンドのみの要素がこの時点で
906                    // 追跡対象から除外され、後段の合成ロジックまで到達しなかった)。
907                    let has_anim = props.iter().any(|(k, _)| {
908                        matches!(
909                            k.as_str(),
910                            "transition" | "animation" | "transition-property" | "animation-name"
911                        )
912                    });
913                    // computed_styles への反映は id の有無に関わらず常に行う(dom_idx 直接使用)。
914                    out.push((dom_idx, props.clone()));
915                    if let Some(eid) = id {
916                        // 明示 id あり: アニメーション追跡にそのまま使う。
917                        if has_anim {
918                            anim_out.push((eid.clone(), props));
919                        }
920                    } else {
921                        // 明示 id なし: 合成 id を DomBridge に登録(getBoundingClientRect/id_rects サポート用)。
922                        let synthetic = alloc::format!("_ta_{}", dom_idx);
923                        if let Some(dn) = dom_nodes.get_mut(dom_idx) {
924                            if dn.id.is_empty() {
925                                dn.id = synthetic.clone();
926                            }
927                        }
928                        if has_anim {
929                            let use_id = dom_nodes.get(dom_idx)
930                                .filter(|dn| !dn.id.is_empty())
931                                .map(|dn| dn.id.clone())
932                                .unwrap_or(synthetic);
933                            anim_out.push((use_id, props));
934                        }
935                    }
936                }
937                for c in &node.children {
938                    collect_styles(c, out, anim_out, counter, dom_nodes);
939                }
940            }
941            let mut collected = alloc::vec::Vec::new();
942            let mut anim_collected = alloc::vec::Vec::new();
943            let mut _counter = 0usize;
944            {
945                let mut dom = self.js_runtime.dom.borrow_mut();
946                collect_styles(&styled_tree, &mut collected, &mut anim_collected, &mut _counter, &mut dom.nodes);
947            }
948            {
949                let mut dom = self.js_runtime.dom.borrow_mut();
950                dom.clear_computed();
951                for (i, props) in collected {
952                    for (k, v) in props {
953                        dom.set_computed_style(i, &k, &v);
954                    }
955                }
956            }
957
958            // --- CSS アニメーション/トランジションの同期 ---
959            // anim_collected: transition/animation を持つ全要素(明示 id + 合成 id)。
960            self.sync_animations(&anim_collected, &stylesheet);
961        }
962
963        // --- サブスクロール領域の同期 ---
964        // 1. JS が el.scrollTop を書き込んでいれば scroll_regions へ反映する。
965        //    (dom.scroll_tops[idx] → 要素 id → scroll_regions[id])
966        {
967            let js_tops: alloc::vec::Vec<(String, i32)> = {
968                let dom = self.js_runtime.dom.borrow();
969                dom.scroll_tops
970                    .iter()
971                    .filter_map(|(idx, top)| {
972                        let id = dom.nodes.get(*idx).map(|n| n.id.clone())?;
973                        if id.is_empty() { None } else { Some((id, *top)) }
974                    })
975                    .collect()
976            };
977            for (id, top) in js_tops {
978                if let Some(entry) = self.scroll_regions.get_mut(&id) {
979                    *entry = top;
980                }
981            }
982        }
983        // 2. レンダラが把握した scroll_regions / scroll_containers を dom へ反映する。
984        //    (scroll_regions[id] → node_idx → dom.scroll_tops)
985        //    (scroll_max_y + height → dom.scroll_heights)
986        {
987            let sync_data: alloc::vec::Vec<(usize, i32, i32)> = {
988                let dom = self.js_runtime.dom.borrow();
989                self.scroll_containers
990                    .iter()
991                    .filter_map(|(cid, max_y, _cx, _cy, _cw, ch)| {
992                        let idx = dom.get_element_by_id(cid)?;
993                        let cur = self.scroll_regions.get(cid).copied().unwrap_or(0);
994                        Some((idx, cur, max_y + ch))
995                    })
996                    .collect()
997            };
998            let mut dom = self.js_runtime.dom.borrow_mut();
999            for (idx, cur, sh) in sync_data {
1000                dom.scroll_tops.insert(idx, cur);
1001                dom.scroll_heights.insert(idx, sh);
1002            }
1003        }
1004
1005        // スクロール限界の設定 (ビューポートは win_h - top_offset)
1006        let content_height = layout_tree.dimensions.margin_box().height + margin_y * 2;
1007        let viewport_height = (win_h as i32 - top_offset).max(100);
1008        self.max_scroll_y = (content_height - viewport_height).max(0);
1009
1010        // position: sticky の可動範囲下限を確定する。
1011        // スクロールコンテナに属する要素はそのコンテナの content 下端、
1012        // ページスクロールの要素はドキュメント全体の下端を上限に、height 分引いた値まで。
1013        // これ以上は「吸着解除」してその位置で止まる(親からはみ出さない)。
1014        for i in 0..self.elements.len() {
1015            if !self.elements[i].is_sticky {
1016                continue;
1017            }
1018            let cid = self.elements[i].scroll_container_id.clone();
1019            let bound_bottom = if cid.is_empty() {
1020                content_height
1021            } else if let Some((_, _, _, cy, _, ch)) =
1022                self.scroll_containers.iter().find(|(id, ..)| *id == cid)
1023            {
1024                cy + *ch
1025            } else {
1026                content_height
1027            };
1028            let height = self.elements[i].height;
1029            self.elements[i].sticky_bound_max_y = (bound_bottom - height).max(0);
1030        }
1031        // scroll_y は**文書が変わったとき**のみ 0 へリセット。
1032        // 同じ文書の再レイアウト(`:target` 変化・フォント到着・JS 変更)では保持する。
1033        if is_document_changed {
1034            self.scroll_y = 0;
1035        } else {
1036            self.scroll_y = self.scroll_y.min(self.max_scroll_y);
1037        }
1038        self.dirty = true;
1039        self.dirty_reason = "layout.rs:865";
1040
1041        crate::info!(
1042            "WEB_ENGINE: Flatten layout done. generated {} elements.",
1043            self.elements.len()
1044        );
1045
1046        // 現在ページの絶対URLを JS へ反映(fetch/XHR の相対基準 + window/document.location)。
1047        {
1048            let scheme = if self.current_is_https {
1049                "https://"
1050            } else {
1051                "http://"
1052            };
1053            let page_url = alloc::format!("{}{}{}", scheme, self.current_host, self.current_path);
1054            self.js_runtime.set_page_url(&page_url);
1055        }
1056
1057        // <script> タグの自動実行 (もし現在実行中でなければ)。
1058        // インライン本文に加え、<script src="..."> の外部スクリプトも
1059        // 現在のページと同じ HTTP(S) 経路でフェッチしてから実行する。
1060        if !self.executing_script {
1061            self.executing_script = true;
1062            let script_blocks = collect_scripts_from_dom(&root_node);
1063            crate::debug!("[GPU] TRACE: script blocks count={}", script_blocks.len());
1064            for (idx, (src, inline)) in script_blocks.iter().enumerate() {
1065                let script_owned;
1066                let script: &str = match src {
1067                    Some(url) => {
1068                        crate::debug!("[GPU] TRACE: fetching external script idx={} src={}", idx, url);
1069                        match self.fetch_script_text(url) {
1070                            Some(text) => {
1071                                script_owned = text;
1072                                &script_owned
1073                            }
1074                            None => {
1075                                crate::debug!("[GPU] TRACE: external script fetch failed idx={}", idx);
1076                                continue;
1077                            }
1078                        }
1079                    }
1080                    None => inline.as_str(),
1081                };
1082                if !script.is_empty() {
1083                    crate::debug!("[GPU] TRACE: executing script idx={} len={}", idx, script.len());
1084                    if let Err(e) = crate::os_lib::js::eval(script, &mut self.js_runtime) {
1085                        crate::debug!("[GPU] TRACE: JS error: {}", e);
1086                    }
1087                    crate::debug!("[GPU] TRACE: executed script idx={}", idx);
1088                }
1089            }
1090            self.executing_script = false;
1091
1092            // インラインスクリプトが DOM を変更していたら、スクリプトを再実行せずに
1093            // 再レイアウトして変更を反映する。
1094            // 【2026-07-31】非同期取得したフォントが揃ったら再レイアウトする(R-3)。
1095            // 非同期化とキャッシュは動いていたが**完了を通知する経路が無く**、
1096            // 同一ブート内で永久に反映されなかった(実測: cache hit 0 件)。
1097            // レイアウトは取得完了より先に走るので、完了時に促す必要がある。
1098            // 【2026-08-03 移設】フォント取得完了の確認はここでは行わない。
1099            // ここは `parse_and_layout` の内側なので、
1100            // **レイアウトが動いていないと再レイアウトを起こせない**という
1101            // 循環になっていた。実測でフォント 8 本の取得完了は
1102            // 最後のレイアウトより後に起きており、取り込む機会が無かった。
1103            // 確認はフレーム毎に走る `draw()` の冒頭へ移した。
1104
1105            self.relayout_if_dom_dirty(top_offset, win_w, win_h);
1106        }
1107
1108        // 【2026-08-03】URL のフラグメント(`#id`)があれば、要素が出揃った
1109        // この時点でその位置までスクロールする。
1110        //
1111        // 実ブラウザの標準機能であり、このサイトのナビゲーション
1112        // (Top / Profile / Smart Home / Datamining)も `#id` リンク。
1113        // 対象 id がまだ現れていない場合は保持したままにして、
1114        // 次のレイアウト(フォント到着後の再レイアウト等)で再挑戦する。
1115        if !self.pending_fragment.is_empty() {
1116            let frag = self.pending_fragment.clone();
1117            if self.elements.iter().any(|e| e.element_id == frag) {
1118                let href = alloc::format!("#{}", frag);
1119                self.navigate_to_fragment(&href);
1120                self.pending_fragment.clear();
1121                crate::warn!("[NAV] フラグメントへスクロール #{}", frag);
1122            } else {
1123                // 見つからないこと自体は異常ではない(まだ生成されていない、
1124                // あるいは存在しない id)。ただし黙って消さない。
1125                crate::warn!(
1126                    "[NAV] フラグメントの対象がまだ見つからない #{} elements={}",
1127                    frag,
1128                    self.elements.len()
1129                );
1130            }
1131        }
1132
1133        // 【2026-08-05 計測】flatten 以降にも処理が続く。
1134        // 上の `[PERF][LAYOUT]` は flatten までしか測っておらず、
1135        // それだけでは 38〜183 tick と軽い。関数全体との差が
1136        // 「flatten より後」のコストになる。
1137        crate::warn!(
1138            "[PERF][LAYOUT] 関数全体={}tick",
1139            crate::kernel::timer::get_ticks().wrapping_sub(t_stage0)
1140        );
1141    }
1142
1143    /// `<script src="...">` の外部スクリプトを取得する。`url` は resolve_dom_paths 済みのため、
1144    /// 絶対パス(同一ホスト)か `scheme://host/path` の完全URLのどちらか。
1145    /// `url`(`<link href>`等、絶対URLの場合も現在ページ相対パスの場合もある)を
1146    /// 常に絶対URL文字列へ正規化する。外部CSS自身の`url()`相対参照を、その
1147    /// CSSファイル自身の場所を基準に解決する(`rewrite_css_urls`)際の
1148    /// 基点URLとして使う。
1149    fn resolve_page_url(&self, url: &str) -> String {
1150        if url.starts_with("http://") || url.starts_with("https://") {
1151            String::from(url)
1152        } else {
1153            let scheme = if self.current_is_https { "https://" } else { "http://" };
1154            alloc::format!("{}{}{}", scheme, self.current_host, url)
1155        }
1156    }
1157
1158    /// `ensure_image_cached` と同じ規約: 現在のページが `localhost`(アプリのローカルHTML)
1159    /// なら SylFS から読み、それ以外は実際の HTTP(S) 経由でフェッチする。
1160    fn fetch_script_text(&self, url: &str) -> Option<String> {
1161        let _t_fs = super::perf::start();
1162        let r = self.fetch_script_text_inner(url);
1163        super::perf::add(super::perf::Slot::FetchScript, _t_fs);
1164        r
1165    }
1166
1167    fn fetch_script_text_inner(&self, url: &str) -> Option<String> {
1168        if self.current_host == "localhost" {
1169            // FS は複数スレッドから触られる(`kernel::fs::FS_LOCK` の説明を参照)。
1170            let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
1171            let fs = crate::kernel::fs::get_fs();
1172            return fs
1173                .read_file(url)
1174                .map(|(_m, bytes)| String::from_utf8_lossy(&bytes).into_owned());
1175        }
1176        // 【2026-09-05 バグ修正】ここは `https://`/`http://` 以外をすべて
1177        // 「現在のホスト上のパス」として扱っていた。そのため
1178        // プロトコル相対 URL(`//host/path`)が自ホストへ向き、
1179        // 実サイトの `//ajax.googleapis.com/.../jquery.min.js` が
1180        // 自ホストの HTML を返し、それを JS として実行して構文エラーに
1181        // なっていた(=**jQuery 本体が読み込まれていなかった**)。
1182        //
1183        // 同型の誤りは `js/builtins.rs`・`web_engine/render.rs` でも
1184        // 個別に直されており、ここが 4 例目。判定を
1185        // `url_resolve::split_target` に集約して、5 例目が生まれないようにする。
1186        let (is_https, host, path) = super::url_resolve::split_target(
1187            url,
1188            self.current_is_https,
1189            &self.current_host,
1190        );
1191        // 【2026-07-24】この自作TCPスタックは送信データの再送機構を持たず、
1192        // ネットワーク上でパケット(TLS ClientHello等)が消失すると復旧できず
1193        // 接続全体が失敗する(`tcp_send_real`自体への再送追加は既存の安定した
1194        // メインHTML取得経路に悪影響を与えたため断念済み、詳細はTODO.md参照)。
1195        // 【2026-07-25】以前はここで独自に最大3回の再試行をしていたが、
1196        // `https_get_binary`/`http_get_binary`側が接続失敗・応答打ち切りの
1197        // 両方について既に最大3回まで内部で再試行するようになったため、
1198        // ここでさらに3回重ねると最悪ケースで1リソースあたり最大9回もの
1199        // 接続試行が発生し、ロード時間を不必要に悪化させていた。二重管理を
1200        // やめ、呼び出しは1回のみとしリトライは呼び出し先に一本化する。
1201        // 【2026-07-26】外部CSS/JSもディスクキャッシュを使うようにした。
1202        // メインHTMLと画像は以前からキャッシュしていたが、この経路だけが
1203        // 毎回ネットワークへ出ていた。自作TCP/TLSスタックは実測でパケット
1204        // 消失・タイムアウトが頻発しており、一度取得できたスタイルシートを
1205        // 再取得しないだけで表示の再現性が上がる。
1206        // (SylFS に間接ブロックを追加し 1 ファイル上限を約22KB→約85KB へ
1207        //   拡大したので、49KB の `modern-style.css` も対象になる。
1208        //   現状バックエンドは RAMDisk のため効果は同一起動内に限られるが、
1209        //   ページ間の行き来では再取得を避けられる。SDカードが使えるように
1210        //   なれば起動をまたいで効く。)
1211        if let Some(cached) = super::cache::read_cache(&host, &path) {
1212            crate::debug!("[NET] fetch_script_text: cache hit host={} path={}", host, path);
1213            return Some(String::from_utf8_lossy(&cached).into_owned());
1214        }
1215
1216        // 【2026-08-05 試して差し戻した】ここを非同期取得にしたが**回帰した**。
1217        //
1218        // 動機は正しかった: レイアウト 1 回 92〜2176 tick のうち、
1219        // HTML 解析・CSS 解析・レイアウト木構築は合計 57〜183 tick しかなく、
1220        // **大きな値の正体はここの通信待ち**だった。
1221        // 非同期にするとレイアウトは 30〜164 tick まで落ちた。
1222        //
1223        // だが**ページが素の HTML のまま描かれた**。
1224        // CSS 自体は取得できており(`[FONT] 背景取得完了 /css/modern-style.css
1225        // bytes=49077`)、到着通知による再レイアウトも 3 回走っていたのに、
1226        // スタイルが当たらない。`external_css_cache` と `cached_stylesheet` を
1227        // 到着時に捨てるようにしても直らなかった。
1228        //
1229        // 非同期化には「初回は CSS 無しで描き、届いたら**確実に**当て直す」
1230        // 経路の作り込みが要る。キューを差し替えるだけでは足りない。
1231        // 壊れた見た目を残さないため、原因が分かるまで同期のままにする。
1232        // 設計は `spec/resource_loading.md`(R-1/R-3)にある。
1233        // 【2026-08-05 三度試して三度とも戻した】非同期取得は**まだ入れられない**。
1234        //
1235        // ## 動機(測定として正しい)
1236        //
1237        // 段ごとに測ると HTML 解析・CSS 解析・レイアウト木構築は
1238        // 合計 57〜183 tick しかないのに、`parse_and_layout` 全体は
1239        // 92〜2176 tick。**差の正体はここの同期通信待ち**。
1240        // 非同期にするとレイアウトは 35〜432 tick まで落ちる(毎回再現)。
1241        //
1242        // ## 経過
1243        //
1244        // | 回 | 結果 |
1245        // |---|---|
1246        // | 1 | 素の HTML に見えた → **撮影が早かっただけの誤判定**。計装すると `rules=2403` で正しく当たっていた |
1247        // | 2 | メイン HTML が読めない(`total=3`)→ `fetch_limit` で優先度の門を実装 |
1248        // | 3 | 門が閉じている間の投入が滞留 → `pump()` で解消。`total=12` まで回復したが**CSS が当たらない** |
1249        //
1250        // 3 回目は取得もキャッシュ破棄も動いているのにスタイルが当たらない。
1251        // 原因は未特定。**見た目が壊れたまま残さない**ため同期に戻す(R-6)。
1252        //
1253        // ## 計装を入れて分かったこと(4 回目・原因を絞り込んだ)
1254        //
1255        // `[CSSTRACE]` を常設して非同期版を走らせた結果:
1256        //
1257        // ```text
1258        // 非同期投入 host=www.sugi-lab.net path=/css/modern-style.css
1259        // 抽出 ext_len=0            ← 初回は空(想定どおり)
1260        // カスケード開始 ルール数=0
1261        // 到着による再レイアウト(CSS キャッシュ破棄)
1262        // 抽出 ext_len=124813       ← 届いた CSS を取り直せている
1263        // 解析キャッシュ命中=false
1264        // カスケード開始 ルール数=2403   ← 正しくカスケードしている
1265        // [PERF][LAYOUT] 要素=365        ← スタイル付きの要素も生成されている
1266        // ```
1267        //
1268        // **取得・キャッシュ破棄・再抽出・解析・カスケード・要素生成まで
1269        // すべて正しく動いている。** それでも画面は素の HTML のまま
1270        // (待機を 200→330 秒に延ばしても変わらないので**タイミングでもない**)。
1271        //
1272        // ## さらに絞り込んだ(5 回目・原因はほぼ確定)
1273        //
1274        // 描画側にも要素数を出し、ログの**行番号**で突き合わせた:
1275        //
1276        // ```text
1277        // 103: カスケード開始 ルール数=0
1278        // 114: [PERF][LAYOUT] ... 要素=365        ← この 365 要素は CSS 無しで作られた
1279        // 225: カスケード開始 ルール数=2403    ← この後に [PERF][LAYOUT] が無い
1280        // ```
1281        //
1282        // **スタイル付きのレイアウトは開始するが、完了行が出ない。**
1283        // つまり「描かれていない」のではなく「間に合っていない」。
1284        // 同じ実行の `[PERF][DRAW] frames=25`(通常は 100 超)も、
1285        // 実行が終盤だったことを裏づける。
1286        //
1287        // → CSS の到着が遅すぎる。フォント 8 本・画像と同じ 8 枠を
1288        //    奪い合っており、**CSS に優先権が無い**。
1289        //    `spec/resource_loading.md` の実装順序 4
1290        //    「キューを 1 本に統合し種別ごとの優先度を持たせる」が
1291        //    前提条件だと考え、優先度(CSS > フォント > 画像 > JS)を実装した。
1292        //
1293        // ## 6 回目・原因を突き止めた
1294        //
1295        // 優先度を入れても結果は同じだったので、
1296        // `style_tree` の前後・レイアウト木・flatten に印を足して測った:
1297        //
1298        // ```text
1299        // [CSSTRACE] カスケード開始 ルール数=2403
1300        // (「カスケード完了」が出ない)
1301        // ```
1302        //
1303        // **`style_tree` 自体が終わっていない。**
1304        // 同期版では同じ 2403 ルールが 118〜270 tick で完了する。
1305        // 違いは、非同期版ではカスケードが**8 本の取得ワーカーと同時に走る**こと。
1306        //
1307        // カスケードはノードごとに `BTreeMap` を作る確保の塊で、
1308        // このヒープは確保のたびに割り込みを止める spin ロックを取る
1309        // (`kernel/allocator.rs`)。取得ワーカー 8 本と競合すると
1310        // 桁違いに遅くなり、実行時間内に終わらない。
1311        //
1312        // ## 7 回目・競合説も否定された
1313        //
1314        // 「取得ワーカーと競合してカスケードが遅い」という読みで、
1315        // 再レイアウトの条件に `fetch_limit::active() == 0` を足した。
1316        // **それでもカスケードは完了しなかった。**
1317        // ヒープ競合は原因ではない。
1318        //
1319        // ## 分かっていること(ここまでの確定事項)
1320        //
1321        // - CSS の取得・キャッシュ・再抽出・解析(2403 ルール)は正しく動く
1322        // - 再レイアウトも起きる(`到着による再レイアウト` が出る)
1323        // - **`style_tree` が完了しない**(`カスケード開始` の後に完了行が無い)
1324        // - 同じ 2403 ルールでも**同期版なら 13 回すべて完了する**
1325        // - 待機を 330 秒に延ばしても変わらない(時間切れではない)
1326        // - 取得ワーカーが止まっていても変わらない(ヒープ競合ではない)
1327        //
1328        // ## 8 回目・「ハングする」という読みが**誤りだった**
1329        //
1330        // 渡る木を測ると同期版と**完全に同一**だった。
1331        //
1332        // ```text
1333        // 同期:   カスケード開始 ルール数=2403 ノード数=358 深さ=10  → 完了
1334        // 非同期: カスケード開始 ルール数=2403 ノード数=358 深さ=10  → 完了行が無い
1335        // ```
1336        //
1337        // ログの**行番号**を見ると、その「完了行が無い」開始行は
1338        // **ログの最終行**だった。つまりハングではなく、
1339        // **カスケードを始めた瞬間に実行時間が尽きただけ**。
1340        //
1341        // 6・7 回目に「`style_tree` が完了しない」「ヒープ競合だ」と
1342        // 書いたのは誤り。tail だけ見て「後続が無い=止まった」と
1343        // 決めつけていた(**ログの末尾は「止まった」の証拠にならない**)。
1344        //
1345        // ## 本当の問題: 非同期版は CSS の反映が遅すぎる
1346        //
1347        // 同じ実行の `[PERF][DRAW] frames=25`(同期版は 100 超)も、
1348        // 全体が遅いことを示している。
1349        // CSS 到着 → 再レイアウト → `@font-face` 発見 → フォント取得 →
1350        // 再レイアウト、と**直列に**進み、各段に静穏待ち(2 秒)が入る。
1351        //
1352        // ## 段ごとに測った(9 回目・数字が出た)
1353        //
1354        // ```text
1355        // [CSSTRACE] 到着 path=/css/modern-style.css 投入から=987tick   ≒ 9.9 秒
1356        // [CSSTRACE] 到着による再レイアウト 到着から=626tick            ≒ 6.3 秒
1357        // ```
1358        //
1359        // **投入から反映まで合計 16 秒**かかっている。内訳は 2 つ。
1360        //
1361        // ### (1) 取得そのものが遅い(987 tick)
1362        //
1363        // 49KB の CSS に 10 秒。実効 100KB/s なら 1 秒未満のはず。
1364        // 差はワーカーの空き待ち(フォント・画像と 8 枠を奪い合う)。
1365        // 優先度を入れたが、**既に走っているワーカーは止まらない**ので、
1366        // 重い画像が枠を握っている間は CSS が待たされる。
1367        //
1368        // ### (2) 到着してから反映までが遅い(626 tick)
1369        //
1370        // 静穏待ちは 2 秒(`QUIET`)なのに 6.3 秒かかっている。
1371        // 再レイアウトの判定は `draw()` の中でしか行われず、
1372        // その `draw()` が 200 秒で 25〜50 回しか回っていないため
1373        // (同期版は 100 回超)、判定の機会自体が少ない。
1374        //
1375        // ### 【訂正】「フォントがまだ同期だから」は**誤り**
1376        //
1377        // 一度そう結論したが、`fetch_binary_bytes` は
1378        // 2026-07-30 の時点で既に非同期化されている
1379        // (キャッシュに無ければ `enqueue_font` して即座に `None` を返す)。
1380        // コードを読まずに「同期のはずだ」と書いた。
1381        //
1382        // ### 【測定済み】`draw()` の「間」も原因ではない
1383        //
1384        // 前回の `draw()` 終了から次の開始までを測った:
1385        //
1386        // ```text
1387        // 同期:   frames=125 total=15522tick 間隔合計=746tick 間隔最大=20tick
1388        // 非同期: frames=50  total=10676tick 間隔合計=943tick 間隔最大=33tick
1389        // ```
1390        //
1391        // **間隔は両方とも極小**(全体の 5% 未満)。
1392        // GUI スレッドは `draw()` の外で止まっていない。これも外れ。
1393        //
1394        // 残るのは「1 回の `draw()` が長い」こと。
1395        // 両方とも `間隔最大` に対して `max`(1 回の最大)が
1396        // 2563〜4043 tick = 25〜40 秒あり、
1397        // **数十秒かかる `draw()` が存在する**。
1398        // 次に測るならそこ(`max` を記録した 1 回の中で何をしていたか)。
1399        //
1400        // ## ここまでで確かなこと
1401        //
1402        // - 投入から反映まで 16 秒(内訳 9.9 秒 + 6.3 秒)
1403        // - 取得の 9.9 秒は枠待ち(優先度を入れても、走行中のワーカーは止まらない)
1404        // - 反映の 6.3 秒は `draw()` の回数が少ないことに由来する
1405        // - `draw()` が減る理由は未特定(フォント同期説は誤り)
1406        //
1407        // 付随して入れた `fetch_limit`(優先度の門)と `pump()`(滞留の解消)、
1408        // 到着時の `external_css_cache` 破棄、`[CSSTRACE]` の計装は
1409        // **それぞれ独立して正しい**ので残してある。
1410        let result = if is_https {
1411            crate::kernel::tls::https_get_binary(&host, &path)
1412        } else {
1413            crate::kernel::net::http_get_binary(&host, &path)
1414        };
1415        match result {
1416            Ok(bytes) if !bytes.is_empty() => {
1417                // 空応答はキャッシュしない。一度 0 バイトを書くと
1418                // 以後は毎回キャッシュ命中して**恒久的に空が返り続ける**。
1419                super::cache::write_cache(&host, &path, &bytes);
1420                Some(String::from_utf8_lossy(&bytes).into_owned())
1421            }
1422            Ok(_) => {
1423                crate::warn!("[NET] fetch_script_text: 空応答 host={} path={}", host, path);
1424                None
1425            }
1426            Err(e) => {
1427                crate::warn!(
1428                    "[NET] fetch_script_text: 取得失敗 host={} path={} err={}",
1429                    host, path, e
1430                );
1431                None
1432            }
1433        }
1434    }
1435
1436    /// `fetch_script_text`と同じURL解決規則だが、UTF-8ロッシー変換を行わず
1437    /// バイト列のまま返す。TTF/OTF等のバイナリ形式はテキスト変換で内容が
1438    /// 破損する(不正なバイト列がU+FFFDに置換される)ため専用に必要
1439    /// (`@font-face`の実行時フォント読込用。2026-07-22新設)。
1440    fn fetch_binary_bytes(&self, url: &str) -> Option<alloc::vec::Vec<u8>> {
1441        if self.current_host == "localhost" {
1442            // 同上。フォント取得はワーカースレッドからも走る。
1443            let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
1444            let fs = crate::kernel::fs::get_fs();
1445            return fs.read_file(url).map(|(_m, bytes)| bytes);
1446        }
1447        fn split_host_path(rest: &str) -> (String, String) {
1448            #[allow(clippy::string_slice)]
1449            match rest.find('/') {
1450                Some(idx) => (String::from(&rest[..idx]), String::from(&rest[idx..])),
1451                None => (String::from(rest), String::from("/")),
1452            }
1453        }
1454        let (is_https, host, path) = if let Some(rest) = url.strip_prefix("https://") {
1455            let (h, p) = split_host_path(rest);
1456            (true, h, p)
1457        } else if let Some(rest) = url.strip_prefix("http://") {
1458            let (h, p) = split_host_path(rest);
1459            (false, h, p)
1460        } else {
1461            (self.current_is_https, self.current_host.clone(), String::from(url))
1462        };
1463        // 【2026-07-30】フォントもディスクキャッシュを使う。
1464        //
1465        // 実測でキャッシュ利用箇所を全列挙したところ、
1466        // CSS/JS(`fetch_script_text`)と画像(`image_loader_thread`)は
1467        // `/cache/browser/` を使っているのに、**フォント取得だけ使っていなかった**。
1468        // そのため 5.3MB の Noto Sans JP を毎回ダウンロードし直しており、
1469        // 描画が始まらない直接の要因になっていた(`spec/resource_loading.md`)。
1470        if let Some(cached) = super::cache::read_cache(&host, &path) {
1471            crate::warn!(
1472                "[NET] fetch_binary_bytes: cache hit host={} path={} bytes={}",
1473                host,
1474                path,
1475                cached.len()
1476            );
1477            return Some(cached);
1478        }
1479        // 【2026-07-30】キャッシュに無ければ**背景で取得を予約して即座に戻る**。
1480        //
1481        // 以前はここで同期的にダウンロードしており、5.3MB のフォントで
1482        // レンダリングが止まっていた。取得を打ち切るのはブラウザの挙動として
1483        // 誤りなので、**最後まで取得しつつ描画は待たせない**方式へ変える
1484        // (`spec/resource_loading.md` の R-1/R-2)。
1485        // 取得完了後はキャッシュに載るので、次のレイアウトで反映される(R-3)。
1486        // 【2026-08-05 正しく試して悪化・不採用】
1487        //
1488        // 「積むだけにして全部積んでから起こす」(`enqueue_kind_deferred`
1489        // + `spawn_workers`)を、今度は**実コードに当たったことを
1490        // `grep -c` で確認したうえで**測った。
1491        //
1492        // ```text
1493        // 従来:   TLS握手=2958/12  spawn=4  total=13 ok=13
1494        // 遅延版: TLS握手=3525/13  spawn=6  total=13 ok=13
1495        // ```
1496        //
1497        // **握手が 12 → 13 に増えて悪化した。**
1498        // `spawn_workers` が一度に 4 本起こすため、各ワーカーが
1499        // 1 件ずつ持って別々に接続し、**まとめ取得が成立しにくくなる**。
1500        // 「全部並べてから起こす」と「並列に起こす」は両立しない。
1501        //
1502        // 過去 2 回の同案は置換ミスで実コードに当たっておらず、
1503        // そこから導いた R-19 は取り下げ済み。これが唯一の有効な観測。
1504        super::font_queue::enqueue_font(host.clone(), path.clone(), is_https);
1505        None
1506    }
1507
1508    /// `@font-face`で参照されたフォントを実行時に取得・登録する。
1509    /// 【2026-07-24修正】以前は相対URL(`../webfonts/x.woff2`等。FontAwesome等が
1510    /// 使う)を「base URL解決未対応」として無条件でスキップしており、
1511    /// アイコンフォントが一切取得されない既知のバグだった。外部CSSの取得時点
1512    /// (`rewrite_css_urls`、CSSファイル自身の絶対URLを基準に解決)で
1513    /// 相対`url()`参照をすべて絶対URLへ書き換え済みにしたため、ここに届く
1514    /// 時点では(インラインの`<style>`ブロック由来を除き)通常すでに絶対URL
1515    /// になっている。ここでの`http(s)://`チェックは、`data:`URI等
1516    /// 非対応形式を安全に弾くガードとして残す。同名familyが既に登録済みなら
1517    /// ネットワーク往復を避けてスキップする(2026-07-22新設)。
1518    fn fetch_and_register_font_faces(&self, font_faces: &[crate::os_lib::css::FontFaceDef]) {
1519        // 【2026-07-26】`@font-face`の取得は同期的にここで行われ、1件の失敗に
1520        // 約7秒(DNS/TCP/TLSのタイムアウト)かかる。実サイトは Noto Sans JP 4件
1521        // +FontAwesome 6件=10件の`@font-face`を持つため、ネットワークが不調な
1522        // ときは**約70秒間、初回描画が始まらない**。フォントはあくまで装飾で
1523        // あり、取得できなくてもフォールバックフォントでページは読める。
1524        // 連続失敗が閾値に達したら以降の取得を諦めて描画へ進む(回路遮断器)。
1525        // これにより最悪ケースの遅延を約70秒から約14秒へ短縮する。
1526        // 【2026-08-03】連続失敗による打ち切り(`MAX_CONSECUTIVE_FAILURES`)を撤去した。
1527        //
1528        // 取得は非同期(`font_queue`)で描画をブロックしないので、
1529        // 「何本か失敗したから以降は諦める」理由が無い。
1530        // 打ち切りは本物のブラウザの挙動ではない(`spec/resource_loading.md` R-2)。
1531        // 実際この打ち切りは過去に Font Awesome 4 本を全部スキップさせていた。
1532        //
1533        // 集計用の `fetched_fonts` も参照されていなかったので併せて削除
1534        // (`cargo clippy` の unused_assignments 警告として出ていた)。
1535
1536        for ff in font_faces {
1537            // 【2026-08-05】ウェイトまで見る。family 名だけで判定すると、
1538            // 同じ family の別ウェイト(Font Awesome の solid 900)が
1539            // 「登録済み」と誤判定されて一度も取り込まれない。
1540            if crate::kernel::vector_font::has_custom_font_weighted(&ff.family, ff.weight) {
1541                continue;
1542            }
1543            let Some(url) = &ff.src_url else { continue };
1544            if !(url.starts_with("http://") || url.starts_with("https://")) {
1545                continue;
1546            }
1547            // 【2026-07-30】サイズ・本数の上限判定は撤去した。
1548            // 取得は非同期(`font_queue`)になり描画をブロックしないので、
1549            // 「大きいから諦める」必要が無くなった。
1550            // 打ち切りはブラウザの挙動として誤り(`spec/resource_loading.md` R-2)。
1551            let t0 = crate::kernel::timer::get_ticks();
1552            if let Some(bytes) = self.fetch_binary_bytes(url) {
1553                let elapsed = crate::kernel::timer::get_ticks().wrapping_sub(t0);
1554                let ok = crate::kernel::vector_font::register_custom_font_weighted(
1555                    &ff.family,
1556                    ff.weight,
1557                    bytes,
1558                );
1559                crate::println!(
1560                    "[CSSDBG] @font-face '{}' <- {} registered={} elapsed_ticks={}",
1561                    ff.family, url, ok, elapsed
1562                );
1563            } else {
1564                // 【2026-07-31】非同期化により、ここへ来るのは
1565                // 「取得失敗」ではなく**「キューへ積んで取得中」**である。
1566                // 以前はこれを失敗と数えており、`MAX_CONSECUTIVE_FAILURES = 2` の
1567                // サーキットブレーカーが即発動して**以降のフォントを全部スキップ**
1568                // していた(実測: Font Awesome 4 本すべてが SKIPPED)。
1569                // `Pending` は失敗ではない(`font_budget::should_count_as_failure`、
1570                // 単体試験 3 件で契約を固定)。
1571                crate::println!(
1572                    "[CSSDBG] @font-face '{}' <- {} 非同期取得へ投入(描画は待たない)",
1573                    ff.family, url
1574                );
1575            }
1576        }
1577    }
1578
1579    /// DOM ブリッジが dirty なら、スクリプトを再実行せずに再レイアウトして反映する。
1580    /// クリックリスナや初期スクリプトによる textContent/style/class 変更を画面へ出す。
1581    pub fn relayout_if_dom_dirty(&mut self, top_offset: i32, win_w: u32, win_h: u32) {
1582        let dirty = {
1583            let dom = self.js_runtime.dom.clone();
1584            // dirty は parse_and_layout → to_dom_node() 後に b.dirty=false でクリアされる。
1585            // ここで先にクリアすると bridge_is_source 判定が false になり
1586            // to_dom_node() が呼ばれないため、遅延クリア方式に変更した。
1587            let d = dom.borrow().dirty;
1588            d
1589        };
1590        if !dirty {
1591            return;
1592        }
1593        let html = self.last_html.clone();
1594        // executing_script を立ててスクリプト再実行を抑止(apply_overrides 経路で再描画)。
1595        let was_executing = self.executing_script;
1596        self.executing_script = true;
1597        // 【2026-07-27診断】DOM 変更由来の再レイアウト回数を可視化する。
1598        {
1599            static N: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
1600            let n = N.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
1601            if n < 8 {
1602                crate::warn!("[LAYOUT][SRC] dom-dirty relayout #{}", n + 1);
1603            }
1604        }
1605        self.parse_and_layout(&html, None, top_offset, win_w, win_h);
1606        self.executing_script = was_executing;
1607        self.dirty = true;
1608        self.dirty_reason = "layout.rs:1190";
1609    }
1610
1611    /// アニメーションの算出値を、描画要素へ直接書き込む(軽量再ペイント経路)。
1612    ///
1613    /// 対象要素が見つからない、値を解釈できない、対応していないプロパティが
1614    /// 含まれる場合は `false` を返す。呼び出し側は従来の再レイアウト経路へ
1615    /// フォールバックすること(**部分適用のまま画面に出さない**ため、
1616    /// 1 つでも適用できなければフレーム全体をやり直す)。
1617    fn apply_paint_only_values(&mut self, computed: &[(String, String, String)]) -> bool {
1618        for (eid, prop, val) in computed {
1619            let mut applied_any = false;
1620            let mut failed = false;
1621            for el in self.elements.iter_mut() {
1622                if el.element_id != *eid {
1623                    continue;
1624                }
1625                let ok = match prop.trim().to_ascii_lowercase().as_str() {
1626                    "color" => match crate::os_lib::css::parse_color(val) {
1627                        Some(c) => {
1628                            el.color = c;
1629                            true
1630                        }
1631                        None => false,
1632                    },
1633                    "background-color" => match crate::os_lib::css::parse_color(val) {
1634                        Some(c) => {
1635                            el.bg_color = Some(c);
1636                            true
1637                        }
1638                        None => false,
1639                    },
1640                    "opacity" => match val.trim().parse::<f32>() {
1641                        Ok(f) if f.is_finite() => {
1642                            el.opacity = (f.clamp(0.0, 1.0) * 255.0) as u8;
1643                            true
1644                        }
1645                        _ => false,
1646                    },
1647                    // 【2026-08-02 発見・修正】`transform` は書き込み先
1648                    // (`el.transform_tx/ty`・`scale`・`rotate`・`skew`)が
1649                    // **既に存在する**のに、ここで `false` を返して
1650                    // 全再レイアウト(HTML 再パース+2403 ルール再カスケード、
1651                    // 実測約 6 秒)へ落としていた。
1652                    //
1653                    // 実サイトの `fadeInUp` 系は `opacity` と `transform: translateY()`
1654                    // を組みで動かすため、**必ず**この分岐に当たっていた。
1655                    // 軽量再ペイント経路が用意されているのに一度も使われない状態。
1656                    //
1657                    // 通常のレイアウト時と同じ `parse_transform` を使うので、
1658                    // 解釈は完全に一致する。
1659                    "transform" => {
1660                        let (tx, ty, sx, sy, rot, skx, sky) =
1661                            super::helpers::parse_transform(Some(val.as_str()));
1662                        el.transform_tx = tx;
1663                        el.transform_ty = ty;
1664                        el.transform_scale_x = sx;
1665                        el.transform_scale_y = sy;
1666                        el.transform_rotate_deg = rot;
1667                        el.transform_skew_x = skx;
1668                        el.transform_skew_y = sky;
1669                        true
1670                    }
1671                    "border-color"
1672                    | "border-top-color"
1673                    | "border-right-color"
1674                    | "border-bottom-color"
1675                    | "border-left-color"
1676                    | "outline-color" => match crate::os_lib::css::parse_color(val) {
1677                        Some(c) => {
1678                            el.border_color = Some(c);
1679                            true
1680                        }
1681                        None => false,
1682                    },
1683                    "text-decoration-color" => match crate::os_lib::css::parse_color(val) {
1684                        Some(c) => {
1685                            el.decoration_color = Some(c);
1686                            true
1687                        }
1688                        None => false,
1689                    },
1690                    "caret-color" => match crate::os_lib::css::parse_color(val) {
1691                        Some(c) => {
1692                            el.caret_color = Some(c);
1693                            true
1694                        }
1695                        None => false,
1696                    },
1697                    "accent-color" => match crate::os_lib::css::parse_color(val) {
1698                        Some(c) => {
1699                            el.accent_color = Some(c);
1700                            true
1701                        }
1702                        None => false,
1703                    },
1704                    // 再ペイント扱いだが直接書き込む先が無いプロパティ
1705                    // (box-shadow 等)は再レイアウトへ委ねる。
1706                    _ => false,
1707                };
1708                if ok {
1709                    applied_any = true;
1710                } else {
1711                    failed = true;
1712                }
1713            }
1714            if failed || !applied_any {
1715                return false;
1716            }
1717        }
1718        true
1719    }
1720
1721    /// 現在時刻(ms)。カーネルティック(10ms 単位)から算出。
1722    pub(crate) fn now_ms(&self) -> f32 {
1723        (crate::kernel::timer::get_ticks() as f32) * 10.0
1724    }
1725
1726    /// parse_and_layout 後に呼ばれ、各 id 付き要素の算出スタイルから
1727    /// `transition` / `animation` 指定を読み取り、アニメーションエンジンへ反映する。
1728    /// - animation: 新規 @keyframes アニメを登録(同名は重複無視)。
1729    /// - transition: 監視対象プロパティの値が前回から変化していたらトランジション開始。
1730    fn sync_animations(
1731        &mut self,
1732        collected: &[(String, alloc::vec::Vec<(String, String)>)],
1733        stylesheet: &crate::os_lib::css::StyleSheet,
1734    ) {
1735        // アニメ駆動の再レイアウト中は、補間値が computed に出てくるため
1736        // トランジション検出も prev 更新も行わない(誤発火・状態汚染の防止)。
1737        if self.in_anim_relayout {
1738            return;
1739        }
1740        let now = self.now_ms();
1741
1742        for (eid, props) in collected {
1743            // プロパティマップ化。
1744            let mut map: alloc::collections::BTreeMap<&str, &str> =
1745                alloc::collections::BTreeMap::new();
1746            for (k, v) in props {
1747                map.insert(k.as_str(), v.as_str());
1748            }
1749
1750            // `transition` ショートハンドが無くても `transition-property`/`-duration`/
1751            // `-delay`/`-timing-function` の個別ロングハンドだけが指定されているケースに
1752            // 対応する。以前はショートハンドしか見ておらず、この一般的な書き方(生成された
1753            // CSS やコンポーネントスタイルでよく使われる)では常に無視されていた。
1754            // ロングハンドをショートハンド相当の文字列へ合成し、既存の `parse_transition`
1755            // をそのまま再利用する。
1756            fn synthesize_transition_shorthand(
1757                map: &alloc::collections::BTreeMap<&str, &str>,
1758            ) -> String {
1759                let Some(prop_list) = map.get("transition-property") else {
1760                    return String::new();
1761                };
1762                let props: alloc::vec::Vec<&str> =
1763                    prop_list.split(',').map(|s| s.trim()).collect();
1764                if props.is_empty() || props[0].is_empty() {
1765                    return String::new();
1766                }
1767                fn split_list(v: Option<&str>) -> alloc::vec::Vec<&str> {
1768                    v.map(|s| s.split(',').map(|s| s.trim()).collect())
1769                        .unwrap_or_default()
1770                }
1771                let durations = split_list(map.get("transition-duration").copied());
1772                let delays = split_list(map.get("transition-delay").copied());
1773                let timings = split_list(map.get("transition-timing-function").copied());
1774                let get_cyclic = |v: &alloc::vec::Vec<&str>, i: usize, default: &str| -> String {
1775                    if v.is_empty() {
1776                        String::from(default)
1777                    } else {
1778                        String::from(v[i % v.len()])
1779                    }
1780                };
1781                props
1782                    .iter()
1783                    .enumerate()
1784                    .map(|(i, prop)| {
1785                        alloc::format!(
1786                            "{} {} {} {}",
1787                            prop,
1788                            get_cyclic(&durations, i, "0s"),
1789                            get_cyclic(&timings, i, "ease"),
1790                            get_cyclic(&delays, i, "0s"),
1791                        )
1792                    })
1793                    .collect::<alloc::vec::Vec<alloc::string::String>>()
1794                    .join(", ")
1795            }
1796            let transition_str = map
1797                .get("transition")
1798                .map(|s| String::from(*s))
1799                .unwrap_or_else(|| synthesize_transition_shorthand(&map));
1800            // `transition` と同種のバグ: `animation` ショートハンドが無くても
1801            // `animation-name`/`-duration`/`-delay`/`-timing-function`/`-iteration-count`/
1802            // `-direction`/`-fill-mode` の個別ロングハンドだけが指定されているケースに対応する。
1803            fn synthesize_animation_shorthand(
1804                map: &alloc::collections::BTreeMap<&str, &str>,
1805            ) -> String {
1806                let Some(name_list) = map.get("animation-name") else {
1807                    return String::new();
1808                };
1809                let names: alloc::vec::Vec<&str> =
1810                    name_list.split(',').map(|s| s.trim()).collect();
1811                if names.is_empty() || names[0].is_empty() || names[0] == "none" {
1812                    return String::new();
1813                }
1814                fn split_list(v: Option<&str>) -> alloc::vec::Vec<&str> {
1815                    v.map(|s| s.split(',').map(|s| s.trim()).collect())
1816                        .unwrap_or_default()
1817                }
1818                let durations = split_list(map.get("animation-duration").copied());
1819                let delays = split_list(map.get("animation-delay").copied());
1820                let timings = split_list(map.get("animation-timing-function").copied());
1821                let iterations = split_list(map.get("animation-iteration-count").copied());
1822                let directions = split_list(map.get("animation-direction").copied());
1823                let fills = split_list(map.get("animation-fill-mode").copied());
1824                let play_states = split_list(map.get("animation-play-state").copied());
1825                let get_cyclic = |v: &alloc::vec::Vec<&str>, i: usize, default: &str| -> String {
1826                    if v.is_empty() {
1827                        String::from(default)
1828                    } else {
1829                        String::from(v[i % v.len()])
1830                    }
1831                };
1832                names
1833                    .iter()
1834                    .enumerate()
1835                    .map(|(i, name)| {
1836                        alloc::format!(
1837                            "{} {} {} {} {} {} {} {}",
1838                            name,
1839                            get_cyclic(&durations, i, "0s"),
1840                            get_cyclic(&timings, i, "ease"),
1841                            get_cyclic(&delays, i, "0s"),
1842                            get_cyclic(&iterations, i, "1"),
1843                            get_cyclic(&directions, i, "normal"),
1844                            get_cyclic(&fills, i, "none"),
1845                            get_cyclic(&play_states, i, "running"),
1846                        )
1847                    })
1848                    .collect::<alloc::vec::Vec<alloc::string::String>>()
1849                    .join(", ")
1850            }
1851            let animation_str = map
1852                .get("animation")
1853                .map(|s| String::from(*s))
1854                .unwrap_or_else(|| synthesize_animation_shorthand(&map));
1855
1856            // --- animation --- カンマ区切りで複数のアニメーションを同時指定できる
1857            // (例: `animation: fade 1s, bounce 2s infinite;`)ため、全項目を処理する。
1858            if !animation_str.is_empty() {
1859                let specs = crate::os_lib::css::parse_animations(&animation_str);
1860                // `animation-name` が別名へ切り替わった場合、その別名がもう存在しない
1861                // 古いアニメーションを取り消す(`iterations:infinite` だと自然完了しないため
1862                // 放置すると永久に走り続けてしまうバグがあった)。
1863                let current_names: alloc::vec::Vec<alloc::string::String> =
1864                    specs.iter().map(|s| s.name.clone()).collect();
1865                self.anim_engine.retain_animation_names(eid, &current_names);
1866                for spec in specs {
1867                    if let Some(kf) = stylesheet
1868                        .keyframes
1869                        .iter()
1870                        .find(|k| k.name == spec.name)
1871                        .cloned()
1872                    {
1873                        self.anim_engine.start_animation(eid, spec, kf, now);
1874                    }
1875                }
1876            }
1877
1878            // --- transition ---
1879            if !transition_str.is_empty() {
1880                let specs = crate::os_lib::css::parse_transition(&transition_str);
1881                let prev = self.anim_prev_values.get(eid).cloned().unwrap_or_default();
1882                for ts in &specs {
1883                    // "all" は監視プロパティ群を限定できないので主要プロパティに展開。
1884                    let target_props: alloc::vec::Vec<String> = if ts.property == "all" {
1885                        map.keys()
1886                            .filter(|k| crate::os_lib::css::is_animatable_property(k))
1887                            .map(|k| String::from(*k))
1888                            .collect()
1889                    } else {
1890                        alloc::vec![ts.property.clone()]
1891                    };
1892                    for tp in target_props {
1893                        if let Some(new_val) = map.get(tp.as_str()) {
1894                            if let Some(old_val) = prev.get(&tp) {
1895                                if old_val != new_val {
1896                                    self.anim_engine.start_transition(
1897                                        eid,
1898                                        &tp,
1899                                        old_val,
1900                                        new_val,
1901                                        ts.clone(),
1902                                        now,
1903                                    );
1904                                }
1905                            }
1906                        }
1907                    }
1908                }
1909            }
1910
1911            // 今回の値を prev として保存(次回の変化検出用)。
1912            let mut snapshot: alloc::collections::BTreeMap<String, String> =
1913                alloc::collections::BTreeMap::new();
1914            for (k, v) in props {
1915                snapshot.insert(k.clone(), v.clone());
1916            }
1917            self.anim_prev_values.insert(eid.clone(), snapshot);
1918            self.anim_specs
1919                .insert(eid.clone(), (transition_str, animation_str));
1920        }
1921    }
1922
1923    /// レンダラが毎フレーム呼ぶ。進行中のアニメ/トランジションを現在時刻で評価し、
1924    /// 算出値を DOM ブリッジの style に焼き込んで再描画させる。
1925    /// 戻り値: アニメーションが進行中で次フレームも再描画が必要なら true。
1926    pub fn tick_animations(&mut self, top_offset: i32) -> bool {
1927        if !self.anim_engine.is_active() {
1928            return false;
1929        }
1930        let now = self.now_ms();
1931        let _active = self.anim_engine.tick(now);
1932        let computed = self.anim_engine.computed.clone();
1933
1934        // 【2026-07-28】軽量再ペイント経路。
1935        // このフレームで変化したのが色・不透明度など**ボックスを変えない**プロパティ
1936        // だけなら、カスケードもレイアウトもやり直さず算出値を差し替えて再描画する。
1937        // 実サイトではレイアウト 1 回が 10 秒以上かかるため、これをやらないと
1938        // 描いたフレームが画面に出る前に次のレイアウトが始まり永久に収束しない。
1939        //
1940        // なお CSS アニメーションは本来インラインスタイルを書き換えない(実ブラウザでも
1941        // `element.style` には現れない)。この経路では DOM ブリッジを一切触らないので、
1942        // ブリッジの dirty を立てずに済み、`relayout_if_dom_dirty` の連鎖も起きない。
1943        let paint_applied = if computed.is_empty() {
1944            false
1945        } else {
1946            let scope = super::repaint_scope::scope_for(computed.iter().map(|(_, p, _)| p.as_str()));
1947            scope == super::repaint_scope::RepaintScope::PaintOnly
1948                && self.apply_paint_only_values(&computed)
1949        };
1950
1951        // 算出値を DOM ブリッジへ反映(軽量経路が使えなかったときのみ)。
1952        if !computed.is_empty() && !paint_applied {
1953            let mut dom = self.js_runtime.dom.borrow_mut();
1954            for (eid, prop, val) in &computed {
1955                if let Some(idx) = dom.get_element_by_id(eid) {
1956                    dom.set_style(idx, prop, val);
1957                }
1958            }
1959            // set_style が dirty=true を立てる。その dirty を parse_and_layout 内の
1960            // to_dom_node() 呼び出しに使うため、ここでは *クリアしない*。
1961            // to_dom_node() 後に b.dirty=false されるので無限ループも起きない。
1962            // in_anim_relayout=true により sync_animations は抑止されるため
1963            // prev 値が汚染されることもない。
1964        }
1965        // `animationend`/`transitionend`/`animationstart`(CSSアニメーション/
1966        // トランジションが自動でライフサイクルイベントを発火する経路が丸ごと
1967        // 未対応だった。`AnimationEvent`/`TransitionEvent`コンストラクタ自体は
1968        // 既に実装済みで手動dispatchは可能だったが、実際の開始/完了を検知して
1969        // 自動発火する仕組みが無かった)。`AnimationEngine::tick()`が検知した
1970        // ライフサイクルイベントを、要素IDからDOMノードindexへ解決して実際に
1971        // ディスパッチする。
1972        let lifecycle_events = self.anim_engine.lifecycle_events.clone();
1973        for (eid, event_type, name) in &lifecycle_events {
1974            let idx_opt = self.js_runtime.dom.borrow().get_element_by_id(eid);
1975            if let Some(idx) = idx_opt {
1976                let key = if *event_type == "transitionend" {
1977                    "propertyName"
1978                } else {
1979                    "animationName"
1980                };
1981                self.js_runtime.dispatch_event_with(
1982                    idx,
1983                    event_type,
1984                    &[(
1985                        alloc::string::String::from(key),
1986                        crate::os_lib::js::value::Value::str(name.clone()),
1987                    )],
1988                );
1989            }
1990        }
1991        // 【2026-08-02 発見・修正】算出値が 1 つも無いフレームで全再レイアウトしていた。
1992        //
1993        // 実測: `animation relayout` が 5 回起きているのに、
1994        // 「軽量経路が使えなかった理由」を出す診断は **1 回も出なかった**。
1995        // つまり `computed` が空のまま下の全再レイアウト(HTML 再パース+
1996        // 2403 ルールの再カスケード、実測約 6 秒)へ落ちていた。
1997        //
1998        // 算出値が無いということは、このフレームでアニメーションが
1999        // 変える値が何も無いということ。反映すべきものが無いので
2000        // レイアウトをやり直す理由も無い。
2001        //
2002        // ライフサイクルイベント(`animationend` 等)のハンドラが DOM を
2003        // 変更した場合は、ブリッジの dirty 経由で
2004        // `relayout_if_dom_dirty` が拾うため取りこぼさない。
2005        if computed.is_empty() {
2006            return true;
2007        }
2008
2009        // 軽量経路が成立したフレームはここで完了。再描画だけ要求して戻る。
2010        if paint_applied {
2011            self.dirty = true;
2012            self.dirty_reason = "layout.rs:1518";
2013            return true;
2014        }
2015
2016        // アニメーション由来の再レイアウトは、現状 HTML 再パース + 全ルール再カスケードを
2017        // 伴うため 1 フレームあたりのコストが極めて高い。実測コストが 500ms を超える場合、
2018        // 再レイアウトが描画を占有して「描いた内容が画面に出る前に次のレイアウトが始まる」
2019        // 状態に陥る。そこで実測時間の 3 倍のクールダウンを設け、デューティを 25% に抑える。
2020        // (本来の解はアニメ値の差し替えのみで再ペイントする軽量経路。spec/TODO.md 参照)
2021        let since = self.now_ms() - self.last_anim_relayout_end_ms;
2022        match super::anim_budget::should_skip_relayout(self.last_anim_layout_ms, since) {
2023            Ok(true) => {
2024                // アニメーションは継続中。値は DOM ブリッジに反映済みなので
2025                // 次に許可された再レイアウトでまとめて反映される。
2026                return true;
2027            }
2028            Ok(false) => {}
2029            Err(_) => {
2030                // 判定不能。エラーは anim_budget 側でログ済み。
2031                // 間引かない(従来動作)方向にフォールバックし、計測値をリセットする。
2032                self.last_anim_layout_ms = 0.0;
2033                self.last_anim_relayout_end_ms = 0.0;
2034            }
2035        }
2036
2037        // 再レイアウト(アニメ値を反映)。in_anim_relayout を立てて sync_animations 抑止。
2038        let html = self.last_html.clone();
2039        let was_executing = self.executing_script;
2040        self.executing_script = true;
2041        self.in_anim_relayout = true;
2042        // 【2026-07-27診断】再レイアウトの誘発元を切り分ける。
2043        // CSS アニメーションが 1 フレームごとに全再レイアウト(2403 ルールで
2044        // 約 46 秒)を要求していると、描画が完了する前に次の再レイアウトが
2045        // 始まり、描いた内容が画面に残らない。
2046        {
2047            static N: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
2048            let n = N.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
2049            if n < 8 {
2050                crate::warn!("[LAYOUT][SRC] animation relayout #{}", n + 1);
2051            }
2052        }
2053        let t0 = self.now_ms();
2054        self.parse_and_layout(&html, None, top_offset, self.win_w, self.win_h);
2055        let end = self.now_ms();
2056        self.last_anim_layout_ms = end - t0;
2057        self.last_anim_relayout_end_ms = end;
2058        self.in_anim_relayout = false;
2059        self.executing_script = was_executing;
2060        self.dirty = true;
2061        self.dirty_reason = "layout.rs:1566";
2062        true
2063    }
2064
2065    /// 現在のページ背景色の明度から、デフォルトの (文字色, リンク色) を返す。
2066    /// 明るい背景なら濃い文字+濃い青リンク、暗い背景なら明るい文字+シアンリンク。
2067    fn default_text_colors(&self) -> (u32, u32) {
2068        let bg = self.page_bg_color.unwrap_or(0xFFFFFFFF);
2069        let r = (bg >> 16) & 0xFF;
2070        let g = (bg >> 8) & 0xFF;
2071        let b = bg & 0xFF;
2072        let luma = (299 * r + 587 * g + 114 * b) / 1000;
2073        if luma > 140 {
2074            // 明るい背景
2075            (0xFF1A1A1A, 0xFF1A5FB4)
2076        } else {
2077            // 暗い背景(Dracula 系)
2078            (0xFFF8F8F2, 0xFF8BE9FD)
2079        }
2080    }
2081
2082    fn flatten_layout(
2083        &mut self,
2084        layout: &crate::os_lib::layout::LayoutBox,
2085        current_form_action: &str,
2086        current_form_method: &str,
2087        current_href: &str,
2088        current_font_bold: bool,
2089        current_font_italic: bool,
2090        current_underline: bool,
2091        current_line_through: bool,
2092        current_color: Option<u32>,
2093        current_font_size: u32,
2094        parent_id: &str,
2095        list_state: Option<(bool, u32)>,
2096        parent_tag: &str,
2097        current_decoration_color: Option<u32>,
2098        current_decoration_style: String,
2099        current_decoration_thickness: i32,
2100        current_decoration_underline_offset: i32,
2101        current_overline: bool,
2102        current_pointer_events_none: bool,
2103    ) {
2104        // 暴走防止: 病的なページ(巨大/深い入れ子)でも RenderElement の生成数を上限で
2105        // 打ち切り、ヒープ枯渇を未然に防ぐ。上限到達後は以降の要素を無視する。
2106        if self.elements.len() >= MAX_RENDER_ELEMENTS {
2107            return;
2108        }
2109
2110        fn extract_hover_color(styled: &crate::os_lib::css::StyledNode, key: &str) -> Option<u32> {
2111            styled
2112                .hover_values
2113                .as_ref()
2114                .and_then(|h| h.get(key).and_then(|s| crate::os_lib::css::parse_color(s)))
2115        }
2116
2117        fn extract_color(
2118            styled: &crate::os_lib::css::StyledNode,
2119            key1: &str,
2120            key2: &str,
2121        ) -> Option<u32> {
2122            let val = styled
2123                .specified_values
2124                .get(key1)
2125                .or_else(|| styled.specified_values.get(key2))?;
2126            // まずそのまま解析
2127            if let Some(c) = crate::os_lib::css::parse_color(val) {
2128                // 【2026-07-28】完全透明(アルファ 0)は「背景なし」と等価なので None を返す。
2129                // `parse_color` は不透明色に必ず 0xFF を立てるため、アルファ 0 は
2130                // `transparent` / `rgba(...,0)` を明示指定した場合だけを意味する。
2131                // これを色として返すと、描画側の「アルファ 0x00 は不透明として扱う」
2132                // 互換規則(`spec/alpha_compositing.md`)に拾われ **不透明な黒**になる。
2133                // 実際 `.nav-list` が `bg=0x00000000` で右半分を黒く塗り潰していた。
2134                if (c >> 24) & 0xFF == 0 {
2135                    return None;
2136                }
2137                return Some(c);
2138            }
2139            // `background: url(...) #color` / `background: #color url(...)` のような
2140            // ショートハンドから色トークンだけを取り出す
2141            for token in val.split_whitespace() {
2142                if token.starts_with("url(")
2143                    || token == "no-repeat"
2144                    || token == "repeat"
2145                    || token == "repeat-x"
2146                    || token == "repeat-y"
2147                    || token == "cover"
2148                    || token == "contain"
2149                    || token == "center"
2150                    || token == "top"
2151                    || token == "bottom"
2152                    || token == "left"
2153                    || token == "right"
2154                    || token.ends_with("px")
2155                    || token.ends_with('%')
2156                {
2157                    continue;
2158                }
2159                if let Some(c) = crate::os_lib::css::parse_color(token) {
2160                    return Some(c);
2161                }
2162            }
2163            None
2164        }
2165
2166        fn extract_hover_color_alt(
2167            styled: &crate::os_lib::css::StyledNode,
2168            key1: &str,
2169            key2: &str,
2170        ) -> Option<u32> {
2171            styled.hover_values.as_ref().and_then(|h| {
2172                h.get(key1)
2173                    .or_else(|| h.get(key2))
2174                    .and_then(|s| crate::os_lib::css::parse_color(s))
2175            })
2176        }
2177
2178        fn extract_focus_color(styled: &crate::os_lib::css::StyledNode, key: &str) -> Option<u32> {
2179            styled
2180                .focus_values
2181                .as_ref()
2182                .and_then(|h| h.get(key).and_then(|s| crate::os_lib::css::parse_color(s)))
2183        }
2184
2185        fn extract_focus_color_alt(
2186            styled: &crate::os_lib::css::StyledNode,
2187            key1: &str,
2188            key2: &str,
2189        ) -> Option<u32> {
2190            styled.focus_values.as_ref().and_then(|h| {
2191                h.get(key1)
2192                    .or_else(|| h.get(key2))
2193                    .and_then(|s| crate::os_lib::css::parse_color(s))
2194            })
2195        }
2196
2197        fn extract_active_color(styled: &crate::os_lib::css::StyledNode, key: &str) -> Option<u32> {
2198            styled
2199                .active_values
2200                .as_ref()
2201                .and_then(|h| h.get(key).and_then(|s| crate::os_lib::css::parse_color(s)))
2202        }
2203
2204        fn extract_active_color_alt(
2205            styled: &crate::os_lib::css::StyledNode,
2206            key1: &str,
2207            key2: &str,
2208        ) -> Option<u32> {
2209            styled.active_values.as_ref().and_then(|h| {
2210                h.get(key1)
2211                    .or_else(|| h.get(key2))
2212                    .and_then(|s| crate::os_lib::css::parse_color(s))
2213            })
2214        }
2215
2216        fn extract_marker_color(styled: &crate::os_lib::css::StyledNode) -> Option<u32> {
2217            styled
2218                .specified_values
2219                .get("x-marker-color")
2220                .and_then(|s| crate::os_lib::css::parse_color(s))
2221        }
2222
2223        fn extract_marker_font_size(styled: &crate::os_lib::css::StyledNode) -> Option<u32> {
2224            styled
2225                .specified_values
2226                .get("x-marker-font-size")
2227                .and_then(|s| parse_font_size(s))
2228        }
2229
2230        fn extract_marker_content(styled: &crate::os_lib::css::StyledNode) -> Option<String> {
2231            styled.specified_values.get("x-marker-content").cloned()
2232        }
2233
2234        fn extract_placeholder_color(styled: &crate::os_lib::css::StyledNode) -> Option<u32> {
2235            styled
2236                .specified_values
2237                .get("x-placeholder-color")
2238                .and_then(|s| crate::os_lib::css::parse_color(s))
2239        }
2240
2241        fn get_text_content(l: &crate::os_lib::layout::LayoutBox) -> String {
2242            let mut text = String::new();
2243            match &l.box_type {
2244                crate::os_lib::layout::BoxType::BlockNode(styled)
2245                | crate::os_lib::layout::BoxType::InlineNode(styled)
2246                | crate::os_lib::layout::BoxType::InlineBlockNode(styled) => {
2247                    if let crate::os_lib::dom::NodeType::Text(t) = &styled.node.node_type {
2248                        text.push_str(t);
2249                    }
2250                }
2251                _ => {}
2252            }
2253            for child in &l.children {
2254                text.push_str(&get_text_content(child));
2255            }
2256            text
2257        }
2258
2259        // <select> 配下の <option> を (value, ラベル) で収集する。
2260        // value 属性が無ければラベルテキストを value として用いる(HTML 仕様準拠)。
2261        // 戻り値: (options, selected_index)。selected 属性付き option が無ければ先頭を選択。
2262        fn collect_select_options(
2263            l: &crate::os_lib::layout::LayoutBox,
2264        ) -> (alloc::vec::Vec<(String, String)>, usize) {
2265            let mut opts: alloc::vec::Vec<(String, String)> = alloc::vec::Vec::new();
2266            let mut selected_idx = 0usize;
2267            fn walk(
2268                l: &crate::os_lib::layout::LayoutBox,
2269                opts: &mut alloc::vec::Vec<(String, String)>,
2270                selected_idx: &mut usize,
2271            ) {
2272                if let crate::os_lib::layout::BoxType::BlockNode(styled)
2273                | crate::os_lib::layout::BoxType::InlineNode(styled)
2274                | crate::os_lib::layout::BoxType::InlineBlockNode(styled) = &l.box_type
2275                {
2276                    if let crate::os_lib::dom::NodeType::Element {
2277                        tag_name,
2278                        attributes,
2279                        ..
2280                    } = &styled.node.node_type
2281                    {
2282                        if tag_name == "option" {
2283                            let label = String::from(get_text_content(l).trim());
2284                            let value = attributes
2285                                .get("value")
2286                                .cloned()
2287                                .unwrap_or_else(|| label.clone());
2288                            if attributes.contains_key("selected") {
2289                                *selected_idx = opts.len();
2290                            }
2291                            opts.push((value, label));
2292                            return; // option 配下はラベルとして消費済み
2293                        }
2294                    }
2295                }
2296                for child in &l.children {
2297                    walk(child, opts, selected_idx);
2298                }
2299            }
2300            walk(l, &mut opts, &mut selected_idx);
2301            (opts, selected_idx)
2302        }
2303
2304        // <label> 配下に内包された最初の input/select/textarea の id を返す。
2305        // <label><input id='x'> ...</label> のような暗黙的関連付け用。
2306        fn find_labelable_descendant_id(l: &crate::os_lib::layout::LayoutBox) -> Option<String> {
2307            if let crate::os_lib::layout::BoxType::BlockNode(styled)
2308            | crate::os_lib::layout::BoxType::InlineNode(styled)
2309            | crate::os_lib::layout::BoxType::InlineBlockNode(styled) = &l.box_type
2310            {
2311                if let crate::os_lib::dom::NodeType::Element {
2312                    tag_name,
2313                    attributes,
2314                    ..
2315                } = &styled.node.node_type
2316                {
2317                    if (tag_name == "input" || tag_name == "select" || tag_name == "textarea")
2318                        && attributes.contains_key("id")
2319                    {
2320                        return attributes.get("id").cloned();
2321                    }
2322                }
2323            }
2324            for child in &l.children {
2325                if let Some(id) = find_labelable_descendant_id(child) {
2326                    return Some(id);
2327                }
2328            }
2329            None
2330        }
2331
2332        match &layout.box_type {
2333            crate::os_lib::layout::BoxType::BlockNode(styled)
2334            | crate::os_lib::layout::BoxType::InlineNode(styled)
2335            | crate::os_lib::layout::BoxType::InlineBlockNode(styled)
2336            | crate::os_lib::layout::BoxType::FlexNode(styled)
2337            | crate::os_lib::layout::BoxType::TableNode(styled)
2338            | crate::os_lib::layout::BoxType::TableRowGroupNode(styled)
2339            | crate::os_lib::layout::BoxType::TableRowNode(styled)
2340            | crate::os_lib::layout::BoxType::TableCellNode(styled) => {
2341                match &styled.node.node_type {
2342                    crate::os_lib::dom::NodeType::Element {
2343                        tag_name,
2344                        attributes,
2345                        ..
2346                    } => {
2347                        // body/html 要素のページ全体背景色・文字色をキャプチャ
2348                        if tag_name == "body" || tag_name == "html" {
2349                            if self.page_bg_color.is_none() {
2350                                let bg = styled
2351                                    .specified_values
2352                                    .get("background-color")
2353                                    .or_else(|| styled.specified_values.get("background"))
2354                                    .and_then(|s| crate::os_lib::css::parse_color(s));
2355                                if bg.is_some() {
2356                                    self.page_bg_color = bg;
2357                                }
2358                            }
2359                            if self.page_text_color.is_none() {
2360                                let tc = styled
2361                                    .specified_values
2362                                    .get("color")
2363                                    .and_then(|s| crate::os_lib::css::parse_color(s));
2364                                if tc.is_some() {
2365                                    self.page_text_color = tc;
2366                                }
2367                            }
2368                        }
2369
2370                        let mut next_form_action = String::from(current_form_action);
2371                        let mut next_form_method = String::from(current_form_method);
2372                        let mut next_href = String::from(current_href);
2373                        if tag_name == "form" {
2374                            if let Some(action) = attributes.get("action") {
2375                                next_form_action = action.clone();
2376                            }
2377                            if let Some(method) = attributes.get("method") {
2378                                next_form_method = method.clone();
2379                            }
2380                        }
2381                        if tag_name == "a" {
2382                            if let Some(h) = attributes.get("href") {
2383                                next_href = h.clone();
2384                            }
2385                        }
2386
2387                        // `border-radius` 系の値は、`%` 指定(例: 正円アバターの定番
2388                        // `border-radius: 50%`)を要素サイズ(`container_w`)に対する
2389                        // 割合として解決する必要があるため、要素サイズが確定する
2390                        // `container_w`/`container_h` 計算後(本関数の下方)まで
2391                        // 生の CSS 文字列のまま保持しておく(2026-07-14。以前は
2392                        // ここで即座に px 解析しており `%` は常に「暫定で 0」に
2393                        // 切り捨てられ、`border-radius: 50%` 等が静かに無効化されて
2394                        // いたバグを修正)。
2395                        let border_radius_shorthand =
2396                            styled.specified_values.get("border-radius").cloned();
2397                        let border_radius_tl =
2398                            styled.specified_values.get("border-top-left-radius").cloned();
2399                        let border_radius_tr =
2400                            styled.specified_values.get("border-top-right-radius").cloned();
2401                        let border_radius_br =
2402                            styled.specified_values.get("border-bottom-right-radius").cloned();
2403                        let border_radius_bl =
2404                            styled.specified_values.get("border-bottom-left-radius").cloned();
2405
2406                        // 継承スタイルコンテキストを計算
2407                        let mut next_font_bold = current_font_bold;
2408                        let mut next_font_italic = current_font_italic;
2409                        let mut next_underline = current_underline;
2410                        let mut next_line_through = current_line_through;
2411                        let mut next_overline = current_overline;
2412                        let mut next_color: Option<u32> = current_color;
2413                        let mut next_font_size: u32 = current_font_size;
2414                        let mut next_decoration_color = current_decoration_color;
2415                        let mut next_decoration_style = current_decoration_style.clone();
2416                        let mut next_decoration_thickness = current_decoration_thickness;
2417                        let mut next_decoration_underline_offset = current_decoration_underline_offset;
2418                        // pointer-events: none は継承する(inherited)。子孫で明示的に auto に
2419                        // 戻すケース(`pointer-events:auto` による部分的な復元)は非対応の簡略実装。
2420                        let mut next_pointer_events_none = current_pointer_events_none;
2421                        if let Some(pe) = styled.value("pointer-events") {
2422                            let pe_l = pe.trim().to_lowercase();
2423                            if pe_l == "none" {
2424                                next_pointer_events_none = true;
2425                            } else if pe_l == "auto" {
2426                                next_pointer_events_none = false;
2427                            }
2428                        }
2429                        if let Some(dc) = styled.value("text-decoration-color") {
2430                            if let Some(c) = crate::os_lib::css::parse_color(dc.trim()) {
2431                                next_decoration_color = Some(c);
2432                            }
2433                        }
2434                        if let Some(ds) = styled.value("text-decoration-style") {
2435                            let ds_l = ds.trim().to_lowercase();
2436                            if matches!(ds_l.as_str(), "solid" | "dotted" | "dashed" | "wavy" | "double") {
2437                                next_decoration_style = ds_l;
2438                            }
2439                        }
2440                        if let Some(dt) = styled.value("text-decoration-thickness") {
2441                            let dt_t = dt.trim();
2442                            if !dt_t.eq_ignore_ascii_case("auto") && !dt_t.eq_ignore_ascii_case("from-font") {
2443                                next_decoration_thickness = parse_px_val(dt_t).max(1);
2444                            }
2445                        }
2446                        if let Some(uo) = styled.value("text-underline-offset") {
2447                            let uo_t = uo.trim();
2448                            if !uo_t.eq_ignore_ascii_case("auto") {
2449                                next_decoration_underline_offset = parse_px_val(uo_t);
2450                            }
2451                        }
2452                        // `text-decoration-line`(CSS Text Decoration Module Level 3の
2453                        // ロングハンド。`-color`/`-style`/`-thickness` は既に対応済みだったが
2454                        // 肝心の `-line` 単体指定だけが完全に無視され、`text-decoration`
2455                        // ショートハンド経由でしか下線等を付けられなかった)。
2456                        if let Some(tdl) = styled.value("text-decoration-line") {
2457                            let (u, lt, ov, is_none, _, _) = parse_text_decoration_shorthand(&tdl);
2458                            if is_none {
2459                                next_underline = false;
2460                                next_line_through = false;
2461                                next_overline = false;
2462                            } else {
2463                                if u {
2464                                    next_underline = true;
2465                                }
2466                                if lt {
2467                                    next_line_through = true;
2468                                }
2469                                if ov {
2470                                    next_overline = true;
2471                                }
2472                            }
2473                        }
2474                        if let Some(fw) = styled.value("font-weight") {
2475                            let fw_l = fw.trim().to_lowercase();
2476                            if fw_l == "bold"
2477                                || fw_l == "bolder"
2478                                || fw_l.parse::<u32>().unwrap_or(400) >= 700
2479                            {
2480                                next_font_bold = true;
2481                            } else if fw_l == "normal" || fw_l.parse::<u32>().unwrap_or(400) < 700 {
2482                                next_font_bold = false;
2483                            }
2484                        }
2485                        if let Some(fs) = styled.value("font-style") {
2486                            let fs_l = fs.trim().to_lowercase();
2487                            if fs_l == "italic" || fs_l == "oblique" {
2488                                next_font_italic = true;
2489                            } else if fs_l == "normal" {
2490                                next_font_italic = false;
2491                            }
2492                        }
2493                        if let Some(td) = styled.value("text-decoration") {
2494                            let (u, lt, ov, is_none, style, color) =
2495                                parse_text_decoration_shorthand(&td);
2496                            if is_none {
2497                                next_underline = false;
2498                                next_line_through = false;
2499                                next_overline = false;
2500                            } else {
2501                                if u {
2502                                    next_underline = true;
2503                                }
2504                                if lt {
2505                                    next_line_through = true;
2506                                }
2507                                if ov {
2508                                    next_overline = true;
2509                                }
2510                                if let Some(s) = style {
2511                                    next_decoration_style = s;
2512                                }
2513                                if let Some(c) = color {
2514                                    next_decoration_color = Some(c);
2515                                }
2516                            }
2517                        }
2518                        if tag_name == "b" || tag_name == "strong" {
2519                            next_font_bold = true;
2520                        }
2521                        if tag_name == "i" || tag_name == "em" {
2522                            next_font_italic = true;
2523                        }
2524                        if tag_name == "u" {
2525                            next_underline = true;
2526                        }
2527                        if tag_name == "s" || tag_name == "del" || tag_name == "strike" {
2528                            next_line_through = true;
2529                        }
2530                        let effective_color_str = styled
2531                            .value("-webkit-text-fill-color")
2532                            .or_else(|| styled.value("color"));
2533                        if let Some(c_str) = effective_color_str {
2534                            if let Some(c_val) = parse_color(&c_str) {
2535                                let is_bg_clip_text = styled.value("background-clip").as_deref().map(str::trim) == Some("text")
2536                                    || styled.value("-webkit-background-clip").as_deref().map(str::trim) == Some("text");
2537                                if (c_val >> 24) == 0 && is_bg_clip_text {
2538                                    if next_color.is_none() {
2539                                        next_color = Some(0xFF000000);
2540                                    }
2541                                } else {
2542                                    next_color = Some(c_val);
2543                                }
2544                            }
2545                        }
2546                        if let Some(sz_str) = styled.value("font-size") {
2547                            if let Some(sz_val) = parse_font_size(&sz_str) {
2548                                next_font_size = sz_val;
2549                            }
2550                        }
2551                        let aspect_ratio_val = styled
2552                            .value("aspect-ratio")
2553                            .and_then(|v| parse_aspect_ratio(&v));
2554
2555                        let is_hr = tag_name == "hr";
2556                        let is_img = tag_name == "img";
2557                        // 【2026-09-03】`<canvas>` の 2D コンテキスト id。
2558                        //
2559                        // `getContext("2d")` が呼ばれると JS 側が
2560                        // `_c2d_id` 属性へ registry の id を書く。ここで拾って
2561                        // `RenderElement` へ載せ、描画時に `Surface` の内容を
2562                        // 要素の矩形へ転送する。
2563                        //
2564                        // DOM 属性を経由するのは、レイアウトが `StyledNode`
2565                        // しか見ないため。JS のオブジェクトを直接引くと
2566                        // 層をまたぐ依存になる。
2567                        let canvas_ctx_id: Option<u32> = if tag_name == "canvas" {
2568                            attributes
2569                                .get("_c2d_id")
2570                                .and_then(|s| s.trim().parse::<u32>().ok())
2571                        } else {
2572                            None
2573                        };
2574                        let is_video = tag_name == "video";
2575                        // <progress>/<meter>(HTML5 ネイティブ進捗/計量バー)。`<img>`/`<video>` と
2576                        // 同じ「置換要素」として扱い、専用の RenderElement を構築する。
2577                        // <meter> の low/high/optimum による色分けは非対応(常に同じ色で描画)。
2578                        let is_progress = tag_name == "progress" || tag_name == "meter";
2579                        let progress_ratio: f32 = if is_progress {
2580                            let parse_f32 = |k: &str, default: f32| -> f32 {
2581                                attributes
2582                                    .get(k)
2583                                    .and_then(|v| v.trim().parse::<f32>().ok())
2584                                    .unwrap_or(default)
2585                            };
2586                            let min = if tag_name == "meter" { parse_f32("min", 0.0) } else { 0.0 };
2587                            let max = parse_f32("max", 1.0);
2588                            let value = parse_f32("value", min);
2589                            let range = (max - min).max(0.0001);
2590                            ((value - min) / range).clamp(0.0, 1.0)
2591                        } else {
2592                            0.0
2593                        };
2594                        let mut is_input = tag_name == "input";
2595                        let is_textarea = tag_name == "textarea";
2596                        let mut is_button = tag_name == "button";
2597                        let is_select = tag_name == "select";
2598                        let mut is_reset_btn = false;
2599
2600                        // input 種別判定: checkbox/radio はトグル要素、submit/button/reset はボタン。
2601                        let mut input_kind: u8 = 0;
2602                        if is_input {
2603                            if let Some(t) = attributes.get("type") {
2604                                let tl = t.trim().to_lowercase();
2605                                if tl == "submit" || tl == "button" || tl == "reset" {
2606                                    is_input = false;
2607                                    is_button = true;
2608                                    if tl == "reset" {
2609                                        is_reset_btn = true;
2610                                    }
2611                                } else if tl == "checkbox" {
2612                                    input_kind = 1;
2613                                } else if tl == "radio" {
2614                                    input_kind = 2;
2615                                } else if tl == "range" {
2616                                    input_kind = 5;
2617                                }
2618                            }
2619                        }
2620                        if is_select {
2621                            input_kind = 3;
2622                        }
2623                        // <button type="reset"> も reset ボタンとして扱う。
2624                        if tag_name == "button" {
2625                            if let Some(t) = attributes.get("type") {
2626                                if t.trim().eq_ignore_ascii_case("reset") {
2627                                    is_reset_btn = true;
2628                                }
2629                            }
2630                        }
2631
2632                        let mut el_id = String::new();
2633                        if let Some(id_val) = attributes.get("id") {
2634                            el_id = id_val.clone();
2635                        } else if let Some(name_val) = attributes.get("name") {
2636                            el_id = name_val.clone();
2637                        }
2638                        // フォーム送信のパラメータ名は `name` 属性を用いる(element_id とは別物)。
2639                        // id を持つ入力でも name で送信されるよう element_id → name を記録する。
2640                        if (is_input || is_textarea) && !el_id.is_empty() {
2641                            if let Some(name_val) = attributes.get("name") {
2642                                self.field_names.insert(el_id.clone(), name_val.clone());
2643                            }
2644                        }
2645
2646                        let mut onclick = String::new();
2647                        if let Some(oc) = attributes.get("onclick") {
2648                            onclick = oc.clone();
2649                        }
2650
2651                        let mut next_parent_id = String::from(parent_id);
2652                        if !el_id.is_empty() {
2653                            next_parent_id = el_id.clone();
2654                        }
2655
2656                        // --- New CSS features: position, opacity, box-shadow, white-space, text-overflow, linear-gradient ---
2657                        let position_val = styled
2658                            .value("position")
2659                            .unwrap_or_else(|| String::from("static"));
2660                        // `inset` ショートハンド(top/right/bottom/left の一括指定。CSS Box
2661                        // Alignment/Positioning。margin と同じ1〜4値の展開規則)。個別の
2662                        // longhand(top/left/right/bottom)が明示されていればそちらを優先する。
2663                        let inset_val = styled.value("inset");
2664                        fn inset_component(shorthand: &str, side: usize) -> Option<&str> {
2665                            // side: 0=top, 1=right, 2=bottom, 3=left
2666                            let toks: alloc::vec::Vec<&str> = shorthand.split_whitespace().collect();
2667                            match toks.len() {
2668                                1 => toks.first().copied(),
2669                                2 => match side {
2670                                    0 | 2 => toks.first().copied(),
2671                                    _ => toks.get(1).copied(),
2672                                },
2673                                3 => match side {
2674                                    0 => toks.first().copied(),
2675                                    2 => toks.get(2).copied(),
2676                                    _ => toks.get(1).copied(),
2677                                },
2678                                4 => toks.get(side).copied(),
2679                                _ => None,
2680                            }
2681                        }
2682                        fn parse_px_i32(v: &str) -> Option<i32> {
2683                            v.trim()
2684                                .strip_suffix("px")
2685                                .unwrap_or(v.trim())
2686                                .parse::<i32>()
2687                                .ok()
2688                        }
2689                        let pos_top = styled.value("top").and_then(|v| parse_px_i32(&v)).or_else(
2690                            || inset_val.as_deref().and_then(|s| inset_component(s, 0)).and_then(parse_px_i32),
2691                        );
2692                        let pos_left = styled.value("left").and_then(|v| parse_px_i32(&v)).or_else(
2693                            || inset_val.as_deref().and_then(|s| inset_component(s, 3)).and_then(parse_px_i32),
2694                        );
2695                        let pos_right = styled.value("right").and_then(|v| parse_px_i32(&v)).or_else(
2696                            || inset_val.as_deref().and_then(|s| inset_component(s, 1)).and_then(parse_px_i32),
2697                        );
2698                        let pos_bottom = styled.value("bottom").and_then(|v| parse_px_i32(&v)).or_else(
2699                            || inset_val.as_deref().and_then(|s| inset_component(s, 2)).and_then(parse_px_i32),
2700                        );
2701                        // `z-index` は position が static(既定値)以外のときにのみ効果を持つ
2702                        // 仕様(CSS2.1/CSS3)。以前は position を一切見ておらず、static な
2703                        // 要素にも `z-index` がそのまま適用されてしまい、意図しない重なり順に
2704                        // なるバグがあった。
2705                        let own_z = if position_val == "static" {
2706                            0
2707                        } else {
2708                            styled
2709                                .value("z-index")
2710                                .and_then(|v| v.trim().parse::<i32>().ok())
2711                                .unwrap_or(0)
2712                        };
2713                        // 【2026-07-28】祖先の実効 z まで引き上げる。
2714                        // これをやらないと `section{position:relative;z-index:5}` の
2715                        // static な子孫(z=0)が section より前に描かれ、直後に
2716                        // section の背景で丸ごと塗り潰される(実際に踏んだ回帰)。
2717                        // 仕様と不変条件は `spec/paint_order.md` を参照。
2718                        let z_index_val = super::paint_order::effective_z(
2719                            own_z,
2720                            Some(self.inherited_paint_z),
2721                        );
2722                        let is_fixed_pos = position_val == "fixed";
2723                        let is_sticky_pos = position_val == "sticky";
2724
2725                        // overflow: hidden/scroll/auto/clip は子孫をこの要素の content box でクリップする。
2726                        // scroll/auto のときはさらにサブスクロールコンテナとして扱う。
2727                        // `overflow` ショートハンドは `<overflow-x>`(1値なら両軸に適用)または
2728                        // `<overflow-x> <overflow-y>`(2値)を受け付ける。
2729                        let (overflow_x_val, overflow_y_val) = match styled.value("overflow") {
2730                            Some(v) => {
2731                                let toks: alloc::vec::Vec<&str> = v.split_whitespace().collect();
2732                                match toks.as_slice() {
2733                                    [x, y] => (String::from(*x), String::from(*y)),
2734                                    [xy] => (String::from(*xy), String::from(*xy)),
2735                                    _ => (String::new(), String::new()),
2736                                }
2737                            }
2738                            None => (
2739                                styled.value("overflow-x").unwrap_or_default(),
2740                                styled.value("overflow-y").unwrap_or_default(),
2741                            ),
2742                        };
2743                        let axis_clips = |v: &str| matches!(v.trim(), "hidden" | "scroll" | "auto" | "clip");
2744                        let axis_scrolls = |v: &str| matches!(v.trim(), "scroll" | "auto");
2745                        // 軸ごとに独立してクリップするか判定する。以前は x/y いずれかが該当すれば
2746                        // 矩形1つ丸ごとをクリップに使っていたため、`overflow-x:hidden;
2747                        // overflow-y:visible`(またはその逆)のような軸ごとに異なる指定で、
2748                        // 本来クリップされないはずの軸まで誤ってクリップされてしまうバグがあった。
2749                        let x_clips = axis_clips(&overflow_x_val);
2750                        let y_clips = axis_clips(&overflow_y_val);
2751                        let overflow_clips = x_clips || y_clips;
2752                        // scroll/auto: 独立スクロール領域を作る(element_id が必要)。
2753                        let is_scroll_container =
2754                            axis_scrolls(&overflow_x_val) || axis_scrolls(&overflow_y_val);
2755
2756                        let opacity_val: u8 = styled
2757                            .value("opacity")
2758                            .and_then(|v| {
2759                                v.trim()
2760                                    .parse::<f32>()
2761                                    .ok()
2762                                    .map(|f| (f.clamp(0.0, 1.0) * 255.0) as u8)
2763                            })
2764                            .unwrap_or(255);
2765
2766                        let _white_space_nowrap = styled
2767                            .value("white-space")
2768                            .map(|v| v.trim() == "nowrap")
2769                            .unwrap_or(false);
2770                        let _text_overflow_ellipsis = styled
2771                            .value("text-overflow")
2772                            .map(|v| v.trim() == "ellipsis")
2773                            .unwrap_or(false);
2774
2775                        // Parse box-shadow: `<Xpx> <Ypx> [<Blur>px] [<color>] [inset]` を
2776                        // 最上位カンマで区切った複数シャドウに対応(box-shadow の値自体に
2777                        // 括弧を含む余地は無いため、単純な `,` 分割で安全に複数シャドウを
2778                        // 分離できる)。各セグメントを独立してトークン解析する。
2779                        // オフセット/ぼかしの長さトークン解決は`parse_shadow_len_tok`
2780                        // (モジュールトップレベル。丸ごと未対応だった`rem`単位を
2781                        // 2026-07-17に追加。詳細は同関数のdocコメント参照)を使う。
2782                        fn parse_one_box_shadow(seg: &str) -> Option<(i32, i32, i32, u32, bool)> {
2783                            let seg = seg.trim();
2784                            if seg.is_empty() {
2785                                return None;
2786                            }
2787                            let mut ox = 0i32;
2788                            let mut oy = 0i32;
2789                            let mut _blur = 0i32;
2790                            let mut shadow_color = 0x80000000u32;
2791                            let mut px_count = 0;
2792                            let mut is_inset = false;
2793                            for token in seg.split_whitespace() {
2794                                if token == "inset" {
2795                                    is_inset = true;
2796                                    continue;
2797                                }
2798                                if let Some(n) = parse_shadow_len_tok(token) {
2799                                    match px_count {
2800                                        0 => {
2801                                            ox = n;
2802                                            px_count += 1;
2803                                        }
2804                                        1 => {
2805                                            oy = n;
2806                                            px_count += 1;
2807                                        }
2808                                        2 => {
2809                                            _blur = n;
2810                                            px_count += 1;
2811                                        }
2812                                        _ => {}
2813                                    }
2814                                    continue;
2815                                }
2816                                if let Some(c) = parse_color(token) {
2817                                    shadow_color = c;
2818                                }
2819                            }
2820                            Some((ox, oy, _blur, shadow_color, is_inset))
2821                        }
2822                        let mut box_shadow: alloc::vec::Vec<(i32, i32, i32, u32, bool)> =
2823                            styled.value("box-shadow").map_or_else(alloc::vec::Vec::new, |v| {
2824                                let v = v.trim().to_lowercase();
2825                                if v == "none" {
2826                                    alloc::vec::Vec::new()
2827                                } else {
2828                                    // 【2026-08-03 発見・修正】`split(',')` は
2829                                    // **`rgba(0, 0, 0, 0.1)` の中のカンマでも切ってしまう**。
2830                                    //
2831                                    // 実サイトの `.research-card` は
2832                                    // `box-shadow: 0 2px 10px rgba(0,0,0,0.1)`(影 1 個)だが、
2833                                    // 実測で `shadows=4` になっていた。壊れた断片が
2834                                    // 影として解釈され、白いカードが白地の上で
2835                                    // 輪郭を失っていた。
2836                                    //
2837                                    // 括弧の深さを見る既存ヘルパ
2838                                    // (`css::values::split_top_level_commas`。
2839                                    //  グラデーションで同じ罠を踏んで作ったもの)を使う。
2840                                    crate::os_lib::css::split_top_level_commas(&v)
2841                                        .into_iter()
2842                                        .filter_map(parse_one_box_shadow)
2843                                        .collect()
2844                                }
2845                            });
2846
2847                        if box_shadow.is_empty() {
2848                            if let Some(f) = styled.value("filter") {
2849                                if let Some(ds) =
2850                                    crate::os_lib::web_engine::draw_helpers::parse_drop_shadow(&f)
2851                                {
2852                                    box_shadow.push(ds);
2853                                }
2854                            }
2855                        }
2856
2857                        // Parse linear-gradient from background or background-image
2858                        let linear_gradient = {
2859                            let bg_val = styled
2860                                .value("background")
2861                                .or_else(|| styled.value("background-image"));
2862                            bg_val.and_then(|v| {
2863                                let v_lower = v.to_lowercase();
2864                                if !v_lower.contains("linear-gradient")
2865                                    || v_lower.contains("repeating-linear-gradient")
2866                                {
2867                                    return None;
2868                                }
2869                                // `rgba(...)` が引数に入るため、単純な find(')') では
2870                                // rgba の閉じ括弧を拾って定義が壊れる(実際に踏んだ回帰)。
2871                                // 入れ子対応の対応括弧探索を使う。
2872                                let open_paren = v_lower.find("linear-gradient(")? + 15;
2873                                let inner_start = open_paren + 1;
2874                                let inner_end =
2875                                    super::css_units::find_balanced_close(&v, open_paren)?;
2876                                let inner = v.get(inner_start..inner_end)?;
2877                                let mut parts = super::css_units::split_top_level_commas(inner).into_iter();
2878                                let first = parts.next()?.trim();
2879                                
2880                                let mut angle_deg = 180.0; // default to bottom
2881                                let mut is_first_color = true;
2882                                
2883                                // 角度や方向の判定
2884                                let first_lower = first.to_lowercase();
2885                                if first_lower.starts_with("to ") {
2886                                    if first_lower == "to top" { angle_deg = 0.0; }
2887                                    else if first_lower == "to right" { angle_deg = 90.0; }
2888                                    else if first_lower == "to bottom" { angle_deg = 180.0; }
2889                                    else if first_lower == "to left" { angle_deg = 270.0; }
2890                                    else if first_lower == "to top right" || first_lower == "to right top" { angle_deg = 45.0; }
2891                                    else if first_lower == "to bottom right" || first_lower == "to right bottom" { angle_deg = 135.0; }
2892                                    else if first_lower == "to bottom left" || first_lower == "to left bottom" { angle_deg = 225.0; }
2893                                    else if first_lower == "to top left" || first_lower == "to left top" { angle_deg = 315.0; }
2894                                    is_first_color = false;
2895                                } else if first_lower.ends_with("deg") {
2896                                    if let Ok(deg) = first_lower.trim_end_matches("deg").trim().parse::<f32>() {
2897                                        angle_deg = deg;
2898                                        is_first_color = false;
2899                                    }
2900                                } else if first_lower.ends_with("turn") {
2901                                    if let Ok(turn) = first_lower.trim_end_matches("turn").trim().parse::<f32>() {
2902                                        angle_deg = turn * 360.0;
2903                                        is_first_color = false;
2904                                    }
2905                                }
2906                                
2907                                let mut raw_stops = alloc::vec::Vec::new();
2908                                if is_first_color {
2909                                    raw_stops.push(first);
2910                                }
2911                                for p in parts {
2912                                    raw_stops.push(p.trim());
2913                                }
2914                                
2915                                let mut stops = alloc::vec::Vec::new();
2916                                for (i, s) in raw_stops.iter().enumerate() {
2917                                    let mut tokens = s.split_whitespace();
2918                                    if let Some(c_str) = tokens.next() {
2919                                        if let Some(c) = parse_color(c_str) {
2920                                            let mut pos = None;
2921                                            if let Some(p_str) = tokens.next() {
2922                                                if p_str.ends_with('%') {
2923                                                    if let Ok(v) = p_str.trim_end_matches('%').parse::<f32>() {
2924                                                        pos = Some(v / 100.0);
2925                                                    }
2926                                                }
2927                                            }
2928                                            stops.push((c, pos));
2929                                        }
2930                                    }
2931                                }
2932                                
2933                                if stops.len() < 2 {
2934                                    return None;
2935                                }
2936                                
2937                                // 位置の自動計算 (省略されている場合)
2938                                let n = stops.len();
2939                                if stops[0].1.is_none() { stops[0].1 = Some(0.0); }
2940                                if stops[n-1].1.is_none() { stops[n-1].1 = Some(1.0); }
2941                                
2942                                let mut final_stops = alloc::vec::Vec::new();
2943                                let mut last_pos = 0.0;
2944                                for i in 0..n {
2945                                    if let Some(p) = stops[i].1 {
2946                                        last_pos = p;
2947                                    } else {
2948                                        // Find next specified position
2949                                        let mut next_pos = 1.0;
2950                                        let mut next_idx = n - 1;
2951                                        for j in i+1..n {
2952                                            if let Some(p) = stops[j].1 {
2953                                                next_pos = p;
2954                                                next_idx = j;
2955                                                break;
2956                                            }
2957                                        }
2958                                        let step = (next_pos - last_pos) / ((next_idx - i + 1) as f32);
2959                                        last_pos += step;
2960                                    }
2961                                    final_stops.push(super::GradientStop {
2962                                        color: stops[i].0,
2963                                        position: last_pos,
2964                                    });
2965                                }
2966
2967                                Some(super::LinearGradient {
2968                                    angle_deg,
2969                                    stops: final_stops,
2970                                })
2971                            })
2972                        };
2973
2974                        // Parse radial-gradient(circle, c1, c2) from background/background-image.
2975                        // 楕円形状・位置指定は非対応で、常に矩形の中心を起点とする簡略実装。
2976                        let radial_gradient = {
2977                            let bg_val = styled
2978                                .value("background")
2979                                .or_else(|| styled.value("background-image"));
2980                            bg_val.and_then(|v| {
2981                                let v_lower = v.to_lowercase();
2982                                if !v_lower.contains("radial-gradient")
2983                                    || v_lower.contains("repeating-radial-gradient")
2984                                {
2985                                    return None;
2986                                }
2987                                let inner_start = v_lower.find("radial-gradient(")? + 17;
2988                                let inner_end =
2989                                    v_lower.get(inner_start..)?.find(')')? + inner_start;
2990                                let inner = v.get(inner_start..inner_end)?;
2991                                let mut parts = inner.split(',');
2992                                let first = parts.next()?.trim();
2993                                
2994                                let mut shape = super::RadialShape::Ellipse;
2995                                let mut pos_x = 0.5;
2996                                let mut pos_y = 0.5;
2997                                let mut is_first_color = true;
2998                                
2999                                let first_lower = first.to_lowercase();
3000                                if first_lower.contains("circle") || first_lower.contains("ellipse") || first_lower.contains("at ") {
3001                                    is_first_color = false;
3002                                    if first_lower.contains("circle") {
3003                                        shape = super::RadialShape::Circle;
3004                                    }
3005                                    if let Some(at_idx) = first_lower.find("at ") {
3006                                        let pos_str = first_lower.get(at_idx + 3..)?;
3007                                        let mut pos_tokens = pos_str.split_whitespace();
3008                                        if let Some(x_str) = pos_tokens.next() {
3009                                            if x_str == "left" { pos_x = 0.0; }
3010                                            else if x_str == "right" { pos_x = 1.0; }
3011                                            else if x_str == "center" { pos_x = 0.5; }
3012                                            else if x_str.ends_with('%') {
3013                                                if let Ok(v) = x_str.trim_end_matches('%').parse::<f32>() {
3014                                                    pos_x = v / 100.0;
3015                                                }
3016                                            }
3017                                        }
3018                                        if let Some(y_str) = pos_tokens.next() {
3019                                            if y_str == "top" { pos_y = 0.0; }
3020                                            else if y_str == "bottom" { pos_y = 1.0; }
3021                                            else if y_str == "center" { pos_y = 0.5; }
3022                                            else if y_str.ends_with('%') {
3023                                                if let Ok(v) = y_str.trim_end_matches('%').parse::<f32>() {
3024                                                    pos_y = v / 100.0;
3025                                                }
3026                                            }
3027                                        } else {
3028                                            pos_y = pos_x; // 1つだけ指定された場合の中央寄せなど (厳密な仕様ではないが簡略)
3029                                        }
3030                                    }
3031                                }
3032                                
3033                                let mut raw_stops = alloc::vec::Vec::new();
3034                                if is_first_color {
3035                                    raw_stops.push(first);
3036                                }
3037                                for p in parts {
3038                                    raw_stops.push(p.trim());
3039                                }
3040                                
3041                                let mut stops = alloc::vec::Vec::new();
3042                                for (i, s) in raw_stops.iter().enumerate() {
3043                                    let mut tokens = s.split_whitespace();
3044                                    if let Some(c_str) = tokens.next() {
3045                                        if let Some(c) = parse_color(c_str) {
3046                                            let mut pos = None;
3047                                            if let Some(p_str) = tokens.next() {
3048                                                if p_str.ends_with('%') {
3049                                                    if let Ok(v) = p_str.trim_end_matches('%').parse::<f32>() {
3050                                                        pos = Some(v / 100.0);
3051                                                    }
3052                                                }
3053                                            }
3054                                            stops.push((c, pos));
3055                                        }
3056                                    }
3057                                }
3058                                
3059                                if stops.len() < 2 {
3060                                    return None;
3061                                }
3062                                
3063                                let n = stops.len();
3064                                if stops[0].1.is_none() { stops[0].1 = Some(0.0); }
3065                                if stops[n-1].1.is_none() { stops[n-1].1 = Some(1.0); }
3066                                
3067                                let mut final_stops = alloc::vec::Vec::new();
3068                                let mut last_pos = 0.0;
3069                                for i in 0..n {
3070                                    if let Some(p) = stops[i].1 {
3071                                        last_pos = p;
3072                                    } else {
3073                                        let mut next_pos = 1.0;
3074                                        let mut next_idx = n - 1;
3075                                        for j in i+1..n {
3076                                            if let Some(p) = stops[j].1 {
3077                                                next_pos = p;
3078                                                next_idx = j;
3079                                                break;
3080                                            }
3081                                        }
3082                                        let step = (next_pos - last_pos) / ((next_idx - i + 1) as f32);
3083                                        last_pos += step;
3084                                    }
3085                                    final_stops.push(super::GradientStop {
3086                                        color: stops[i].0,
3087                                        position: last_pos,
3088                                    });
3089                                }
3090
3091                                Some(super::RadialGradient {
3092                                    shape,
3093                                    pos_x,
3094                                    pos_y,
3095                                    stops: final_stops,
3096                                })
3097                            })
3098                        };
3099
3100                        // Parse conic-gradient(c1, c2, ...) from background/background-image.
3101                        // 全カラーストップを角度方向へ等間隔配置する(`from`/位置指定は非対応の
3102                        // 簡略実装、radial-gradient と同じ方針)。以前は先頭2色しか保持できない
3103                        // `(u32,u32)` だったため3色目以降が丸ごと失われるバグがあった。
3104                        let conic_gradient = {
3105                            let bg_val = styled
3106                                .value("background")
3107                                .or_else(|| styled.value("background-image"));
3108                            bg_val.and_then(|v| {
3109                                let v_lower = v.to_lowercase();
3110                                if !v_lower.contains("conic-gradient") {
3111                                    return None;
3112                                }
3113                                let inner_start = v_lower.find("conic-gradient(")? + 15;
3114                                let inner_end =
3115                                    v_lower.get(inner_start..)?.find(')')? + inner_start;
3116                                let inner = v.get(inner_start..inner_end)?;
3117                                let parts: Vec<&str> = inner.split(',').collect();
3118                                let colors: Vec<u32> = parts
3119                                    .iter()
3120                                    .filter_map(|p| parse_color(p.trim()))
3121                                    .collect();
3122                                if colors.len() < 2 {
3123                                    return None;
3124                                }
3125                                let n = colors.len();
3126                                let stops: alloc::vec::Vec<super::GradientStop> = colors
3127                                    .into_iter()
3128                                    .enumerate()
3129                                    .map(|(i, color)| super::GradientStop {
3130                                        color,
3131                                        position: i as f32 / (n - 1) as f32,
3132                                    })
3133                                    .collect();
3134                                Some(stops)
3135                            })
3136                        };
3137
3138                        // repeating-linear-gradient(c1 [len], c2 len, ...) / repeating-radial-gradient(...):
3139                        // 色トークンの中から末尾の px 長さトークンを周期(cycle_px)として使う簡略実装
3140                        // (角度/形状/位置指定は非対応、px 以外の単位も非対応)。全カラーストップを
3141                        // 収集し周期内で均等割り補間する(以前は先頭2色のみで3色目以降が失われていた)。
3142                        fn extract_px_cycle(parts: &[&str]) -> Option<i32> {
3143                            parts
3144                                .iter()
3145                                .rev()
3146                                .find_map(|p| {
3147                                    p.trim()
3148                                        .rsplit(' ')
3149                                        .next()
3150                                        .and_then(|tok| tok.strip_suffix("px"))
3151                                        .and_then(|n| n.parse::<i32>().ok())
3152                                })
3153                                .filter(|n| *n > 0)
3154                        }
3155                        let repeating_linear_gradient = {
3156                            let bg_val = styled
3157                                .value("background")
3158                                .or_else(|| styled.value("background-image"));
3159                            bg_val.and_then(|v| {
3160                                let v_lower = v.to_lowercase();
3161                                if !v_lower.contains("repeating-linear-gradient") {
3162                                    return None;
3163                                }
3164                                let inner_start = v_lower.find("repeating-linear-gradient(")? + 26;
3165                                let inner_end =
3166                                    v_lower.get(inner_start..)?.find(')')? + inner_start;
3167                                let inner = v.get(inner_start..inner_end)?;
3168                                let parts: Vec<&str> = inner.split(',').collect();
3169                                let colors: Vec<u32> = parts
3170                                    .iter()
3171                                    .filter_map(|p| parse_color(p.trim().split(' ').next().unwrap_or("")))
3172                                    .collect();
3173                                if colors.len() < 2 {
3174                                    return None;
3175                                }
3176                                let cycle = extract_px_cycle(&parts).unwrap_or(20);
3177                                let dir = parts[0].trim().to_lowercase();
3178                                let is_vertical =
3179                                    dir.contains("bottom") || dir == "180deg" || dir == "to bottom";
3180                                Some((colors, cycle, is_vertical))
3181                            })
3182                        };
3183                        let repeating_radial_gradient = {
3184                            let bg_val = styled
3185                                .value("background")
3186                                .or_else(|| styled.value("background-image"));
3187                            bg_val.and_then(|v| {
3188                                let v_lower = v.to_lowercase();
3189                                if !v_lower.contains("repeating-radial-gradient") {
3190                                    return None;
3191                                }
3192                                let inner_start = v_lower.find("repeating-radial-gradient(")? + 26;
3193                                let inner_end =
3194                                    v_lower.get(inner_start..)?.find(')')? + inner_start;
3195                                let inner = v.get(inner_start..inner_end)?;
3196                                let parts: Vec<&str> = inner.split(',').collect();
3197                                let colors: Vec<u32> = parts
3198                                    .iter()
3199                                    .filter_map(|p| parse_color(p.trim().split(' ').next().unwrap_or("")))
3200                                    .collect();
3201                                if colors.len() < 2 {
3202                                    return None;
3203                                }
3204                                let cycle = extract_px_cycle(&parts).unwrap_or(20);
3205                                Some((colors, cycle))
3206                            })
3207                        };
3208
3209                        // `visibility: collapse` は本来テーブル行/列専用の特殊な折りたたみ
3210                        // レイアウト(行/列自体の高さ/幅を無くす)を持つが、この処理系はその
3211                        // 専用レイアウトを実装していない。以前は `collapse` が文字列として
3212                        // `"hidden"` と一致しないため何の効果も持たず(可視のまま描画される)、
3213                        // 完全に無視されていた。テーブル行/列以外の要素では仕様上も
3214                        // `collapse` は `hidden` と同じ扱いでよいとされているため、
3215                        // 専用レイアウト非対応を許容しつつ `hidden` と同一視する簡略実装とする
3216                        // (何もしないよりは大幅に改善、他の多くの簡略実装と同じ妥協水準)。
3217                        let visibility_hidden = styled
3218                            .value("visibility")
3219                            .map(|v| matches!(v.trim(), "hidden" | "collapse"))
3220                            .unwrap_or(false);
3221
3222                        // コンテナ背景・ボーダー要素の追加
3223                        let container_x = layout.dimensions.content.x
3224                            - layout.dimensions.padding.left
3225                            - layout.dimensions.border.left;
3226                        let container_y = layout.dimensions.content.y
3227                            - layout.dimensions.padding.top
3228                            - layout.dimensions.border.top;
3229                        let container_w = layout.dimensions.content.width
3230                            + layout.dimensions.padding.left
3231                            + layout.dimensions.padding.right
3232                            + layout.dimensions.border.left
3233                            + layout.dimensions.border.right;
3234                        let container_h = layout.dimensions.content.height
3235                            + layout.dimensions.padding.top
3236                            + layout.dimensions.padding.bottom
3237                            + layout.dimensions.border.top
3238                            + layout.dimensions.border.bottom;
3239
3240                        // `border-radius` の `%` 値を要素サイズに対する割合として
3241                        // 解決する(`border-radius: <h-radii> / <v-radii>` の楕円角
3242                        // 構文は非対応のため水平半径のみを円弧として使う従来どおりの
3243                        // 簡略実装。`%` は仕様上コーナーごとに対応する軸(水平半径は
3244                        // 幅、垂直半径は高さ)に対する割合だが、この実装は水平半径
3245                        // しか保持しないため常に `container_w` を基準にする)。
3246                        // `rem`対応は`resolve_border_radius`(モジュールトップレベル。
3247                        // 丸ごと未対応だった。2026-07-17)参照。
3248                        let parse_radius = |s: &str| -> i32 { resolve_border_radius(s, container_w) };
3249                        let mut border_radius = (0, 0, 0, 0);
3250                        if let Some(s) = &border_radius_shorthand {
3251                            let h_part = s.split('/').next().unwrap_or(s);
3252                            let parts: alloc::vec::Vec<&str> = h_part.split_whitespace().collect();
3253                            if parts.len() == 1 {
3254                                let v = parse_radius(parts[0]);
3255                                border_radius = (v, v, v, v);
3256                            } else if parts.len() == 2 {
3257                                let v1 = parse_radius(parts[0]);
3258                                let v2 = parse_radius(parts[1]);
3259                                border_radius = (v1, v2, v1, v2);
3260                            } else if parts.len() == 3 {
3261                                let v1 = parse_radius(parts[0]);
3262                                let v2 = parse_radius(parts[1]);
3263                                let v3 = parse_radius(parts[2]);
3264                                border_radius = (v1, v2, v3, v2);
3265                            } else if parts.len() >= 4 {
3266                                border_radius = (
3267                                    parse_radius(parts[0]),
3268                                    parse_radius(parts[1]),
3269                                    parse_radius(parts[2]),
3270                                    parse_radius(parts[3]),
3271                                );
3272                            }
3273                        }
3274                        if let Some(s) = &border_radius_tl { border_radius.0 = parse_radius(s); }
3275                        if let Some(s) = &border_radius_tr { border_radius.1 = parse_radius(s); }
3276                        if let Some(s) = &border_radius_br { border_radius.2 = parse_radius(s); }
3277                        if let Some(s) = &border_radius_bl { border_radius.3 = parse_radius(s); }
3278
3279                        let bg_color = extract_color(styled, "background-color", "background");
3280                        // 【2026-08-03診断】研究セクション(高さ 990 の要素)の
3281                        // 背景が `None` になる件。生の算出値を 1 回だけ出す。
3282                        if bg_color.is_none() && layout.dimensions.content.height > 800 {
3283                            static ONCE: core::sync::atomic::AtomicU32 =
3284                                core::sync::atomic::AtomicU32::new(0);
3285                            if ONCE.fetch_add(1, core::sync::atomic::Ordering::Relaxed) < 4 {
3286                                crate::warn!(
3287                                    "[BGDIAG] tag={} h={} background-color={:?} background={:?} class={:?}",
3288                                    tag_name,
3289                                    layout.dimensions.content.height,
3290                                    styled.value("background-color"),
3291                                    styled.value("background"),
3292                                    styled.value("x-class-debug")
3293                                );
3294                            }
3295                        }
3296                        let border_style = extract_border_style(styled);
3297                        let border_color = if !border_style.is_none() {
3298                            extract_border_color(styled, 0)
3299                        } else {
3300                            None
3301                        };
3302
3303                        let border_top_style = styled
3304                            .value("border-top-style")
3305                            .or_else(|| styled.value("border-style"))
3306                            .map(|s| super::BorderStyle::parse(&s))
3307                            .unwrap_or_default();
3308                        let border_right_style = styled
3309                            .value("border-right-style")
3310                            .or_else(|| styled.value("border-style"))
3311                            .map(|s| super::BorderStyle::parse(&s))
3312                            .unwrap_or_default();
3313                        let border_bottom_style = styled
3314                            .value("border-bottom-style")
3315                            .or_else(|| styled.value("border-style"))
3316                            .map(|s| super::BorderStyle::parse(&s))
3317                            .unwrap_or_default();
3318                        let border_left_style = styled
3319                            .value("border-left-style")
3320                            .or_else(|| styled.value("border-style"))
3321                            .map(|s| super::BorderStyle::parse(&s))
3322                            .unwrap_or_default();
3323
3324                        let border_top_color = styled
3325                            .value("border-top-color")
3326                            .or_else(|| styled.value("border-color"))
3327                            .and_then(|v| parse_color(&v));
3328                        let border_right_color = styled
3329                            .value("border-right-color")
3330                            .or_else(|| styled.value("border-color"))
3331                            .and_then(|v| parse_color(&v));
3332                        let border_bottom_color = styled
3333                            .value("border-bottom-color")
3334                            .or_else(|| styled.value("border-color"))
3335                            .and_then(|v| parse_color(&v));
3336                        let border_left_color = styled
3337                            .value("border-left-color")
3338                            .or_else(|| styled.value("border-color"))
3339                            .and_then(|v| parse_color(&v));
3340
3341                        let bg_image = styled
3342                            .value("background-image")
3343                            .or_else(|| styled.value("background"))
3344                            .and_then(|v| extract_url_path(&v));
3345                        let bg_images = styled
3346                            .value("background-image")
3347                            .or_else(|| styled.value("background"))
3348                            .map(|v| extract_all_url_paths(&v))
3349                            .unwrap_or_default();
3350                        // `background`ショートハンドの`<position>/<size>`分解は
3351                        // カスケード時点(`css::expand_background_shorthand`)で完了済みの
3352                        // ため、ここでは`background-position`/`-size`/`-repeat`の
3353                        // longhandをそのまま読むだけでよい。
3354                        let bg_size_raw = styled.value("background-size");
3355                        let bg_size = parse_bg_size(bg_size_raw.as_deref());
3356                        let bg_repeat_raw = styled.value("background-repeat");
3357                        let (bg_repeat_x, bg_repeat_y) = parse_bg_repeat(bg_repeat_raw.as_deref());
3358                        let bg_position_raw = styled.value("background-position");
3359                        let (bg_pos_x, bg_pos_y) = parse_bg_position(bg_position_raw.as_deref());
3360                        // 複数レイヤー(`background-image: url(a), url(b)`)向けの、レイヤーごとの
3361                        // size/repeat/position。以前は全レイヤーが `bg_size`/`bg_repeat_*`/
3362                        // `bg_pos_*`(先頭値のみ)を共有しており、`background-position: left top,
3363                        // right bottom` のようなレイヤーごとの指定が無視されるバグがあった。
3364                        // CSS仕様どおり、値の個数がレイヤー数より少なければ先頭から繰り返す。
3365                        let bg_layers: alloc::vec::Vec<(
3366                            super::BgSizeMode,
3367                            super::BgRepeatMode,
3368                            super::BgRepeatMode,
3369                            f32,
3370                            f32,
3371                        )> = if bg_images.len() > 1 {
3372                            let sizes: alloc::vec::Vec<&str> = bg_size_raw
3373                                .as_deref()
3374                                .map(|v| v.split(',').collect())
3375                                .unwrap_or_default();
3376                            let repeats: alloc::vec::Vec<&str> = bg_repeat_raw
3377                                .as_deref()
3378                                .map(|v| v.split(',').collect())
3379                                .unwrap_or_default();
3380                            let positions: alloc::vec::Vec<&str> = bg_position_raw
3381                                .as_deref()
3382                                .map(|v| v.split(',').collect())
3383                                .unwrap_or_default();
3384                            (0..bg_images.len())
3385                                .map(|i| {
3386                                    let size = if sizes.is_empty() {
3387                                        bg_size
3388                                    } else {
3389                                        parse_bg_size(Some(sizes[i % sizes.len()].trim()))
3390                                    };
3391                                    let (rx, ry) = if repeats.is_empty() {
3392                                        (bg_repeat_x, bg_repeat_y)
3393                                    } else {
3394                                        parse_bg_repeat(Some(repeats[i % repeats.len()].trim()))
3395                                    };
3396                                    let (px, py) = if positions.is_empty() {
3397                                        (bg_pos_x, bg_pos_y)
3398                                    } else {
3399                                        parse_bg_position(Some(positions[i % positions.len()].trim()))
3400                                    };
3401                                    (size, rx, ry, px, py)
3402                                })
3403                                .collect()
3404                        } else {
3405                            alloc::vec::Vec::new()
3406                        };
3407                        let background_blend_mode = styled
3408                            .value("background-blend-mode")
3409                            .unwrap_or_default();
3410                        // `background-clip`/`background-origin`: border-box/padding-box/content-box。
3411                        // `background-clip: text` は文字マスク合成が必要で非対応のため border-box にフォールバック。
3412                        fn normalize_bg_box(v: Option<alloc::string::String>) -> alloc::string::String {
3413                            match v.as_deref().map(str::trim) {
3414                                Some("padding-box") => alloc::string::String::from("padding-box"),
3415                                Some("content-box") => alloc::string::String::from("content-box"),
3416                                _ => alloc::string::String::from("border-box"),
3417                            }
3418                        }
3419                        let bg_clip = normalize_bg_box(
3420                            styled.value("background-clip")
3421                                .or_else(|| styled.value("-webkit-background-clip")),
3422                        );
3423                        // `background-origin` の既定値は border-box ではなく padding-box(CSS 仕様)。
3424                        let bg_origin = match styled.value("background-origin").as_deref().map(str::trim)
3425                        {
3426                            Some("border-box") => alloc::string::String::from("border-box"),
3427                            Some("content-box") => alloc::string::String::from("content-box"),
3428                            _ => alloc::string::String::from("padding-box"),
3429                        };
3430                        // `background-attachment: fixed` はスクロールしてもパターンが
3431                        // ビューポートに固定されて見える効果。`local` はネストしたスクロール
3432                        // コンテナ内での挙動差異があるが、この処理系では `scroll` と同一視する。
3433                        let bg_attachment = match styled.value("background-attachment").as_deref().map(str::trim)
3434                        {
3435                            Some("fixed") => alloc::string::String::from("fixed"),
3436                            _ => alloc::string::String::from("scroll"),
3437                        };
3438                        // CSS `cursor`: `text` のみ実際のカーソル形状へ反映する(他は非対応で
3439                        // 既定の矢印にフォールバックするが、値自体は保持する)。
3440                        let cursor_style = styled
3441                            .value("cursor")
3442                            .map(|v| String::from(v.trim()))
3443                            .unwrap_or_default();
3444                        let (outline_color, outline_width, outline_style) =
3445                            match parse_outline(styled) {
3446                                Some((c, w, s)) => (Some(c), w, s),
3447                                None => (None, 0, String::from("solid")),
3448                            };
3449                        let outline_offset = styled
3450                            .value("outline-offset")
3451                            .map(|v| parse_px_val(v.trim()))
3452                            .unwrap_or(0);
3453
3454                        // Parse text-shadow: `<Xpx> <Ypx> [<Blur>px] [<color>]` のカンマ区切り
3455                        // 複数シャドウに対応(box-shadow と同じ理由・同じ方式で最上位カンマ分割)。
3456                        // `rem`が丸ごと未対応だった同型のバグ(box-shadowと同じ理由。
3457                        // 詳細は`parse_shadow_len_tok`のdocコメント参照)。同関数を再利用。
3458                        fn parse_one_text_shadow(seg: &str) -> Option<(i32, i32, i32, u32)> {
3459                            let seg = seg.trim();
3460                            if seg.is_empty() {
3461                                return None;
3462                            }
3463                            let mut ox = 0i32;
3464                            let mut oy = 0i32;
3465                            let mut blur = 0i32;
3466                            let mut sh_color = 0x80808080u32;
3467                            let mut px_count = 0;
3468                            for token in seg.split_whitespace() {
3469                                if let Some(n) = parse_shadow_len_tok(token) {
3470                                    match px_count {
3471                                        0 => ox = n,
3472                                        1 => oy = n,
3473                                        2 => blur = n,
3474                                        _ => {}
3475                                    }
3476                                    px_count += 1;
3477                                    continue;
3478                                }
3479                                if let Some(c) = parse_color(token) {
3480                                    sh_color = c;
3481                                }
3482                            }
3483                            if px_count == 0 {
3484                                None
3485                            } else {
3486                                Some((ox, oy, blur, sh_color))
3487                            }
3488                        }
3489                        let text_shadow: alloc::vec::Vec<(i32, i32, i32, u32)> = styled
3490                            .value("text-shadow")
3491                            .map_or_else(alloc::vec::Vec::new, |v| {
3492                                let v = v.trim().to_lowercase();
3493                                if v == "none" {
3494                                    alloc::vec::Vec::new()
3495                                } else {
3496                                    v.split(',').filter_map(parse_one_text_shadow).collect()
3497                                }
3498                            });
3499
3500                        // ::before / ::after content。`display:none` が指定されていれば
3501                        // 仕様通りボックス自体を生成しない(`content:none` と同じ扱い)。
3502                        let before_content = if styled.value("x-before-display").as_deref() == Some("none") {
3503                            String::new()
3504                        } else {
3505                            styled.value("x-before-content").unwrap_or_default()
3506                        };
3507                        let after_content = if styled.value("x-after-display").as_deref() == Some("none") {
3508                            String::new()
3509                        } else {
3510                            styled.value("x-after-content").unwrap_or_default()
3511                        };
3512                        // FontAwesome等のアイコンフォントは、アイコン専用クラス(`.fas`/`.far`/
3513                        // `.fab`等)で要素自身に`font-family`を指定し、実際のグリフは同じ要素の
3514                        // `::before{content:"\fXXX"}`から出す、という実装が一般的(`::before`は
3515                        // 既定で親要素からfont-familyを継承するため、要素自身の指定で足りる)。
3516                        // `x-before-font-family`(`::before`セレクタへの直接指定、稀)があれば
3517                        // それを優先し、無ければ要素自身の`font-family`にフォールバックする。
3518                        // 要素自身の font-family。カンマ区切りのまま渡し、
3519                        // 描画側が先頭から順に登録済みフォントを探す。
3520                        let font_family = styled
3521                            .value("font-family")
3522                            .map(|v| String::from(v.trim()))
3523                            .unwrap_or_default();
3524                        let before_font_family = styled
3525                            .value("x-before-font-family")
3526                            .or_else(|| styled.value("font-family"))
3527                            .and_then(|v| v.split(',').next().map(|s| String::from(s.trim().trim_matches('"').trim_matches('\''))))
3528                            .unwrap_or_default();
3529
3530                        // empty-cells: hide — 空セル(子孫がテキストのみ、かつ空白のみ)の
3531                        // 背景/境界線を非表示にする。`border-collapse:collapse` では常に
3532                        // 境界線が表示される仕様だが、この簡略実装ではその区別は行わない
3533                        // (collapse 時の empty-cells 適用除外は非対応、実用上の影響は軽微)。
3534                        let is_empty_cell_hidden = (tag_name == "td" || tag_name == "th")
3535                            && styled
3536                                .value("empty-cells")
3537                                .map(|v| v.trim().eq_ignore_ascii_case("hide"))
3538                                .unwrap_or(false)
3539                            && styled.node.children.iter().all(|c| match &c.node_type {
3540                                crate::os_lib::dom::NodeType::Text(t) => t.trim().is_empty(),
3541                                _ => false,
3542                            });
3543
3544                        // CSS transform: translate / scale(X/Y) / rotate 簡易パース
3545                        let (
3546                            transform_tx,
3547                            transform_ty,
3548                            transform_scale_x,
3549                            transform_scale_y,
3550                            transform_rotate_deg,
3551                            transform_skew_x,
3552                            transform_skew_y,
3553                        ) = parse_transform(styled.value("transform").as_deref());
3554                        let (transform_origin_x, transform_origin_y) =
3555                            parse_transform_origin(styled.value("transform-origin").as_deref());
3556
3557                        if (bg_color.is_some()
3558                            || (border_color.is_some() && !border_style.is_none())
3559                            || tag_name == "blockquote"
3560                            || bg_image.is_some()
3561                            || linear_gradient.is_some()
3562                            || radial_gradient.is_some()
3563                            || conic_gradient.is_some()
3564                            || repeating_linear_gradient.is_some()
3565                            || repeating_radial_gradient.is_some()
3566                            || !box_shadow.is_empty())
3567                            && container_w > 0
3568                            && container_h > 0
3569                            && tag_name != "head"
3570                            && tag_name != "title"
3571                            && tag_name != "meta"
3572                            && tag_name != "link"
3573                            && tag_name != "style"
3574                            && tag_name != "script"
3575                            && !is_empty_cell_hidden
3576                        {
3577                            self.elements.push(RenderElement {
3578                                text: String::new(),
3579                                font_size: 0,
3580                                color: 0,
3581                                bg_color,
3582                                hover_bg_color: extract_hover_color_alt(
3583                                    styled,
3584                                    "background-color",
3585                                    "background",
3586                                ),
3587                                hover_color: None,
3588                                hover_border_color: extract_hover_color(styled, "border-color"),
3589                                filter: styled.value("filter").unwrap_or_default(),
3590                                mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
3591                                backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
3592                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
3593                                text_stroke_width: extract_text_stroke(styled).0,
3594                                text_stroke_color: extract_text_stroke(styled).1,
3595                                marker_color: extract_marker_color(styled),
3596                                marker_font_size: extract_marker_font_size(styled),
3597                                marker_content: extract_marker_content(styled),
3598                                focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
3599                                focus_color: None,
3600                                focus_border_color: extract_focus_color(styled, "border-color"),
3601                                active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
3602                                active_color: None,
3603                                active_border_color: extract_active_color(styled, "border-color"),
3604                                border_color,
3605                                border_style,
3606                                // 【2026-08-05 発見・修正】`false` の決め打ちだった。
3607                                //
3608                                // この要素の `::before` / `::after` の内容は
3609                                // **この要素自身**の書体で描かれる。にもかかわらず
3610                                // 太字・斜体を常に無効にしていたため、
3611                                // `.fas { font-weight: 900 }` のアイコンが
3612                                // regular(400) の書体で引かれ、字形が無くて
3613                                // 箱(☐)になっていた。
3614                                //
3615                                // 実測: `[FONTPICK] font awesome 7 free|bold=false|
3616                                // hit=Some("font awesome 7 free|400")`
3617                                font_bold: styled
3618                                    .value("font-weight")
3619                                    .map(|v| {
3620                                        let t = v.trim().to_lowercase();
3621                                        t == "bold"
3622                                            || t == "bolder"
3623                                            || t.parse::<u32>().unwrap_or(400) >= 700
3624                                    })
3625                                    .unwrap_or(false),
3626                                font_italic: styled
3627                                    .value("font-style")
3628                                    .map(|v| {
3629                                        let t = v.trim().to_lowercase();
3630                                        t == "italic" || t == "oblique"
3631                                    })
3632                                    .unwrap_or(false),
3633                                underline: false,
3634                                line_through: false,
3635                                is_video: false,
3636                                video_src: String::new(),
3637                                is_link: false,
3638                                href: String::new(),
3639                                is_hr: false,
3640                                is_img: false,
3641                                canvas_ctx_id,
3642                                img_alt: String::new(),
3643                                is_input: false,
3644                                is_textarea: false,
3645                                is_button: false,
3646                                button_icon: 0,
3647                                element_id: el_id.clone(),
3648                                scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
3649                                onclick: onclick.clone(),
3650                                form_action: String::new(),
3651                                form_method: String::new(),
3652                                width: container_w,
3653                                x_offset: container_x,
3654                                y_offset: container_y,
3655                                height: container_h,
3656                                border_radius,
3657                                is_li: tag_name == "li",
3658                                li_num: None,
3659                                is_blockquote: tag_name == "blockquote",
3660                                border_top_width: layout.dimensions.border.top,
3661                                border_right_width: layout.dimensions.border.right,
3662                                border_bottom_width: layout.dimensions.border.bottom,
3663                                border_left_width: layout.dimensions.border.left,
3664                                padding_top: layout.dimensions.padding.top,
3665                                padding_right: layout.dimensions.padding.right,
3666                                padding_bottom: layout.dimensions.padding.bottom,
3667                                padding_left: layout.dimensions.padding.left,
3668                                bg_clip,
3669                                bg_origin,
3670                                bg_attachment,
3671                                cursor_style,
3672                                border_top_color,
3673                                border_right_color,
3674                                border_bottom_color,
3675                                border_left_color,
3676                                border_top_style,
3677                                border_right_style,
3678                                border_bottom_style,
3679                                border_left_style,
3680                                bg_image,
3681                                bg_images,
3682                                bg_size,
3683                                bg_repeat_x,
3684                                bg_repeat_y,
3685                                bg_pos_x,
3686                                bg_pos_y,
3687                                background_blend_mode,
3688                                bg_layers,
3689                                outline_color,
3690                                outline_width,
3691                                outline_offset,
3692                                outline_style,
3693                                pos_top,
3694                                pos_left,
3695                                pos_right,
3696                                pos_bottom,
3697                                z_index: z_index_val,
3698                                paint_depth: self.inherited_paint_depth,
3699                                is_fixed: is_fixed_pos,
3700                                is_sticky: is_sticky_pos,
3701                                sticky_top: if is_sticky_pos { pos_top } else { None },
3702                                sticky_bottom: if is_sticky_pos { pos_bottom } else { None },
3703                                box_shadow,
3704                                linear_gradient,
3705                                radial_gradient,
3706                                conic_gradient,
3707                                repeating_linear_gradient,
3708                                repeating_radial_gradient,
3709                                opacity: opacity_val,
3710                                visibility_hidden,
3711                                text_shadow,
3712                                before_content,
3713                                after_content,
3714                                font_family,
3715                                before_font_family,
3716                                transform_tx,
3717                                transform_ty,
3718                                transform_scale_x,
3719                                transform_scale_y,
3720                                transform_rotate_deg,
3721                                transform_skew_x,
3722                                transform_skew_y,
3723                                transform_origin_x,
3724                                transform_origin_y,
3725                                // `mask-image` はフルサポート(url()による画像マスク等)は非対応だが、
3726                                // `circle()`/`ellipse()`/`polygon()` 等の図形関数構文は `clip-path` と
3727                                // 全く同じ `parse_clip_shape` を再利用してマスキングできる。`clip-path`
3728                                // が無指定のときのみ `mask-image` にフォールバックする(`clip-path` が
3729                                // 優先、両方指定されるケースは稀なため簡略化)。`mask-image:url(...)`
3730                                // のような非対応構文は `parse_clip_shape` がどのプレフィックスにも
3731                                // 一致せず `None` を返すため、単に無視されるだけで安全。
3732                                clip_path: styled
3733                                    .value("clip-path")
3734                                    .or_else(|| styled.value("mask-image"))
3735                                    .unwrap_or_default(),
3736                                ..Default::default()
3737                            });
3738                        }
3739
3740                        // 子要素をフラット化
3741                        // select は option 子要素を独自に収集済みなので通常フラット化をスキップ。
3742                        let skip_children = is_button || is_input || is_textarea || is_select;
3743                        if !skip_children {
3744                            let is_ul = tag_name == "ul";
3745                            let is_ol = tag_name == "ol";
3746                            let mut child_list_state = if is_ul {
3747                                Some((false, 0))
3748                            } else if is_ol {
3749                                Some((true, 0))
3750                            } else {
3751                                list_state
3752                            };
3753
3754                            // overflow クリップ: 子孫の生成インデックス範囲に、この要素の
3755                            // content box を clip 矩形として後付けする(入れ子は交差)。
3756                            let clip_rect_for_children: Option<(i32, i32, i32, i32)> =
3757                                if overflow_clips {
3758                                    let c = &layout.dimensions.content;
3759                                    Some((c.x, c.y, c.x + c.width, c.y + c.height))
3760                                } else {
3761                                    None
3762                                };
3763                            let clip_start_idx = self.elements.len();
3764
3765                            // <label> の関連付け先 id を算出(for 属性優先、無ければ内包 input)。
3766                            // この label のサブツリーで生成された要素に後付けし、テキストクリックで
3767                            // 該当 input をトグル/フォーカスできるようにする。
3768                            let label_target: Option<String> = if tag_name == "label" {
3769                                attributes
3770                                    .get("for")
3771                                    .cloned()
3772                                    .filter(|s| !s.is_empty())
3773                                    .or_else(|| find_labelable_descendant_id(layout))
3774                            } else {
3775                                None
3776                            };
3777
3778                            for child in &layout.children {
3779                                let is_child_li = match &child.box_type {
3780                                    crate::os_lib::layout::BoxType::BlockNode(styled_child)
3781                                    | crate::os_lib::layout::BoxType::InlineNode(styled_child)
3782                                    | crate::os_lib::layout::BoxType::InlineBlockNode(
3783                                        styled_child,
3784                                    ) => match &styled_child.node.node_type {
3785                                        crate::os_lib::dom::NodeType::Element {
3786                                            tag_name: child_tag,
3787                                            ..
3788                                        } => child_tag == "li",
3789                                        _ => false,
3790                                    },
3791                                    _ => false,
3792                                };
3793
3794                                if is_child_li {
3795                                    if let Some((is_ol, count)) = child_list_state {
3796                                        child_list_state = Some((is_ol, count + 1));
3797                                    }
3798                                }
3799
3800                                // 子孫は自分の実効 z を祖先値として引き継ぐ。
3801                                let saved_paint_z = self.inherited_paint_z;
3802                                let saved_paint_depth = self.inherited_paint_depth;
3803                                self.inherited_paint_z = z_index_val;
3804                                self.inherited_paint_depth =
3805                                    self.inherited_paint_depth.saturating_add(1);
3806                                self.flatten_layout(
3807                                    child,
3808                                    &next_form_action,
3809                                    &next_form_method,
3810                                    &next_href,
3811                                    next_font_bold,
3812                                    next_font_italic,
3813                                    next_underline,
3814                                    next_line_through,
3815                                    next_color,
3816                                    next_font_size,
3817                                    &next_parent_id,
3818                                    child_list_state,
3819                                    tag_name,
3820                                    next_decoration_color,
3821                                    next_decoration_style.clone(),
3822                                    next_decoration_thickness,
3823                                    next_decoration_underline_offset,
3824                                    next_overline,
3825                                    next_pointer_events_none,
3826                                );
3827                                self.inherited_paint_z = saved_paint_z;
3828                                self.inherited_paint_depth = saved_paint_depth;
3829                            }
3830
3831                            // 生成された子孫要素にクリップ矩形を適用(既存 clip と交差)。
3832                            // 軸ごとに独立して適用する: `overflow-x`/`overflow-y` の一方だけが
3833                            // clip 対象の場合、クリップされない軸の境界は据え置く(両軸まとめて
3834                            // 適用すると、クリップ不要な軸まで誤ってクリップされてしまうため)。
3835                            if let Some((cx0, cy0, cx1, cy1)) = clip_rect_for_children {
3836                                for i in clip_start_idx..self.elements.len() {
3837                                    let el = &mut self.elements[i];
3838                                    el.overflow_hidden = true;
3839                                    if x_clips {
3840                                        el.clip_x0 = el.clip_x0.max(cx0);
3841                                        el.clip_x1 = el.clip_x1.min(cx1);
3842                                    }
3843                                    if y_clips {
3844                                        el.clip_y0 = el.clip_y0.max(cy0);
3845                                        el.clip_y1 = el.clip_y1.min(cy1);
3846                                    }
3847                                }
3848                            }
3849
3850                            // overflow:scroll/auto → サブスクロールコンテナとして子孫に ID を付与。
3851                            // 内側に入れ子のスクロールコンテナが存在する子孫は上書きしない
3852                            // (既に内側コンテナの ID が設定済みのため)。
3853                            if is_scroll_container {
3854                                // element_id が空の場合は自動生成(位置ベースの仮 ID)。
3855                                let cid = if el_id.is_empty() {
3856                                    let c = &layout.dimensions.content;
3857                                    alloc::format!("_sc_{}_{}", c.x, c.y)
3858                                } else {
3859                                    el_id.clone()
3860                                };
3861                                // コンテナ高さを超えるコンテンツ量を scroll_max_y として算出。
3862                                let c = &layout.dimensions.content;
3863                                let container_bottom = c.y + c.height;
3864                                let content_bottom = self.elements[clip_start_idx..]
3865                                    .iter()
3866                                    .map(|e| e.y_offset + e.height)
3867                                    .max()
3868                                    .unwrap_or(container_bottom);
3869                                let scroll_max = (content_bottom - container_bottom).max(0);
3870                                // scroll_regions に初期エントリを作る(存在しなければ 0)。
3871                                self.scroll_regions.entry(cid.clone()).or_insert(0);
3872                                // スクロールバー描画・ホバー判定用のメタ情報を登録。
3873                                self.scroll_containers.push((
3874                                    cid.clone(),
3875                                    scroll_max,
3876                                    c.x,
3877                                    c.y,
3878                                    c.width,
3879                                    c.height,
3880                                ));
3881                                // 子孫に scroll_container_id を付与(内側コンテナ済みは除く)。
3882                                for i in clip_start_idx..self.elements.len() {
3883                                    let el = &mut self.elements[i];
3884                                    if el.scroll_container_id.is_empty() {
3885                                        el.scroll_container_id = cid.clone();
3886                                    }
3887                                }
3888                            }
3889
3890                            // <label> サブツリーで生成された要素に関連付け先を付与
3891                            // (内包 input 自体は除外: 自前のトグル動作を持つため)。
3892                            if let Some(target) = label_target {
3893                                for i in clip_start_idx..self.elements.len() {
3894                                    let el = &mut self.elements[i];
3895                                    if el.label_for.is_empty()
3896                                        && el.input_kind == 0
3897                                        && !el.is_input
3898                                        && !el.is_textarea
3899                                    {
3900                                        el.label_for = target.clone();
3901                                    }
3902                                }
3903                            }
3904                        }
3905
3906                        if is_hr {
3907                            self.elements.push(RenderElement {
3908                                text: String::new(),
3909                                font_size: 16,
3910                                color: 0xFF44475A,
3911                                bg_color: None,
3912                                hover_bg_color: extract_hover_color_alt(
3913                                    styled,
3914                                    "background-color",
3915                                    "background",
3916                                ),
3917                                hover_color: extract_hover_color(styled, "color"),
3918                                hover_border_color: extract_hover_color(styled, "border-color"),
3919                                filter: styled.value("filter").unwrap_or_default(),
3920                                mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
3921                                backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
3922                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
3923                                text_stroke_width: extract_text_stroke(styled).0,
3924                                text_stroke_color: extract_text_stroke(styled).1,
3925                                marker_color: extract_marker_color(styled),
3926                                marker_font_size: extract_marker_font_size(styled),
3927                                marker_content: extract_marker_content(styled),
3928                                focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
3929                                focus_color: extract_focus_color(styled, "color"),
3930                                focus_border_color: extract_focus_color(styled, "border-color"),
3931                                active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
3932                                active_color: extract_active_color(styled, "color"),
3933                                active_border_color: extract_active_color(styled, "border-color"),
3934                                border_color: None,
3935                                border_style: super::BorderStyle::Solid,
3936                                font_bold: false,
3937                                underline: false,
3938                                is_video: false,
3939                                video_src: String::new(),
3940                                is_link: false,
3941                                href: String::new(),
3942                                is_hr: true,
3943                                is_img: false,
3944                                canvas_ctx_id,
3945                                img_alt: String::new(),
3946                                is_input: false,
3947                                is_textarea: false,
3948                                is_button: false,
3949                                button_icon: 0,
3950                                element_id: String::new(),
3951                                onclick: String::new(),
3952                                form_action: String::new(),
3953                                form_method: String::new(),
3954                                width: layout.dimensions.content.width,
3955                                x_offset: layout.dimensions.content.x,
3956                                y_offset: layout.dimensions.content.y,
3957                                height: match aspect_ratio_val {
3958                                    Some(ratio) if layout.dimensions.content.height <= 0 => {
3959                                        (layout.dimensions.content.width as f32 / ratio) as i32
3960                                    }
3961                                    _ => layout.dimensions.content.height,
3962                                },
3963                                aspect_ratio: aspect_ratio_val,
3964                                border_radius: (0, 0, 0, 0),
3965                                is_li: false,
3966                                li_num: None,
3967                                is_blockquote: false,
3968                                z_index: self.inherited_paint_z,
3969                                paint_depth: self.inherited_paint_depth,
3970                                ..Default::default()
3971                            });
3972                        } else if is_img {
3973                            let alt = attributes
3974                                .get("alt")
3975                                .cloned()
3976                                .unwrap_or(String::from("Image"));
3977                            let src = attributes.get("src").cloned().unwrap_or(String::new());
3978
3979                            self.elements.push(RenderElement {
3980                                text: String::new(),
3981                                font_size: 16,
3982                                color: 0xFFF8F8F2,
3983                                bg_color: None,
3984                                hover_bg_color: extract_hover_color_alt(
3985                                    styled,
3986                                    "background-color",
3987                                    "background",
3988                                ),
3989                                hover_color: extract_hover_color(styled, "color"),
3990                                hover_border_color: extract_hover_color(styled, "border-color"),
3991                                filter: styled.value("filter").unwrap_or_default(),
3992                                mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
3993                                backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
3994                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
3995                                text_stroke_width: extract_text_stroke(styled).0,
3996                                text_stroke_color: extract_text_stroke(styled).1,
3997                                marker_color: extract_marker_color(styled),
3998                                marker_font_size: extract_marker_font_size(styled),
3999                                marker_content: extract_marker_content(styled),
4000                                focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
4001                                focus_color: extract_focus_color(styled, "color"),
4002                                focus_border_color: extract_focus_color(styled, "border-color"),
4003                                active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
4004                                active_color: extract_active_color(styled, "color"),
4005                                active_border_color: extract_active_color(styled, "border-color"),
4006                                border_color: extract_border_color(styled, 0xFF44475A),
4007                                border_style: extract_border_style(styled),
4008                                font_bold: false,
4009                                underline: false,
4010                                is_video: false,
4011                                video_src: src,
4012                                is_link: false,
4013                                href: String::new(),
4014                                is_hr: false,
4015                                is_img: true,
4016                                canvas_ctx_id,
4017                                img_alt: alt,
4018                                is_input: false,
4019                                is_textarea: false,
4020                                is_button: false,
4021                                button_icon: 0,
4022                                element_id: el_id,
4023                                scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
4024                                onclick,
4025                                form_action: String::new(),
4026                                form_method: String::new(),
4027                                width: layout.dimensions.content.width,
4028                                x_offset: layout.dimensions.content.x,
4029                                y_offset: layout.dimensions.content.y,
4030                                height: layout.dimensions.content.height,
4031                                border_radius,
4032                                is_li: false,
4033                                li_num: None,
4034                                is_blockquote: false,
4035                                // `mask-image` はフルサポート(url()による画像マスク等)は非対応だが、
4036                                // `circle()`/`ellipse()`/`polygon()` 等の図形関数構文は `clip-path` と
4037                                // 全く同じ `parse_clip_shape` を再利用してマスキングできる。`clip-path`
4038                                // が無指定のときのみ `mask-image` にフォールバックする(`clip-path` が
4039                                // 優先、両方指定されるケースは稀なため簡略化)。`mask-image:url(...)`
4040                                // のような非対応構文は `parse_clip_shape` がどのプレフィックスにも
4041                                // 一致せず `None` を返すため、単に無視されるだけで安全。
4042                                clip_path: styled
4043                                    .value("clip-path")
4044                                    .or_else(|| styled.value("mask-image"))
4045                                    .unwrap_or_default(),
4046                                object_fit: parse_object_fit(styled.value("object-fit").as_deref()),
4047                                object_pos_x: parse_object_position(
4048                                    styled.value("object-position").as_deref(),
4049                                )
4050                                .0,
4051                                object_pos_y: parse_object_position(
4052                                    styled.value("object-position").as_deref(),
4053                                )
4054                                .1,
4055                                z_index: self.inherited_paint_z,
4056                                paint_depth: self.inherited_paint_depth,
4057                                ..Default::default()
4058                            });
4059                        } else if is_video {
4060                            let src = attributes.get("src").cloned().unwrap_or(String::new());
4061                            self.elements.push(RenderElement {
4062                                text: String::new(),
4063                                font_size: 16,
4064                                color: 0xFFF8F8F2,
4065                                bg_color: None,
4066                                hover_bg_color: extract_hover_color_alt(
4067                                    styled,
4068                                    "background-color",
4069                                    "background",
4070                                ),
4071                                hover_color: extract_hover_color(styled, "color"),
4072                                hover_border_color: extract_hover_color(styled, "border-color"),
4073                                filter: styled.value("filter").unwrap_or_default(),
4074                                mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
4075                                backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
4076                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
4077                                text_stroke_width: extract_text_stroke(styled).0,
4078                                text_stroke_color: extract_text_stroke(styled).1,
4079                                marker_color: extract_marker_color(styled),
4080                                marker_font_size: extract_marker_font_size(styled),
4081                                marker_content: extract_marker_content(styled),
4082                                focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
4083                                focus_color: extract_focus_color(styled, "color"),
4084                                focus_border_color: extract_focus_color(styled, "border-color"),
4085                                active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
4086                                active_color: extract_active_color(styled, "color"),
4087                                active_border_color: extract_active_color(styled, "border-color"),
4088                                border_color: extract_border_color(styled, 0xFF6272A4),
4089                                border_style: extract_border_style(styled),
4090                                font_bold: false,
4091                                underline: false,
4092                                is_video: true,
4093                                video_src: src,
4094                                is_link: false,
4095                                href: String::new(),
4096                                is_hr: false,
4097                                is_img: false,
4098                                canvas_ctx_id,
4099                                img_alt: String::new(),
4100                                is_input: false,
4101                                is_textarea: false,
4102                                is_button: false,
4103                                button_icon: 0,
4104                                element_id: el_id,
4105                                scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
4106                                onclick,
4107                                form_action: String::new(),
4108                                form_method: String::new(),
4109                                width: layout.dimensions.content.width,
4110                                x_offset: layout.dimensions.content.x,
4111                                y_offset: layout.dimensions.content.y,
4112                                height: layout.dimensions.content.height,
4113                                border_radius,
4114                                is_li: false,
4115                                li_num: None,
4116                                is_blockquote: false,
4117                                z_index: self.inherited_paint_z,
4118                                paint_depth: self.inherited_paint_depth,
4119                                ..Default::default()
4120                            });
4121                        } else if is_progress {
4122                            // input_kind=4 として checkbox/radio/select と同じ仕組みに便乗し、
4123                            // 比率(0.0..1.0)の文字列表現を input_value に格納する
4124                            // (RenderElement に新規フィールドを増やさない簡略実装)。
4125                            self.elements.push(RenderElement {
4126                                text: String::new(),
4127                                font_size: 16,
4128                                color: 0xFFF8F8F2,
4129                                bg_color: None,
4130                                hover_bg_color: extract_hover_color_alt(
4131                                    styled,
4132                                    "background-color",
4133                                    "background",
4134                                ),
4135                                hover_color: extract_hover_color(styled, "color"),
4136                                hover_border_color: extract_hover_color(styled, "border-color"),
4137                                filter: styled.value("filter").unwrap_or_default(),
4138                                mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
4139                                backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
4140                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
4141                                text_stroke_width: extract_text_stroke(styled).0,
4142                                text_stroke_color: extract_text_stroke(styled).1,
4143                                border_color: extract_border_color(styled, 0xFF6272A4),
4144                                border_style: extract_border_style(styled),
4145                                is_video: false,
4146                                video_src: String::new(),
4147                                is_link: false,
4148                                href: String::new(),
4149                                is_hr: false,
4150                                is_img: false,
4151                                canvas_ctx_id,
4152                                img_alt: String::new(),
4153                                is_input: false,
4154                                is_textarea: false,
4155                                is_button: false,
4156                                input_kind: 4,
4157                                input_value: alloc::format!("{}", progress_ratio),
4158                                element_id: el_id,
4159                                onclick,
4160                                width: layout.dimensions.content.width,
4161                                x_offset: layout.dimensions.content.x,
4162                                y_offset: layout.dimensions.content.y,
4163                                height: layout.dimensions.content.height,
4164                                border_radius,
4165                                z_index: self.inherited_paint_z,
4166                                paint_depth: self.inherited_paint_depth,
4167                                ..Default::default()
4168                            });
4169                        } else if is_input || is_textarea || is_select {
4170                            // --- checkbox / radio / select の状態を form_values に初期化 ---
4171                            let mut radio_group = String::new();
4172                            let mut input_value = String::new();
4173                            let mut select_options: alloc::vec::Vec<(String, String)> =
4174                                alloc::vec::Vec::new();
4175
4176                            if input_kind == 1 || input_kind == 2 {
4177                                // checkbox / radio: value 属性(既定 "on")と checked 初期状態。
4178                                input_value = attributes
4179                                    .get("value")
4180                                    .cloned()
4181                                    .unwrap_or_else(|| String::from("on"));
4182                                if input_kind == 2 {
4183                                    radio_group =
4184                                        attributes.get("name").cloned().unwrap_or_default();
4185                                }
4186                                let checked = attributes.contains_key("checked");
4187                                let init_v = if checked {
4188                                    input_value.clone()
4189                                } else {
4190                                    String::new()
4191                                };
4192                                if !el_id.is_empty() {
4193                                    self.initial_form_values
4194                                        .insert(el_id.clone(), init_v.clone());
4195                                    if !self.form_values.contains_key(&el_id) {
4196                                        // checked のときのみ value を、未チェックは空文字を格納。
4197                                        self.form_values.insert(el_id.clone(), init_v);
4198                                    }
4199                                }
4200                            } else if input_kind == 3 {
4201                                // select: option 一覧を収集し、選択中の value を form_values に格納。
4202                                let (opts, sel_idx) = collect_select_options(layout);
4203                                select_options = opts;
4204                                let init_v = select_options
4205                                    .get(sel_idx)
4206                                    .map(|(val, _)| val.clone())
4207                                    .unwrap_or_default();
4208                                if !el_id.is_empty() {
4209                                    self.initial_form_values
4210                                        .insert(el_id.clone(), init_v.clone());
4211                                    if !self.form_values.contains_key(&el_id) {
4212                                        self.form_values.insert(el_id.clone(), init_v);
4213                                    }
4214                                }
4215                            } else if input_kind == 5 {
4216                                // <input type="range">: min/max/step を select_options に
4217                                // (name, value) ペアとして格納する(RenderElement に新規
4218                                // フィールドを増やさず、select 用の Vec<(String,String)> を
4219                                // 流用する簡略実装。クリックで値を設定するのみでドラッグは非対応)。
4220                                let min_s =
4221                                    attributes.get("min").cloned().unwrap_or_else(|| String::from("0"));
4222                                let max_s = attributes
4223                                    .get("max")
4224                                    .cloned()
4225                                    .unwrap_or_else(|| String::from("100"));
4226                                let step_s = attributes
4227                                    .get("step")
4228                                    .cloned()
4229                                    .unwrap_or_else(|| String::from("1"));
4230                                let min_f: f32 = min_s.parse().unwrap_or(0.0);
4231                                let max_f: f32 = max_s.parse().unwrap_or(100.0);
4232                                select_options = alloc::vec![
4233                                    (String::from("min"), min_s),
4234                                    (String::from("max"), max_s),
4235                                    (String::from("step"), step_s),
4236                                ];
4237                                let default_v = alloc::format!("{}", (min_f + max_f) / 2.0);
4238                                let init_v = attributes.get("value").cloned().unwrap_or(default_v);
4239                                if !el_id.is_empty() {
4240                                    self.initial_form_values
4241                                        .insert(el_id.clone(), init_v.clone());
4242                                    if !self.form_values.contains_key(&el_id) {
4243                                        self.form_values.insert(el_id.clone(), init_v);
4244                                    }
4245                                }
4246                            } else {
4247                                // text / textarea: value 属性(textarea は子テキスト)を初期値に。
4248                                let mut val =
4249                                    attributes.get("value").cloned().unwrap_or(String::new());
4250                                if val.is_empty() && is_textarea {
4251                                    val = get_text_content(layout);
4252                                }
4253                                if !el_id.is_empty() {
4254                                    self.initial_form_values.insert(el_id.clone(), val.clone());
4255                                    if !self.form_values.contains_key(&el_id) {
4256                                        self.form_values.insert(el_id.clone(), val);
4257                                    }
4258                                }
4259                                // autofocus 属性: ページロード時点でこの入力欄へフォーカス。
4260                                // (まだどこにもフォーカスが無い場合のみ。DDG 検索ボックス等で有効)
4261                                if !el_id.is_empty()
4262                                    && self.focused_id.is_none()
4263                                    && attributes.contains_key("autofocus")
4264                                {
4265                                    self.focused_id = Some(el_id.clone());
4266                                    // `document.activeElement`(HTML5。丸ごと未対応
4267                                    // だった)が実際のフォーカス状態を反映できるよう、
4268                                    // JS 側の `DomBridge::focused_idx` にも同期する。
4269                                    let didx = self.js_runtime.dom.borrow().get_element_by_id(&el_id);
4270                                    self.js_runtime.dom.borrow_mut().focused_idx = didx;
4271                                }
4272                            }
4273
4274                            self.elements.push(RenderElement {
4275                                text: String::new(),
4276                                font_size: 16,
4277                                color: 0xFFF8F8F2,
4278                                bg_color: None,
4279                                hover_bg_color: extract_hover_color_alt(
4280                                    styled,
4281                                    "background-color",
4282                                    "background",
4283                                ),
4284                                hover_color: extract_hover_color(styled, "color"),
4285                                hover_border_color: extract_hover_color(styled, "border-color"),
4286                                filter: styled.value("filter").unwrap_or_default(),
4287                                mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
4288                                backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
4289                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
4290                                text_stroke_width: extract_text_stroke(styled).0,
4291                                text_stroke_color: extract_text_stroke(styled).1,
4292                                marker_color: extract_marker_color(styled),
4293                                marker_font_size: extract_marker_font_size(styled),
4294                                marker_content: extract_marker_content(styled),
4295                                focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
4296                                focus_color: extract_focus_color(styled, "color"),
4297                                focus_border_color: extract_focus_color(styled, "border-color"),
4298                                active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
4299                                active_color: extract_active_color(styled, "color"),
4300                                active_border_color: extract_active_color(styled, "border-color"),
4301                                border_color: extract_border_color(styled, 0xFF6272A4),
4302                                border_style: extract_border_style(styled),
4303                                font_bold: false,
4304                                underline: false,
4305                                is_video: false,
4306                                video_src: String::new(),
4307                                is_link: false,
4308                                href: String::new(),
4309                                is_hr: false,
4310                                is_img: false,
4311                                canvas_ctx_id,
4312                                img_alt: String::new(),
4313                                // checkbox/radio/select はテキスト編集対象外なので is_input=false。
4314                                is_input: is_input && input_kind == 0,
4315                                is_textarea,
4316                                caret_color: styled.value("caret-color").and_then(|v| parse_color(&v)),
4317                                accent_color: styled.value("accent-color").and_then(|v| parse_color(&v)),
4318                                placeholder_color: extract_placeholder_color(styled),
4319                                placeholder: attributes.get("placeholder").cloned().unwrap_or_default(),
4320                                is_button: false,
4321                                input_kind,
4322                                radio_group,
4323                                input_value,
4324                                select_options,
4325                                button_icon: 0,
4326                                element_id: el_id,
4327                                scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
4328                                onclick,
4329                                form_action: String::new(),
4330                                form_method: String::new(),
4331                                width: layout.dimensions.content.width,
4332                                x_offset: layout.dimensions.content.x,
4333                                y_offset: layout.dimensions.content.y,
4334                                height: layout.dimensions.content.height,
4335                                border_radius,
4336                                is_li: false,
4337                                li_num: None,
4338                                is_blockquote: false,
4339                                z_index: self.inherited_paint_z,
4340                                paint_depth: self.inherited_paint_depth,
4341                                ..Default::default()
4342                            });
4343                        } else if is_button {
4344                            let mut text = get_text_content(layout);
4345                            let class_val =
4346                                attributes.get("class").cloned().unwrap_or(String::new());
4347                            let mut icon_id = 0;
4348                            if class_val == "save_btn" {
4349                                icon_id = 1;
4350                            } else if class_val == "load_btn" {
4351                                icon_id = 2;
4352                            } else if class_val == "wrap_btn" {
4353                                icon_id = 3;
4354                            }
4355
4356                            if el_id == "wrap_status" {
4357                                text = if self.editor_wrap {
4358                                    String::from("Wrap: ON")
4359                                } else {
4360                                    String::from("Wrap: OFF")
4361                                };
4362                            } else if text.is_empty() {
4363                                text = attributes
4364                                    .get("value")
4365                                    .cloned()
4366                                    .unwrap_or(String::from("Button"));
4367                            }
4368                            self.elements.push(RenderElement {
4369                                text,
4370                                font_size: 16,
4371                                color: 0xFFF8F8F2,
4372                                bg_color: None,
4373                                hover_bg_color: extract_hover_color_alt(
4374                                    styled,
4375                                    "background-color",
4376                                    "background",
4377                                ),
4378                                hover_color: extract_hover_color(styled, "color"),
4379                                hover_border_color: extract_hover_color(styled, "border-color"),
4380                                filter: styled.value("filter").unwrap_or_default(),
4381                                mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
4382                                backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
4383                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
4384                                text_stroke_width: extract_text_stroke(styled).0,
4385                                text_stroke_color: extract_text_stroke(styled).1,
4386                                marker_color: extract_marker_color(styled),
4387                                marker_font_size: extract_marker_font_size(styled),
4388                                marker_content: extract_marker_content(styled),
4389                                focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
4390                                focus_color: extract_focus_color(styled, "color"),
4391                                focus_border_color: extract_focus_color(styled, "border-color"),
4392                                active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
4393                                active_color: extract_active_color(styled, "color"),
4394                                active_border_color: extract_active_color(styled, "border-color"),
4395                                border_color: extract_border_color(styled, 0xFF6272A4),
4396                                border_style: extract_border_style(styled),
4397                                font_bold: false,
4398                                underline: false,
4399                                is_video: false,
4400                                video_src: String::new(),
4401                                is_link: false,
4402                                href: String::new(),
4403                                is_hr: false,
4404                                is_img: false,
4405                                canvas_ctx_id,
4406                                img_alt: String::new(),
4407                                is_input: false,
4408                                is_textarea: false,
4409                                is_button: true,
4410                                is_reset: is_reset_btn,
4411                                button_icon: icon_id,
4412                                element_id: el_id,
4413                                scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
4414                                onclick,
4415                                form_action: current_form_action.into(),
4416                                form_method: current_form_method.into(),
4417                                width: layout.dimensions.content.width,
4418                                x_offset: layout.dimensions.content.x,
4419                                y_offset: layout.dimensions.content.y,
4420                                height: layout.dimensions.content.height,
4421                                border_radius,
4422                                is_li: false,
4423                                li_num: None,
4424                                is_blockquote: false,
4425                                z_index: self.inherited_paint_z,
4426                                paint_depth: self.inherited_paint_depth,
4427                                ..Default::default()
4428                            });
4429                        }
4430                    }
4431                    crate::os_lib::dom::NodeType::Text(text) => {
4432                        let mut display_text = text.clone();
4433                        if !parent_id.is_empty() {
4434                            if let Some(updated_text) = self.dom_contents.get(parent_id) {
4435                                display_text = updated_text.clone();
4436                            }
4437                        }
4438                        // text-transform(uppercase / lowercase / capitalize)。
4439                        if let Some(tt) = styled.specified_values.get("text-transform") {
4440                            display_text =
4441                                crate::os_lib::css::apply_text_transform(&display_text, tt);
4442                        }
4443                        // hyphens: ソフトハイフン(U+00AD, `&shy;`)は、実際に行分割される箇所でだけ
4444                        // "-" として見せるのが仕様(既定値 manual)だが、この処理系は
4445                        // 折り返し位置を決めるアルゴリズムにハイフン挿入判定を組み込んでいないため、
4446                        // 常に非表示にする簡略実装(`hyphens:none` と同じ扱い)。誤った箇所に
4447                        // ハイフンが出てしまうより、常に非表示の方が実害が小さいとの判断。
4448                        if display_text.contains('\u{00AD}') {
4449                            display_text = display_text.replace('\u{00AD}', "");
4450                        }
4451                        // tab-size: タブ文字(U+0009)を N 個の半角スペースへ展開する
4452                        // (実ブラウザのようなタブ位置ストップ計算はせず、単純な固定幅展開)。
4453                        if display_text.contains('\t') {
4454                            let tab_size = styled
4455                                .value("tab-size")
4456                                .and_then(|v| v.trim().parse::<usize>().ok())
4457                                .filter(|n| *n > 0)
4458                                .unwrap_or(8)
4459                                .min(64);
4460                            display_text = display_text.replace('\t', &" ".repeat(tab_size));
4461                        }
4462                        let is_link = !current_href.is_empty();
4463                        let (default_text_color, default_link_color) = self.default_text_colors();
4464                        let default_color = if is_link {
4465                            default_link_color
4466                        } else {
4467                            default_text_color
4468                        };
4469                        let mut final_color = current_color.unwrap_or(default_color);
4470                        if is_link {
4471                            final_color = default_link_color;
4472                        }
4473                        let mut final_bg = None;
4474                        // 【2026-08-03】テキスト要素へ `font-family` を渡す。
4475                        //
4476                        // `RenderElement` の生成箇所は 12 あるが、
4477                        // `font_family` を設定していたのは 2 箇所だけで、
4478                        // **本文テキストの要素は空のまま**だった。
4479                        // そのため Web フォントを取得・登録できても
4480                        // 描画で選ばれなかった(実測: 使われたのは
4481                        // アイコンフォントだけ)。
4482                        //
4483                        // `font-family` は継承プロパティなので、カスケード済みの
4484                        // `styled` から引けば子要素でも解決済みの値が得られる。
4485                        let final_font_family = styled
4486                            .value("font-family")
4487                            .map(|v| String::from(v.trim()))
4488                            .unwrap_or_default();
4489                        let mut final_size = current_font_size;
4490                        let mut final_bold = current_font_bold;
4491                        let mut final_italic = current_font_italic;
4492                        let mut final_underline = current_underline;
4493                        let mut final_line_through = current_line_through;
4494                        let mut final_overline = current_overline;
4495                        let mut final_decoration_color = current_decoration_color;
4496                        let mut final_decoration_style = current_decoration_style.clone();
4497                        let mut final_decoration_thickness = current_decoration_thickness;
4498                        let mut final_decoration_underline_offset = current_decoration_underline_offset;
4499                        if let Some(c_str) = styled.specified_values.get("color") {
4500                            if let Some(c_val) = parse_color(c_str) {
4501                                final_color = c_val;
4502                            }
4503                        }
4504                        if let Some(bg_val) =
4505                            extract_color(styled, "background-color", "background")
4506                        {
4507                            final_bg = Some(bg_val);
4508                        }
4509                        if let Some(sz_str) = styled.specified_values.get("font-size") {
4510                            if let Some(sz_val) = parse_font_size(sz_str) {
4511                                final_size = sz_val;
4512                            }
4513                        }
4514                        if let Some(fw_str) = styled.specified_values.get("font-weight") {
4515                            let fw_l = fw_str.trim().to_lowercase();
4516                            if fw_l == "bold"
4517                                || fw_l == "bolder"
4518                                || fw_l.parse::<u32>().unwrap_or(400) >= 700
4519                            {
4520                                final_bold = true;
4521                            }
4522                        }
4523                        // letter-spacing/word-spacing の calc() 解決用の基準幅(% 解決の基準。
4524                        // letter-spacing/word-spacing は通常 % を取らないため主に
4525                        // `calc(1px + 1px)` 等の用途)。この時点ではまだ available_w が
4526                        // 未計算のため、同じフォールバック規則をここでも使う。
4527                        let ls_ws_base_w = if layout.dimensions.content.width > 0 {
4528                            layout.dimensions.content.width
4529                        } else {
4530                            800
4531                        };
4532                        // letter-spacing(文字間 px、負も可)。normal/未指定/不正は 0。
4533                        // calc()/min()/max()/clamp() にも対応(os_lib::layout の段落高さ計測側
4534                        // と同期させる必要があるため同じ規則を使う)。
4535                        let final_ls = match styled.value("letter-spacing") {
4536                            None => 0i32,
4537                            Some(s) => {
4538                                let t = s.trim();
4539                                if t.is_empty() || t.eq_ignore_ascii_case("normal") {
4540                                    0
4541                                } else if t.starts_with("calc(")
4542                                    || t.starts_with("min(")
4543                                    || t.starts_with("max(")
4544                                    || t.starts_with("clamp(")
4545                                {
4546                                    crate::os_lib::css::eval_css_math(t, ls_ws_base_w).unwrap_or(0)
4547                                } else if let Some(num) = t.strip_suffix("px") {
4548                                    num.trim()
4549                                        .parse::<f32>()
4550                                        .ok()
4551                                        .map(|f| f as i32)
4552                                        .unwrap_or(0)
4553                                } else {
4554                                    t.parse::<f32>().ok().map(|f| f as i32).unwrap_or(0)
4555                                }
4556                            }
4557                        };
4558                        // word-spacing(半角スペース1文字ごとに加える px、負も可)。normal/未指定/不正は 0。
4559                        let final_ws = match styled.value("word-spacing") {
4560                            None => 0i32,
4561                            Some(s) => {
4562                                let t = s.trim();
4563                                if t.is_empty() || t.eq_ignore_ascii_case("normal") {
4564                                    0
4565                                } else if t.starts_with("calc(")
4566                                    || t.starts_with("min(")
4567                                    || t.starts_with("max(")
4568                                    || t.starts_with("clamp(")
4569                                {
4570                                    crate::os_lib::css::eval_css_math(t, ls_ws_base_w).unwrap_or(0)
4571                                } else if let Some(num) = t.strip_suffix("px") {
4572                                    num.trim()
4573                                        .parse::<f32>()
4574                                        .ok()
4575                                        .map(|f| f as i32)
4576                                        .unwrap_or(0)
4577                                } else {
4578                                    t.parse::<f32>().ok().map(|f| f as i32).unwrap_or(0)
4579                                }
4580                            }
4581                        };
4582                        // text-emphasis-style: "none"/未指定/空は無し。クォート付きカスタム
4583                        // マークはクォートを剥がして1文字目だけを使う簡略実装。
4584                        let final_emphasis_style = match styled.value("text-emphasis-style") {
4585                            None => String::new(),
4586                            Some(s) => {
4587                                let t = s.trim();
4588                                if t.is_empty() || t.eq_ignore_ascii_case("none") {
4589                                    String::new()
4590                                } else {
4591                                    String::from(t.trim_matches(|c| c == '"' || c == '\''))
4592                                }
4593                            }
4594                        };
4595                        let final_emphasis_color = styled
4596                            .value("text-emphasis-color")
4597                            .and_then(|s| crate::os_lib::css::parse_color(&s));
4598                        if let Some(fs_str) = styled.specified_values.get("font-style") {
4599                            let fs_l = fs_str.trim().to_lowercase();
4600                            if fs_l == "italic" || fs_l == "oblique" {
4601                                final_italic = true;
4602                            } else if fs_l == "normal" {
4603                                final_italic = false;
4604                            }
4605                        }
4606                        if let Some(td_str) = styled.specified_values.get("text-decoration") {
4607                            let (u, lt, ov, is_none, style, color) =
4608                                parse_text_decoration_shorthand(td_str);
4609                            if is_none {
4610                                final_underline = false;
4611                                final_line_through = false;
4612                                final_overline = false;
4613                            } else {
4614                                if u {
4615                                    final_underline = true;
4616                                }
4617                                if lt {
4618                                    final_line_through = true;
4619                                }
4620                                if ov {
4621                                    final_overline = true;
4622                                }
4623                                if let Some(s) = style {
4624                                    final_decoration_style = s;
4625                                }
4626                                if let Some(c) = color {
4627                                    final_decoration_color = Some(c);
4628                                }
4629                            }
4630                        }
4631                        if let Some(tdl) = styled.specified_values.get("text-decoration-line") {
4632                            let (u, lt, ov, is_none, _, _) = parse_text_decoration_shorthand(tdl);
4633                            if is_none {
4634                                final_underline = false;
4635                                final_line_through = false;
4636                                final_overline = false;
4637                            } else {
4638                                if u {
4639                                    final_underline = true;
4640                                }
4641                                if lt {
4642                                    final_line_through = true;
4643                                }
4644                                if ov {
4645                                    final_overline = true;
4646                                }
4647                            }
4648                        }
4649                        if let Some(dc) = styled.specified_values.get("text-decoration-color") {
4650                            if let Some(c) = parse_color(dc.trim()) {
4651                                final_decoration_color = Some(c);
4652                            }
4653                        }
4654                        if let Some(ds) = styled.specified_values.get("text-decoration-style") {
4655                            let ds_l = ds.trim().to_lowercase();
4656                            if matches!(ds_l.as_str(), "solid" | "dotted" | "dashed" | "wavy" | "double") {
4657                                final_decoration_style = ds_l;
4658                            }
4659                        }
4660                        if let Some(dt) = styled.specified_values.get("text-decoration-thickness") {
4661                            let dt_t = dt.trim();
4662                            if !dt_t.eq_ignore_ascii_case("auto") && !dt_t.eq_ignore_ascii_case("from-font") {
4663                                final_decoration_thickness = parse_px_val(dt_t).max(1);
4664                            }
4665                        }
4666                        if let Some(uo) = styled.specified_values.get("text-underline-offset") {
4667                            let uo_t = uo.trim();
4668                            if !uo_t.eq_ignore_ascii_case("auto") {
4669                                final_decoration_underline_offset = parse_px_val(uo_t);
4670                            }
4671                        }
4672
4673                        let is_actual_link = is_link;
4674                        let link_href = String::from(current_href);
4675
4676                        // text-indent: このテキストノードの最初の行だけを右へずらす簡易実装。
4677                        // 折返し幅(available_w)自体は変更しないため、vector_font 側の行数
4678                        // 計算との同期は崩れない(インデント分だけ最初の行の右端が浅く狭まる
4679                        // 近似になるが、実用上の見た目への影響は小さい)。`%`/`em`が丸ごと
4680                        // 未対応だったバグの詳細は`resolve_text_indent`のdocコメント参照。
4681                        let text_indent_px: i32 = styled
4682                            .value("text-indent")
4683                            .map(|s| resolve_text_indent(&s, layout.dimensions.content.width, final_size))
4684                            .unwrap_or(0);
4685
4686                        // ::first-line: このテキストノードの最初の行(line_index==0)にのみ
4687                        // color/font-size/font-weight/font-style を上書きする簡易実装。
4688                        // RenderElement は既に行ごとに分割されているため、::before/::after と
4689                        // 同じ合成プロパティ経由の値を line_index==0 の時だけ適用すればよい。
4690                        let fl_color = styled
4691                            .value("x-first-line-color")
4692                            .and_then(|s| parse_color(&s));
4693                        let fl_size = styled
4694                            .value("x-first-line-font-size")
4695                            .and_then(|s| parse_font_size(&s));
4696                        let fl_bold = styled.value("x-first-line-font-weight").map(|s| {
4697                            let l = s.trim().to_lowercase();
4698                            l == "bold" || l == "bolder" || l.parse::<u32>().unwrap_or(400) >= 700
4699                        });
4700                        let fl_italic = styled.value("x-first-line-font-style").map(|s| {
4701                            let l = s.trim().to_lowercase();
4702                            l == "italic" || l == "oblique"
4703                        });
4704
4705                        // ::first-letter: このテキストノードの最初の1文字だけを別の RenderElement
4706                        // として切り出し、color/font-size/font-weight/font-style を上書きする。
4707                        let flt_color = styled
4708                            .value("x-first-letter-color")
4709                            .and_then(|s| parse_color(&s));
4710                        let flt_size = styled
4711                            .value("x-first-letter-font-size")
4712                            .and_then(|s| parse_font_size(&s));
4713                        let flt_bold = styled.value("x-first-letter-font-weight").map(|s| {
4714                            let l = s.trim().to_lowercase();
4715                            l == "bold" || l == "bolder" || l.parse::<u32>().unwrap_or(400) >= 700
4716                        });
4717                        let flt_italic = styled.value("x-first-letter-font-style").map(|s| {
4718                            let l = s.trim().to_lowercase();
4719                            l == "italic" || l == "oblique"
4720                        });
4721                        let has_first_letter_override = flt_color.is_some()
4722                            || flt_size.is_some()
4723                            || flt_bold.is_some()
4724                            || flt_italic.is_some();
4725
4726                        // white-space / text-overflow / word-break from this node's own CSS
4727                        let node_white_space_nowrap = styled
4728                            .specified_values
4729                            .get("white-space")
4730                            .map(|v| v.trim() == "nowrap")
4731                            .unwrap_or(false);
4732                        let node_white_space_pre_wrap = styled
4733                            .specified_values
4734                            .get("white-space")
4735                            .map(|v| v.trim() == "pre-wrap" || v.trim() == "pre-line")
4736                            .unwrap_or(false);
4737                        let node_word_break_all = styled
4738                            .value("word-break")
4739                            .map(|v| v.trim() == "break-all" || v.trim() == "break-word")
4740                            .unwrap_or(false)
4741                            || styled
4742                                .value("overflow-wrap")
4743                                .map(|v| v.trim() == "break-word" || v.trim() == "anywhere")
4744                                .unwrap_or(false)
4745                            || styled
4746                                .value("word-wrap")
4747                                .map(|v| v.trim() == "break-word" || v.trim() == "anywhere")
4748                                .unwrap_or(false);
4749                        let node_text_overflow_ellipsis = styled
4750                            .specified_values
4751                            .get("text-overflow")
4752                            .map(|v| v.trim() == "ellipsis")
4753                            .unwrap_or(false);
4754                        // `text-overflow:ellipsis` は `direction:rtl` では省略記号を先頭(視覚上の
4755                        // 左)に出し、末尾ではなく先頭側の文字を切り詰めるべき(CSS仕様)。
4756                        // 以前はこの区別が無く、rtl でも常に末尾を切り詰め末尾に "…" を
4757                        // 付けていたため、rtl テキストの省略位置が仕様と逆になっていた。
4758                        let node_is_rtl = styled
4759                            .value("direction")
4760                            .map(|v| v.trim().eq_ignore_ascii_case("rtl"))
4761                            .unwrap_or(false);
4762
4763                        if !display_text.trim().is_empty() {
4764                            let available_w = if layout.dimensions.content.width > 0 {
4765                                layout.dimensions.content.width
4766                            } else {
4767                                800
4768                            };
4769                            let mut current_line = String::new();
4770                            let mut current_line_w = 0i32;
4771
4772                            // line-height の解釈
4773                            let mut line_height = (final_size + 4) as i32;
4774                            if let Some(lh_str) = styled.value("line-height") {
4775                                line_height = parse_line_height(&lh_str, final_size);
4776                            }
4777
4778                            // white-space: nowrap — emit entire text as one RenderElement
4779                            if node_white_space_nowrap {
4780                                let text_w = crate::kernel::vector_font::get_vector_string_width_ls_ws(
4781                                    &display_text,
4782                                    final_size,
4783                                    final_ls,
4784                                    final_ws,
4785                                ) as i32;
4786                                let line_y = layout.dimensions.content.y;
4787                                self.elements.push(RenderElement {
4788                                    text: display_text.clone(),
4789                                    font_family: final_font_family.clone(),
4790                                    font_size: fl_size.unwrap_or(final_size),
4791                                    letter_spacing: final_ls,
4792                                    word_spacing: final_ws,
4793                                    text_emphasis_style: final_emphasis_style.clone(),
4794                                    text_emphasis_color: final_emphasis_color,
4795                                    color: fl_color.unwrap_or(final_color),
4796                                    bg_color: final_bg,
4797                                    font_bold: fl_bold.unwrap_or(final_bold),
4798                                    font_italic: fl_italic.unwrap_or(final_italic),
4799                                    underline: final_underline,
4800                                    line_through: final_line_through,
4801                                    overline: final_overline,
4802                                    decoration_color: final_decoration_color,
4803                                    decoration_style: final_decoration_style.clone(),
4804                                    decoration_thickness: final_decoration_thickness,
4805                                    decoration_underline_offset: final_decoration_underline_offset,
4806                                    is_link: is_actual_link,
4807                                    pointer_events_none: current_pointer_events_none,
4808                                    href: link_href.clone(),
4809                                    width: text_w,
4810                                    x_offset: layout.dimensions.content.x,
4811                                    y_offset: line_y,
4812                                    height: line_height,
4813                                    white_space_nowrap: true,
4814                                    text_overflow_ellipsis: node_text_overflow_ellipsis,
4815                                    text_overflow_rtl: node_is_rtl,
4816                                    z_index: self.inherited_paint_z,
4817                        paint_depth: self.inherited_paint_depth,
4818                        ..Default::default()
4819                                });
4820                                if is_actual_link {
4821                                    self.links.push(HyperlinkArea {
4822                                        href: link_href.clone(),
4823                                        x0: layout.dimensions.content.x,
4824                                        y0: line_y,
4825                                        x1: layout.dimensions.content.x + text_w,
4826                                        y1: line_y + line_height,
4827                                    });
4828                                }
4829                                // skip normal text rendering
4830                            } else {
4831                                // 重要: この折返し規則 ('\n' 強制改行・行頭/行末禁則・改行時に
4832                                // 先頭文字を即消費) は vector_font::get_string_wrapped_size と
4833                                // 完全に同一でなければならない。ズレると layout の段落高さと
4834                                // 行数が食い違い <hr> 等が重なる。変更時は必ず両方を同期すること。
4835                                let mut line_index = 0;
4836                                let line_clamp_max: Option<usize> = styled
4837                                    .value("-webkit-line-clamp")
4838                                    .or_else(|| styled.value("line-clamp"))
4839                                    .and_then(|s| s.trim().parse::<usize>().ok());
4840                                let mut prev_char: Option<char> = None;
4841                                let mut prev_advance = 0i32;
4842
4843                                // ::first-letter: 先頭1文字を別 RenderElement として切り出し、
4844                                // 残りのテキストは通常どおり折返す(先頭文字ぶんだけ x をずらす)。
4845                                // 注意: 折返し判定に使う文字列から1文字減るため、極めて稀に
4846                                // os_lib::layout 側の段落高さ計算(元の文字列全体を使う)と
4847                                // 改行位置が1文字ぶんずれる可能性がある簡略実装(text-indent と同種の近似)。
4848                                let mut first_letter_x_shift = 0i32;
4849                                let first_letter_iter_buf;
4850                                // CSS の ::first-letter は前置の句読点・引用符(例: `"Hello`
4851                                // の `"H`、`«Bonjour»` の `«B`)を実際の最初の文字と一体で扱う
4852                                // 仕様(CSS Pseudo-Elements §3.2)のため、先に句読点類を読み飛ばして
4853                                // fc_str へ蓄積してから実際の最初の文字を1つ加える。
4854                                fn is_first_letter_leading_punct(c: char) -> bool {
4855                                    matches!(
4856                                        c,
4857                                        '!' | '"' | '#' | '$' | '%' | '&' | '\'' | '(' | ')'
4858                                            | '*' | '+' | ',' | '-' | '.' | '/' | ':' | ';'
4859                                            | '<' | '=' | '>' | '?' | '@' | '[' | '\\' | ']'
4860                                            | '^' | '_' | '`' | '{' | '|' | '}' | '~'
4861                                            | '¡' | '¿' | '«' | '»'
4862                                            | '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}'
4863                                    )
4864                                }
4865                                let wrap_text: &str = if has_first_letter_override {
4866                                    let mut chars_iter = display_text.chars();
4867                                    let mut fc_str = String::new();
4868                                    loop {
4869                                        let mut probe = chars_iter.clone();
4870                                        match probe.next() {
4871                                            Some(c) if is_first_letter_leading_punct(c) => {
4872                                                fc_str.push(c);
4873                                                chars_iter = probe;
4874                                            }
4875                                            _ => break,
4876                                        }
4877                                    }
4878                                    if let Some(fc) = chars_iter.next() {
4879                                        let fc_size = flt_size.unwrap_or(final_size);
4880                                        let fc_color = flt_color.unwrap_or(final_color);
4881                                        let fc_bold = flt_bold.unwrap_or(final_bold);
4882                                        let fc_italic = flt_italic.unwrap_or(final_italic);
4883                                        fc_str.push(fc);
4884                                        let fc_w = crate::kernel::vector_font::get_vector_string_width(
4885                                            &fc_str, fc_size,
4886                                        ) as i32;
4887                                        let line_y = layout.dimensions.content.y;
4888                                        self.elements.push(RenderElement {
4889                                            text: fc_str,
4890                                    font_family: final_font_family.clone(),
4891                                            font_size: fc_size,
4892                                            color: fc_color,
4893                                            bg_color: final_bg,
4894                                            font_bold: fc_bold,
4895                                            font_italic: fc_italic,
4896                                            underline: final_underline,
4897                                            line_through: final_line_through,
4898                                            overline: final_overline,
4899                                            decoration_color: final_decoration_color,
4900                                            decoration_style: final_decoration_style.clone(),
4901                                            decoration_thickness: final_decoration_thickness,
4902                                            decoration_underline_offset: final_decoration_underline_offset,
4903                                            is_link: is_actual_link,
4904                                            pointer_events_none: current_pointer_events_none,
4905                                            href: link_href.clone(),
4906                                            width: fc_w,
4907                                            x_offset: layout.dimensions.content.x + text_indent_px,
4908                                            y_offset: line_y,
4909                                            height: line_height,
4910                                            z_index: self.inherited_paint_z,
4911                        paint_depth: self.inherited_paint_depth,
4912                        ..Default::default()
4913                                        });
4914                                        first_letter_x_shift = fc_w;
4915                                        first_letter_iter_buf = String::from(chars_iter.as_str());
4916                                        &first_letter_iter_buf
4917                                    } else {
4918                                        display_text.as_str()
4919                                    }
4920                                } else {
4921                                    display_text.as_str()
4922                                };
4923
4924                                let chars = wrap_text.chars().peekable();
4925                                for c in chars {
4926                                    if c == '\n' {
4927                                        if node_white_space_pre_wrap {
4928                                            // pre-wrap: treat '\n' as an actual newline
4929                                        } else {
4930                                            // collapse mode: '\n' inside text that somehow survived tree.rs flattening
4931                                            // (usually tree.rs flattens it to a space, but if it got here, we still break)
4932                                        }
4933                                        let line_y =
4934                                            layout.dimensions.content.y + line_index * line_height;
4935                                        self.elements.push(RenderElement {
4936                                            text: current_line.clone(),
4937                                        font_family: final_font_family.clone(),
4938                                            font_size: if line_index == 0 { fl_size.unwrap_or(final_size) } else { final_size },
4939                                            letter_spacing: final_ls,
4940                                            word_spacing: final_ws,
4941                                            text_emphasis_style: final_emphasis_style.clone(),
4942                                            text_emphasis_color: final_emphasis_color,
4943                                            color: if line_index == 0 { fl_color.unwrap_or(final_color) } else { final_color },
4944                                            bg_color: final_bg,
4945                                            hover_bg_color: extract_hover_color_alt(
4946                                                styled,
4947                                                "background-color",
4948                                                "background",
4949                                            ),
4950                                            hover_color: extract_hover_color(styled, "color"),
4951                                            hover_border_color: extract_hover_color(
4952                                                styled,
4953                                                "border-color",
4954                                            ),
4955                                            filter: styled.value("filter").unwrap_or_default(),
4956                                            mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
4957                                            backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
4958                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
4959                                text_stroke_width: extract_text_stroke(styled).0,
4960                                text_stroke_color: extract_text_stroke(styled).1,
4961                                            marker_color: extract_marker_color(styled),
4962                                            marker_font_size: extract_marker_font_size(styled),
4963                                            marker_content: extract_marker_content(styled),
4964                                            focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
4965                                            focus_color: extract_focus_color(styled, "color"),
4966                                            focus_border_color: extract_focus_color(styled, "border-color"),
4967                                            active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
4968                                            active_color: extract_active_color(styled, "color"),
4969                                            active_border_color: extract_active_color(styled, "border-color"),
4970                                            border_color: None,
4971                                            border_style: super::BorderStyle::None,
4972                                            font_bold: if line_index == 0 { fl_bold.unwrap_or(final_bold) } else { final_bold },
4973                                            font_italic: if line_index == 0 { fl_italic.unwrap_or(final_italic) } else { final_italic },
4974                                            underline: final_underline,
4975                                            line_through: final_line_through,
4976                                            overline: final_overline,
4977                                            decoration_color: final_decoration_color,
4978                                            decoration_style: final_decoration_style.clone(),
4979                                            decoration_thickness: final_decoration_thickness,
4980                                            decoration_underline_offset: final_decoration_underline_offset,
4981                                            is_video: false,
4982                                            video_src: String::new(),
4983                                            is_link: is_actual_link,
4984                                            pointer_events_none: current_pointer_events_none,
4985                                            href: link_href.clone(),
4986                                            is_hr: false,
4987                                            is_img: false,
4988                                canvas_ctx_id: None,
4989                                            img_alt: String::new(),
4990                                            is_input: false,
4991                                            is_textarea: false,
4992                                            is_button: false,
4993                                            button_icon: 0,
4994                                            element_id: String::new(),
4995                                            onclick: String::new(),
4996                                            form_action: String::new(),
4997                                            form_method: String::new(),
4998                                            width: current_line_w,
4999                                            x_offset: layout.dimensions.content.x + if line_index == 0 { text_indent_px + first_letter_x_shift } else { 0 },
5000                                            y_offset: line_y,
5001                                            height: line_height,
5002                                            border_radius: (0, 0, 0, 0),
5003                                            is_li: parent_tag == "li" && line_index == 0,
5004                                            li_num: if parent_tag == "li" && line_index == 0 {
5005                                                if let Some((true, count)) = list_state {
5006                                                    Some(count)
5007                                                } else {
5008                                                    None
5009                                                }
5010                                            } else {
5011                                                None
5012                                            },
5013                                            list_style_type: extract_list_style(styled),
5014                                            list_style_inside: extract_list_style_inside(styled),
5015                                            list_style_image: extract_list_style_image(styled),
5016                                            is_blockquote: false,
5017                                            z_index: self.inherited_paint_z,
5018                        paint_depth: self.inherited_paint_depth,
5019                        ..Default::default()
5020                                        });
5021
5022                                        if is_actual_link {
5023                                            self.links.push(HyperlinkArea {
5024                                                href: link_href.clone(),
5025                                                x0: layout.dimensions.content.x,
5026                                                y0: line_y,
5027                                                x1: layout.dimensions.content.x + current_line_w,
5028                                                y1: line_y + line_height,
5029                                            });
5030                                        }
5031
5032                                        line_index += 1;
5033                                        // `-webkit-line-clamp`(テキスト中の明示的な`\n`による
5034                                        // 改行フラッシュ経路)。同種の改行フラッシュがもう1箇所
5035                                        // (自然な折返しオーバーフロー経路。約80行下)にもあり、
5036                                        // そちらにしかこのチェックが配線されていなかったため、
5037                                        // `\n`を含むテキストでは`-webkit-line-clamp`が効かない
5038                                        // バグだった(2026-07-22発見・修正)。
5039                                        if let Some(max) = line_clamp_max {
5040                                            if line_index >= max as i32 {
5041                                                break;
5042                                            }
5043                                        }
5044                                        current_line.clear();
5045                                        current_line_w = 0;
5046                                        prev_char = None;
5047                                        prev_advance = 0;
5048                                        continue;
5049                                    }
5050
5051                                    let c_str = {
5052                                        let mut s = String::new();
5053                                        s.push(c);
5054                                        s
5055                                    };
5056                                    let advance =
5057                                        crate::kernel::vector_font::get_vector_string_width(
5058                                            &c_str, final_size,
5059                                        ) as i32
5060                                            + final_ls
5061                                            + if c == ' ' { final_ws } else { 0 };
5062
5063                                    if current_line_w > 0 && current_line_w + advance > available_w
5064                                    {
5065                                        // 行頭禁則
5066                                        if !node_word_break_all && crate::kernel::vector_font::is_line_start_forbidden(c) {
5067                                            current_line.push(c);
5068                                            current_line_w += advance;
5069                                            prev_char = Some(c);
5070                                            prev_advance = advance;
5071                                            continue;
5072                                        }
5073
5074                                        // 【2026-09-04】単語の途中では折り返さない。
5075                                        //
5076                                        // 従来は 1 文字単位で切っており、英単語が
5077                                        // 語中で割れていた(ナビ項目 "Papers" が
5078                                        // 縦一列に潰れる形で現れた)。CSS の既定
5079                                        // (`overflow-wrap: normal`)では単語は
5080                                        // 分割せず、収まらなければはみ出す。
5081                                        //
5082                                        // 規則は計測側(`vector_font` の
5083                                        // `get_string_wrapped_size_ext`)と同一に保つ。
5084                                        // ずれると箱の大きさと文字の位置が食い違う。
5085                                        if let Some(pc) = prev_char {
5086                                            if !crate::os_lib::layout::line_break::can_break_between(
5087                                                pc,
5088                                                c,
5089                                                node_word_break_all,
5090                                            ) {
5091                                                current_line.push(c);
5092                                                current_line_w += advance;
5093                                                prev_char = Some(c);
5094                                                prev_advance = advance;
5095                                                continue;
5096                                            }
5097                                        }
5098
5099                                        // 行末禁則
5100                                        let mut send_prev = false;
5101                                        if !node_word_break_all {
5102                                            if let Some(pc) = prev_char {
5103                                                if crate::kernel::vector_font::is_line_end_forbidden(pc)
5104                                                {
5105                                                    send_prev = true;
5106                                                }
5107                                            }
5108                                        }
5109
5110                                        let actual_w;
5111                                        let mut next_line = String::new();
5112                                        let mut next_line_w = 0;
5113
5114                                        if send_prev {
5115                                            if let Some(pc) = prev_char {
5116                                                current_line.pop();
5117                                                next_line.push(pc);
5118                                                next_line_w = prev_advance;
5119                                            }
5120                                            actual_w = current_line_w - prev_advance;
5121                                        } else {
5122                                            actual_w = current_line_w;
5123                                        }
5124
5125                                        let line_y =
5126                                            layout.dimensions.content.y + line_index * line_height;
5127                                        self.elements.push(RenderElement {
5128                                            text: current_line.clone(),
5129                                        font_family: final_font_family.clone(),
5130                                            font_size: if line_index == 0 { fl_size.unwrap_or(final_size) } else { final_size },
5131                                            letter_spacing: final_ls,
5132                                            word_spacing: final_ws,
5133                                            text_emphasis_style: final_emphasis_style.clone(),
5134                                            text_emphasis_color: final_emphasis_color,
5135                                            color: if line_index == 0 { fl_color.unwrap_or(final_color) } else { final_color },
5136                                            bg_color: final_bg,
5137                                            hover_bg_color: extract_hover_color_alt(
5138                                                styled,
5139                                                "background-color",
5140                                                "background",
5141                                            ),
5142                                            hover_color: extract_hover_color(styled, "color"),
5143                                            hover_border_color: extract_hover_color(
5144                                                styled,
5145                                                "border-color",
5146                                            ),
5147                                            filter: styled.value("filter").unwrap_or_default(),
5148                                            mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
5149                                            backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
5150                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
5151                                text_stroke_width: extract_text_stroke(styled).0,
5152                                text_stroke_color: extract_text_stroke(styled).1,
5153                                            marker_color: extract_marker_color(styled),
5154                                            marker_font_size: extract_marker_font_size(styled),
5155                                            marker_content: extract_marker_content(styled),
5156                                            focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
5157                                            focus_color: extract_focus_color(styled, "color"),
5158                                            focus_border_color: extract_focus_color(styled, "border-color"),
5159                                            active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
5160                                            active_color: extract_active_color(styled, "color"),
5161                                            active_border_color: extract_active_color(styled, "border-color"),
5162                                            border_color: None,
5163                                            border_style: super::BorderStyle::None,
5164                                            font_bold: if line_index == 0 { fl_bold.unwrap_or(final_bold) } else { final_bold },
5165                                            font_italic: if line_index == 0 { fl_italic.unwrap_or(final_italic) } else { final_italic },
5166                                            underline: final_underline,
5167                                            line_through: final_line_through,
5168                                            overline: final_overline,
5169                                            decoration_color: final_decoration_color,
5170                                            decoration_style: final_decoration_style.clone(),
5171                                            decoration_thickness: final_decoration_thickness,
5172                                            decoration_underline_offset: final_decoration_underline_offset,
5173                                            is_video: false,
5174                                            video_src: String::new(),
5175                                            is_link: is_actual_link,
5176                                            pointer_events_none: current_pointer_events_none,
5177                                            href: link_href.clone(),
5178                                            is_hr: false,
5179                                            is_img: false,
5180                                canvas_ctx_id: None,
5181                                            img_alt: String::new(),
5182                                            is_input: false,
5183                                            is_textarea: false,
5184                                            is_button: false,
5185                                            button_icon: 0,
5186                                            element_id: String::new(),
5187                                            onclick: String::new(),
5188                                            form_action: String::new(),
5189                                            form_method: String::new(),
5190                                            width: actual_w,
5191                                            x_offset: layout.dimensions.content.x + if line_index == 0 { text_indent_px + first_letter_x_shift } else { 0 },
5192                                            y_offset: line_y,
5193                                            height: line_height,
5194                                            border_radius: (0, 0, 0, 0),
5195                                            is_li: parent_tag == "li" && line_index == 0,
5196                                            li_num: if parent_tag == "li" && line_index == 0 {
5197                                                if let Some((true, count)) = list_state {
5198                                                    Some(count)
5199                                                } else {
5200                                                    None
5201                                                }
5202                                            } else {
5203                                                None
5204                                            },
5205                                            list_style_type: extract_list_style(styled),
5206                                            list_style_inside: extract_list_style_inside(styled),
5207                                            list_style_image: extract_list_style_image(styled),
5208                                            is_blockquote: false,
5209                                            z_index: self.inherited_paint_z,
5210                        paint_depth: self.inherited_paint_depth,
5211                        ..Default::default()
5212                                        });
5213
5214                                        if is_actual_link {
5215                                            self.links.push(HyperlinkArea {
5216                                                href: link_href.clone(),
5217                                                x0: layout.dimensions.content.x,
5218                                                y0: line_y,
5219                                                x1: layout.dimensions.content.x + actual_w,
5220                                                y1: line_y + line_height,
5221                                            });
5222                                        }
5223
5224                                        line_index += 1;
5225                                        if let Some(max) = line_clamp_max {
5226                                            if line_index >= max as i32 {
5227                                                break;
5228                                            }
5229                                        }
5230                                        current_line = next_line;
5231                                        current_line.push(c);
5232                                        current_line_w = next_line_w + advance;
5233
5234                                        prev_char = Some(c);
5235                                        prev_advance = advance;
5236                                        continue;
5237                                    }
5238
5239                                    current_line.push(c);
5240                                    current_line_w += advance;
5241                                    prev_char = Some(c);
5242                                    prev_advance = advance;
5243                                }
5244
5245                                if !current_line.is_empty() {
5246                                    let line_y =
5247                                        layout.dimensions.content.y + line_index * line_height;
5248                                    self.elements.push(RenderElement {
5249                                        text: current_line.clone(),
5250                                        font_family: final_font_family.clone(),
5251                                        font_size: final_size,
5252                                        letter_spacing: final_ls,
5253                                        word_spacing: final_ws,
5254                                        text_emphasis_style: final_emphasis_style.clone(),
5255                                        text_emphasis_color: final_emphasis_color,
5256                                        color: final_color,
5257                                        bg_color: final_bg,
5258                                        hover_bg_color: extract_hover_color_alt(
5259                                            styled,
5260                                            "background-color",
5261                                            "background",
5262                                        ),
5263                                        hover_color: extract_hover_color(styled, "color"),
5264                                        hover_border_color: extract_hover_color(
5265                                            styled,
5266                                            "border-color",
5267                                        ),
5268                                        filter: styled.value("filter").unwrap_or_default(),
5269                                        mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
5270                                        backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
5271                                image_rendering: styled.value("image-rendering").unwrap_or_default(),
5272                                text_stroke_width: extract_text_stroke(styled).0,
5273                                text_stroke_color: extract_text_stroke(styled).1,
5274                                        marker_color: extract_marker_color(styled),
5275                                        marker_font_size: extract_marker_font_size(styled),
5276                                        marker_content: extract_marker_content(styled),
5277                                        focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
5278                                        focus_color: extract_focus_color(styled, "color"),
5279                                        focus_border_color: extract_focus_color(styled, "border-color"),
5280                                        active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
5281                                        active_color: extract_active_color(styled, "color"),
5282                                        active_border_color: extract_active_color(styled, "border-color"),
5283                                        border_color: None,
5284                                        border_style: super::BorderStyle::None,
5285                                        font_bold: final_bold,
5286                                        font_italic: final_italic,
5287                                        underline: final_underline,
5288                                        line_through: final_line_through,
5289                                        overline: final_overline,
5290                                        decoration_color: final_decoration_color,
5291                                        decoration_style: final_decoration_style.clone(),
5292                                        decoration_thickness: final_decoration_thickness,
5293                                        decoration_underline_offset: final_decoration_underline_offset,
5294                                        is_video: false,
5295                                        video_src: String::new(),
5296                                        is_link: is_actual_link,
5297                                        pointer_events_none: current_pointer_events_none,
5298                                        href: link_href.clone(),
5299                                        is_hr: false,
5300                                        is_img: false,
5301                                canvas_ctx_id: None,
5302                                        img_alt: String::new(),
5303                                        is_input: false,
5304                                        is_textarea: false,
5305                                        is_button: false,
5306                                        button_icon: 0,
5307                                        element_id: String::new(),
5308                                        onclick: String::new(),
5309                                        form_action: String::new(),
5310                                        form_method: String::new(),
5311                                        width: current_line_w,
5312                                        x_offset: layout.dimensions.content.x + if line_index == 0 { text_indent_px + first_letter_x_shift } else { 0 },
5313                                        y_offset: line_y,
5314                                        height: line_height,
5315                                        border_radius: (0, 0, 0, 0),
5316                                        is_li: parent_tag == "li" && line_index == 0,
5317                                        li_num: if parent_tag == "li" && line_index == 0 {
5318                                            if let Some((true, count)) = list_state {
5319                                                Some(count)
5320                                            } else {
5321                                                None
5322                                            }
5323                                        } else {
5324                                            None
5325                                        },
5326                                        is_blockquote: false,
5327                                        z_index: self.inherited_paint_z,
5328                        paint_depth: self.inherited_paint_depth,
5329                        ..Default::default()
5330                                    });
5331
5332                                    if is_actual_link {
5333                                        self.links.push(HyperlinkArea {
5334                                            href: link_href.clone(),
5335                                            x0: layout.dimensions.content.x,
5336                                            y0: line_y,
5337                                            x1: layout.dimensions.content.x + current_line_w,
5338                                            y1: line_y + line_height,
5339                                        });
5340                                    }
5341                                }
5342                            } // end else (non-nowrap)
5343                        }
5344                    }
5345                }
5346            }
5347            _ => {
5348                // 匿名ボックス等、要素を生成しない中間ノードでも子はツリー上 1 段深い。
5349                // ここで深さを進めないと、祖先より子孫が浅く見えて描画順が逆転する。
5350                let saved_paint_depth = self.inherited_paint_depth;
5351                self.inherited_paint_depth = self.inherited_paint_depth.saturating_add(1);
5352                for child in &layout.children {
5353                    self.flatten_layout(
5354                        child,
5355                        current_form_action,
5356                        current_form_method,
5357                        current_href,
5358                        current_font_bold,
5359                        current_font_italic,
5360                        current_underline,
5361                        current_line_through,
5362                        current_color,
5363                        current_font_size,
5364                        parent_id,
5365                        list_state,
5366                        parent_tag,
5367                        current_decoration_color,
5368                        current_decoration_style.clone(),
5369                        current_decoration_thickness,
5370                        current_decoration_underline_offset,
5371                        current_overline,
5372                        current_pointer_events_none,
5373                    );
5374                }
5375                self.inherited_paint_depth = saved_paint_depth;
5376            }
5377        }
5378    }
5379}
5380
5381fn extract_text_stroke(styled: &crate::os_lib::css::StyledNode) -> (i32, Option<u32>) {
5382    let val_str = styled
5383        .value("text-stroke")
5384        .or_else(|| styled.value("-webkit-text-stroke"))
5385        .unwrap_or_default();
5386    if val_str.is_empty() {
5387        let width_str = styled.value("-webkit-text-stroke-width").unwrap_or_default();
5388        let color_str = styled.value("-webkit-text-stroke-color").unwrap_or_default();
5389        let w = parse_px_val(&width_str);
5390        let c = parse_color(&color_str);
5391        return (w, c);
5392    }
5393    let mut width = 0;
5394    let mut color = None;
5395    for p in val_str.split_whitespace() {
5396        let w = parse_px_val(p);
5397        if w > 0 {
5398            width = w;
5399        } else if let Some(c) = parse_color(p) {
5400            color = Some(c);
5401        }
5402    }
5403    (width, color)
5404}