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, ¤t_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, `­`)は、実際に行分割される箇所でだけ
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}