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