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