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 // 【2026-09-26】解析は純粋モジュール `box_shadow`(試験あり)へ。
2783 // 従来は空白で単純に分割して `rgba(0, 0, 0, 0.1)` を壊し、既定の
2784 // 50% の黒で塗っていた(カードの影が濃い灰色の塊になっていた)。
2785 let mut box_shadow: alloc::vec::Vec<(i32, i32, i32, u32, bool)> = styled
2786 .value("box-shadow")
2787 .map(|v| super::box_shadow::parse_list(&v.to_lowercase()))
2788 .unwrap_or_default();
2789
2790 if box_shadow.is_empty() {
2791 if let Some(f) = styled.value("filter") {
2792 if let Some(ds) =
2793 crate::os_lib::web_engine::draw_helpers::parse_drop_shadow(&f)
2794 {
2795 box_shadow.push(ds);
2796 }
2797 }
2798 }
2799
2800 // Parse linear-gradient from background or background-image
2801 let linear_gradient = {
2802 let bg_val = styled
2803 .value("background")
2804 .or_else(|| styled.value("background-image"));
2805 bg_val.and_then(|v| {
2806 let v_lower = v.to_lowercase();
2807 if !v_lower.contains("linear-gradient")
2808 || v_lower.contains("repeating-linear-gradient")
2809 {
2810 return None;
2811 }
2812 // `rgba(...)` が引数に入るため、単純な find(')') では
2813 // rgba の閉じ括弧を拾って定義が壊れる(実際に踏んだ回帰)。
2814 // 入れ子対応の対応括弧探索を使う。
2815 let open_paren = v_lower.find("linear-gradient(")? + 15;
2816 let inner_start = open_paren + 1;
2817 let inner_end =
2818 super::css_units::find_balanced_close(&v, open_paren)?;
2819 let inner = v.get(inner_start..inner_end)?;
2820 let mut parts = super::css_units::split_top_level_commas(inner).into_iter();
2821 let first = parts.next()?.trim();
2822
2823 let mut angle_deg = 180.0; // default to bottom
2824 let mut is_first_color = true;
2825
2826 // 角度や方向の判定
2827 let first_lower = first.to_lowercase();
2828 if first_lower.starts_with("to ") {
2829 if first_lower == "to top" { angle_deg = 0.0; }
2830 else if first_lower == "to right" { angle_deg = 90.0; }
2831 else if first_lower == "to bottom" { angle_deg = 180.0; }
2832 else if first_lower == "to left" { angle_deg = 270.0; }
2833 else if first_lower == "to top right" || first_lower == "to right top" { angle_deg = 45.0; }
2834 else if first_lower == "to bottom right" || first_lower == "to right bottom" { angle_deg = 135.0; }
2835 else if first_lower == "to bottom left" || first_lower == "to left bottom" { angle_deg = 225.0; }
2836 else if first_lower == "to top left" || first_lower == "to left top" { angle_deg = 315.0; }
2837 is_first_color = false;
2838 } else if first_lower.ends_with("deg") {
2839 if let Ok(deg) = first_lower.trim_end_matches("deg").trim().parse::<f32>() {
2840 angle_deg = deg;
2841 is_first_color = false;
2842 }
2843 } else if first_lower.ends_with("turn") {
2844 if let Ok(turn) = first_lower.trim_end_matches("turn").trim().parse::<f32>() {
2845 angle_deg = turn * 360.0;
2846 is_first_color = false;
2847 }
2848 }
2849
2850 let mut raw_stops = alloc::vec::Vec::new();
2851 if is_first_color {
2852 raw_stops.push(first);
2853 }
2854 for p in parts {
2855 raw_stops.push(p.trim());
2856 }
2857
2858 let mut stops = alloc::vec::Vec::new();
2859 for (i, s) in raw_stops.iter().enumerate() {
2860 let mut tokens = s.split_whitespace();
2861 if let Some(c_str) = tokens.next() {
2862 if let Some(c) = parse_color(c_str) {
2863 let mut pos = None;
2864 if let Some(p_str) = tokens.next() {
2865 if p_str.ends_with('%') {
2866 if let Ok(v) = p_str.trim_end_matches('%').parse::<f32>() {
2867 pos = Some(v / 100.0);
2868 }
2869 }
2870 }
2871 stops.push((c, pos));
2872 }
2873 }
2874 }
2875
2876 if stops.len() < 2 {
2877 return None;
2878 }
2879
2880 // 位置の自動計算 (省略されている場合)
2881 let n = stops.len();
2882 if stops[0].1.is_none() { stops[0].1 = Some(0.0); }
2883 if stops[n-1].1.is_none() { stops[n-1].1 = Some(1.0); }
2884
2885 let mut final_stops = alloc::vec::Vec::new();
2886 let mut last_pos = 0.0;
2887 for i in 0..n {
2888 if let Some(p) = stops[i].1 {
2889 last_pos = p;
2890 } else {
2891 // Find next specified position
2892 let mut next_pos = 1.0;
2893 let mut next_idx = n - 1;
2894 for j in i+1..n {
2895 if let Some(p) = stops[j].1 {
2896 next_pos = p;
2897 next_idx = j;
2898 break;
2899 }
2900 }
2901 let step = (next_pos - last_pos) / ((next_idx - i + 1) as f32);
2902 last_pos += step;
2903 }
2904 final_stops.push(super::GradientStop {
2905 color: stops[i].0,
2906 position: last_pos,
2907 });
2908 }
2909
2910 Some(super::LinearGradient {
2911 angle_deg,
2912 stops: final_stops,
2913 })
2914 })
2915 };
2916
2917 // Parse radial-gradient(circle, c1, c2) from background/background-image.
2918 // 楕円形状・位置指定は非対応で、常に矩形の中心を起点とする簡略実装。
2919 let radial_gradient = {
2920 let bg_val = styled
2921 .value("background")
2922 .or_else(|| styled.value("background-image"));
2923 bg_val.and_then(|v| {
2924 let v_lower = v.to_lowercase();
2925 if !v_lower.contains("radial-gradient")
2926 || v_lower.contains("repeating-radial-gradient")
2927 {
2928 return None;
2929 }
2930 let inner_start = v_lower.find("radial-gradient(")? + 17;
2931 let inner_end =
2932 v_lower.get(inner_start..)?.find(')')? + inner_start;
2933 let inner = v.get(inner_start..inner_end)?;
2934 let mut parts = inner.split(',');
2935 let first = parts.next()?.trim();
2936
2937 let mut shape = super::RadialShape::Ellipse;
2938 let mut pos_x = 0.5;
2939 let mut pos_y = 0.5;
2940 let mut is_first_color = true;
2941
2942 let first_lower = first.to_lowercase();
2943 if first_lower.contains("circle") || first_lower.contains("ellipse") || first_lower.contains("at ") {
2944 is_first_color = false;
2945 if first_lower.contains("circle") {
2946 shape = super::RadialShape::Circle;
2947 }
2948 if let Some(at_idx) = first_lower.find("at ") {
2949 let pos_str = first_lower.get(at_idx + 3..)?;
2950 let mut pos_tokens = pos_str.split_whitespace();
2951 if let Some(x_str) = pos_tokens.next() {
2952 if x_str == "left" { pos_x = 0.0; }
2953 else if x_str == "right" { pos_x = 1.0; }
2954 else if x_str == "center" { pos_x = 0.5; }
2955 else if x_str.ends_with('%') {
2956 if let Ok(v) = x_str.trim_end_matches('%').parse::<f32>() {
2957 pos_x = v / 100.0;
2958 }
2959 }
2960 }
2961 if let Some(y_str) = pos_tokens.next() {
2962 if y_str == "top" { pos_y = 0.0; }
2963 else if y_str == "bottom" { pos_y = 1.0; }
2964 else if y_str == "center" { pos_y = 0.5; }
2965 else if y_str.ends_with('%') {
2966 if let Ok(v) = y_str.trim_end_matches('%').parse::<f32>() {
2967 pos_y = v / 100.0;
2968 }
2969 }
2970 } else {
2971 pos_y = pos_x; // 1つだけ指定された場合の中央寄せなど (厳密な仕様ではないが簡略)
2972 }
2973 }
2974 }
2975
2976 let mut raw_stops = alloc::vec::Vec::new();
2977 if is_first_color {
2978 raw_stops.push(first);
2979 }
2980 for p in parts {
2981 raw_stops.push(p.trim());
2982 }
2983
2984 let mut stops = alloc::vec::Vec::new();
2985 for (i, s) in raw_stops.iter().enumerate() {
2986 let mut tokens = s.split_whitespace();
2987 if let Some(c_str) = tokens.next() {
2988 if let Some(c) = parse_color(c_str) {
2989 let mut pos = None;
2990 if let Some(p_str) = tokens.next() {
2991 if p_str.ends_with('%') {
2992 if let Ok(v) = p_str.trim_end_matches('%').parse::<f32>() {
2993 pos = Some(v / 100.0);
2994 }
2995 }
2996 }
2997 stops.push((c, pos));
2998 }
2999 }
3000 }
3001
3002 if stops.len() < 2 {
3003 return None;
3004 }
3005
3006 let n = stops.len();
3007 if stops[0].1.is_none() { stops[0].1 = Some(0.0); }
3008 if stops[n-1].1.is_none() { stops[n-1].1 = Some(1.0); }
3009
3010 let mut final_stops = alloc::vec::Vec::new();
3011 let mut last_pos = 0.0;
3012 for i in 0..n {
3013 if let Some(p) = stops[i].1 {
3014 last_pos = p;
3015 } else {
3016 let mut next_pos = 1.0;
3017 let mut next_idx = n - 1;
3018 for j in i+1..n {
3019 if let Some(p) = stops[j].1 {
3020 next_pos = p;
3021 next_idx = j;
3022 break;
3023 }
3024 }
3025 let step = (next_pos - last_pos) / ((next_idx - i + 1) as f32);
3026 last_pos += step;
3027 }
3028 final_stops.push(super::GradientStop {
3029 color: stops[i].0,
3030 position: last_pos,
3031 });
3032 }
3033
3034 Some(super::RadialGradient {
3035 shape,
3036 pos_x,
3037 pos_y,
3038 stops: final_stops,
3039 })
3040 })
3041 };
3042
3043 // Parse conic-gradient(c1, c2, ...) from background/background-image.
3044 // 全カラーストップを角度方向へ等間隔配置する(`from`/位置指定は非対応の
3045 // 簡略実装、radial-gradient と同じ方針)。以前は先頭2色しか保持できない
3046 // `(u32,u32)` だったため3色目以降が丸ごと失われるバグがあった。
3047 let conic_gradient = {
3048 let bg_val = styled
3049 .value("background")
3050 .or_else(|| styled.value("background-image"));
3051 bg_val.and_then(|v| {
3052 let v_lower = v.to_lowercase();
3053 if !v_lower.contains("conic-gradient") {
3054 return None;
3055 }
3056 let inner_start = v_lower.find("conic-gradient(")? + 15;
3057 let inner_end =
3058 v_lower.get(inner_start..)?.find(')')? + inner_start;
3059 let inner = v.get(inner_start..inner_end)?;
3060 let parts: Vec<&str> = inner.split(',').collect();
3061 let colors: Vec<u32> = parts
3062 .iter()
3063 .filter_map(|p| parse_color(p.trim()))
3064 .collect();
3065 if colors.len() < 2 {
3066 return None;
3067 }
3068 let n = colors.len();
3069 let stops: alloc::vec::Vec<super::GradientStop> = colors
3070 .into_iter()
3071 .enumerate()
3072 .map(|(i, color)| super::GradientStop {
3073 color,
3074 position: i as f32 / (n - 1) as f32,
3075 })
3076 .collect();
3077 Some(stops)
3078 })
3079 };
3080
3081 // repeating-linear-gradient(c1 [len], c2 len, ...) / repeating-radial-gradient(...):
3082 // 色トークンの中から末尾の px 長さトークンを周期(cycle_px)として使う簡略実装
3083 // (角度/形状/位置指定は非対応、px 以外の単位も非対応)。全カラーストップを
3084 // 収集し周期内で均等割り補間する(以前は先頭2色のみで3色目以降が失われていた)。
3085 fn extract_px_cycle(parts: &[&str]) -> Option<i32> {
3086 parts
3087 .iter()
3088 .rev()
3089 .find_map(|p| {
3090 p.trim()
3091 .rsplit(' ')
3092 .next()
3093 .and_then(|tok| tok.strip_suffix("px"))
3094 .and_then(|n| n.parse::<i32>().ok())
3095 })
3096 .filter(|n| *n > 0)
3097 }
3098 let repeating_linear_gradient = {
3099 let bg_val = styled
3100 .value("background")
3101 .or_else(|| styled.value("background-image"));
3102 bg_val.and_then(|v| {
3103 let v_lower = v.to_lowercase();
3104 if !v_lower.contains("repeating-linear-gradient") {
3105 return None;
3106 }
3107 let inner_start = v_lower.find("repeating-linear-gradient(")? + 26;
3108 let inner_end =
3109 v_lower.get(inner_start..)?.find(')')? + inner_start;
3110 let inner = v.get(inner_start..inner_end)?;
3111 let parts: Vec<&str> = inner.split(',').collect();
3112 let colors: Vec<u32> = parts
3113 .iter()
3114 .filter_map(|p| parse_color(p.trim().split(' ').next().unwrap_or("")))
3115 .collect();
3116 if colors.len() < 2 {
3117 return None;
3118 }
3119 let cycle = extract_px_cycle(&parts).unwrap_or(20);
3120 let dir = parts[0].trim().to_lowercase();
3121 let is_vertical =
3122 dir.contains("bottom") || dir == "180deg" || dir == "to bottom";
3123 Some((colors, cycle, is_vertical))
3124 })
3125 };
3126 let repeating_radial_gradient = {
3127 let bg_val = styled
3128 .value("background")
3129 .or_else(|| styled.value("background-image"));
3130 bg_val.and_then(|v| {
3131 let v_lower = v.to_lowercase();
3132 if !v_lower.contains("repeating-radial-gradient") {
3133 return None;
3134 }
3135 let inner_start = v_lower.find("repeating-radial-gradient(")? + 26;
3136 let inner_end =
3137 v_lower.get(inner_start..)?.find(')')? + inner_start;
3138 let inner = v.get(inner_start..inner_end)?;
3139 let parts: Vec<&str> = inner.split(',').collect();
3140 let colors: Vec<u32> = parts
3141 .iter()
3142 .filter_map(|p| parse_color(p.trim().split(' ').next().unwrap_or("")))
3143 .collect();
3144 if colors.len() < 2 {
3145 return None;
3146 }
3147 let cycle = extract_px_cycle(&parts).unwrap_or(20);
3148 Some((colors, cycle))
3149 })
3150 };
3151
3152 // `visibility: collapse` は本来テーブル行/列専用の特殊な折りたたみ
3153 // レイアウト(行/列自体の高さ/幅を無くす)を持つが、この処理系はその
3154 // 専用レイアウトを実装していない。以前は `collapse` が文字列として
3155 // `"hidden"` と一致しないため何の効果も持たず(可視のまま描画される)、
3156 // 完全に無視されていた。テーブル行/列以外の要素では仕様上も
3157 // `collapse` は `hidden` と同じ扱いでよいとされているため、
3158 // 専用レイアウト非対応を許容しつつ `hidden` と同一視する簡略実装とする
3159 // (何もしないよりは大幅に改善、他の多くの簡略実装と同じ妥協水準)。
3160 let visibility_hidden = styled
3161 .value("visibility")
3162 .map(|v| matches!(v.trim(), "hidden" | "collapse"))
3163 .unwrap_or(false);
3164
3165 // コンテナ背景・ボーダー要素の追加
3166 let container_x = layout.dimensions.content.x
3167 - layout.dimensions.padding.left
3168 - layout.dimensions.border.left;
3169 let container_y = layout.dimensions.content.y
3170 - layout.dimensions.padding.top
3171 - layout.dimensions.border.top;
3172 let container_w = layout.dimensions.content.width
3173 + layout.dimensions.padding.left
3174 + layout.dimensions.padding.right
3175 + layout.dimensions.border.left
3176 + layout.dimensions.border.right;
3177 let container_h = layout.dimensions.content.height
3178 + layout.dimensions.padding.top
3179 + layout.dimensions.padding.bottom
3180 + layout.dimensions.border.top
3181 + layout.dimensions.border.bottom;
3182
3183 // `border-radius` の `%` 値を要素サイズに対する割合として
3184 // 解決する(`border-radius: <h-radii> / <v-radii>` の楕円角
3185 // 構文は非対応のため水平半径のみを円弧として使う従来どおりの
3186 // 簡略実装。`%` は仕様上コーナーごとに対応する軸(水平半径は
3187 // 幅、垂直半径は高さ)に対する割合だが、この実装は水平半径
3188 // しか保持しないため常に `container_w` を基準にする)。
3189 // `rem`対応は`resolve_border_radius`(モジュールトップレベル。
3190 // 丸ごと未対応だった。2026-07-17)参照。
3191 let parse_radius = |s: &str| -> i32 { resolve_border_radius(s, container_w) };
3192 let mut border_radius = (0, 0, 0, 0);
3193 if let Some(s) = &border_radius_shorthand {
3194 let h_part = s.split('/').next().unwrap_or(s);
3195 let parts: alloc::vec::Vec<&str> = h_part.split_whitespace().collect();
3196 if parts.len() == 1 {
3197 let v = parse_radius(parts[0]);
3198 border_radius = (v, v, v, v);
3199 } else if parts.len() == 2 {
3200 let v1 = parse_radius(parts[0]);
3201 let v2 = parse_radius(parts[1]);
3202 border_radius = (v1, v2, v1, v2);
3203 } else if parts.len() == 3 {
3204 let v1 = parse_radius(parts[0]);
3205 let v2 = parse_radius(parts[1]);
3206 let v3 = parse_radius(parts[2]);
3207 border_radius = (v1, v2, v3, v2);
3208 } else if parts.len() >= 4 {
3209 border_radius = (
3210 parse_radius(parts[0]),
3211 parse_radius(parts[1]),
3212 parse_radius(parts[2]),
3213 parse_radius(parts[3]),
3214 );
3215 }
3216 }
3217 if let Some(s) = &border_radius_tl { border_radius.0 = parse_radius(s); }
3218 if let Some(s) = &border_radius_tr { border_radius.1 = parse_radius(s); }
3219 if let Some(s) = &border_radius_br { border_radius.2 = parse_radius(s); }
3220 if let Some(s) = &border_radius_bl { border_radius.3 = parse_radius(s); }
3221
3222 let bg_color = extract_color(styled, "background-color", "background");
3223 // 【2026-08-03診断】研究セクション(高さ 990 の要素)の
3224 // 背景が `None` になる件。生の算出値を 1 回だけ出す。
3225 if bg_color.is_none() && layout.dimensions.content.height > 800 {
3226 static ONCE: core::sync::atomic::AtomicU32 =
3227 core::sync::atomic::AtomicU32::new(0);
3228 if ONCE.fetch_add(1, core::sync::atomic::Ordering::Relaxed) < 4 {
3229 crate::warn!(
3230 "[BGDIAG] tag={} h={} background-color={:?} background={:?} class={:?}",
3231 tag_name,
3232 layout.dimensions.content.height,
3233 styled.value("background-color"),
3234 styled.value("background"),
3235 styled.value("x-class-debug")
3236 );
3237 }
3238 }
3239 let border_style = extract_border_style(styled);
3240 let border_color = if !border_style.is_none() {
3241 extract_border_color(styled, 0)
3242 } else {
3243 None
3244 };
3245
3246 let border_top_style = styled
3247 .value("border-top-style")
3248 .or_else(|| styled.value("border-style"))
3249 .map(|s| super::BorderStyle::parse(&s))
3250 .unwrap_or_default();
3251 let border_right_style = styled
3252 .value("border-right-style")
3253 .or_else(|| styled.value("border-style"))
3254 .map(|s| super::BorderStyle::parse(&s))
3255 .unwrap_or_default();
3256 let border_bottom_style = styled
3257 .value("border-bottom-style")
3258 .or_else(|| styled.value("border-style"))
3259 .map(|s| super::BorderStyle::parse(&s))
3260 .unwrap_or_default();
3261 let border_left_style = styled
3262 .value("border-left-style")
3263 .or_else(|| styled.value("border-style"))
3264 .map(|s| super::BorderStyle::parse(&s))
3265 .unwrap_or_default();
3266
3267 let border_top_color = styled
3268 .value("border-top-color")
3269 .or_else(|| styled.value("border-color"))
3270 .and_then(|v| parse_color(&v));
3271 let border_right_color = styled
3272 .value("border-right-color")
3273 .or_else(|| styled.value("border-color"))
3274 .and_then(|v| parse_color(&v));
3275 let border_bottom_color = styled
3276 .value("border-bottom-color")
3277 .or_else(|| styled.value("border-color"))
3278 .and_then(|v| parse_color(&v));
3279 let border_left_color = styled
3280 .value("border-left-color")
3281 .or_else(|| styled.value("border-color"))
3282 .and_then(|v| parse_color(&v));
3283
3284 let bg_image = styled
3285 .value("background-image")
3286 .or_else(|| styled.value("background"))
3287 .and_then(|v| extract_url_path(&v));
3288 let bg_images = styled
3289 .value("background-image")
3290 .or_else(|| styled.value("background"))
3291 .map(|v| extract_all_url_paths(&v))
3292 .unwrap_or_default();
3293 // `background`ショートハンドの`<position>/<size>`分解は
3294 // カスケード時点(`css::expand_background_shorthand`)で完了済みの
3295 // ため、ここでは`background-position`/`-size`/`-repeat`の
3296 // longhandをそのまま読むだけでよい。
3297 let bg_size_raw = styled.value("background-size");
3298 let bg_size = parse_bg_size(bg_size_raw.as_deref());
3299 let bg_repeat_raw = styled.value("background-repeat");
3300 let (bg_repeat_x, bg_repeat_y) = parse_bg_repeat(bg_repeat_raw.as_deref());
3301 let bg_position_raw = styled.value("background-position");
3302 let (bg_pos_x, bg_pos_y) = parse_bg_position(bg_position_raw.as_deref());
3303 // 複数レイヤー(`background-image: url(a), url(b)`)向けの、レイヤーごとの
3304 // size/repeat/position。以前は全レイヤーが `bg_size`/`bg_repeat_*`/
3305 // `bg_pos_*`(先頭値のみ)を共有しており、`background-position: left top,
3306 // right bottom` のようなレイヤーごとの指定が無視されるバグがあった。
3307 // CSS仕様どおり、値の個数がレイヤー数より少なければ先頭から繰り返す。
3308 let bg_layers: alloc::vec::Vec<(
3309 super::BgSizeMode,
3310 super::BgRepeatMode,
3311 super::BgRepeatMode,
3312 f32,
3313 f32,
3314 )> = if bg_images.len() > 1 {
3315 let sizes: alloc::vec::Vec<&str> = bg_size_raw
3316 .as_deref()
3317 .map(|v| v.split(',').collect())
3318 .unwrap_or_default();
3319 let repeats: alloc::vec::Vec<&str> = bg_repeat_raw
3320 .as_deref()
3321 .map(|v| v.split(',').collect())
3322 .unwrap_or_default();
3323 let positions: alloc::vec::Vec<&str> = bg_position_raw
3324 .as_deref()
3325 .map(|v| v.split(',').collect())
3326 .unwrap_or_default();
3327 (0..bg_images.len())
3328 .map(|i| {
3329 let size = if sizes.is_empty() {
3330 bg_size
3331 } else {
3332 parse_bg_size(Some(sizes[i % sizes.len()].trim()))
3333 };
3334 let (rx, ry) = if repeats.is_empty() {
3335 (bg_repeat_x, bg_repeat_y)
3336 } else {
3337 parse_bg_repeat(Some(repeats[i % repeats.len()].trim()))
3338 };
3339 let (px, py) = if positions.is_empty() {
3340 (bg_pos_x, bg_pos_y)
3341 } else {
3342 parse_bg_position(Some(positions[i % positions.len()].trim()))
3343 };
3344 (size, rx, ry, px, py)
3345 })
3346 .collect()
3347 } else {
3348 alloc::vec::Vec::new()
3349 };
3350 let background_blend_mode = styled
3351 .value("background-blend-mode")
3352 .unwrap_or_default();
3353 // `background-clip`/`background-origin`: border-box/padding-box/content-box。
3354 // `background-clip: text` は文字マスク合成が必要で非対応のため border-box にフォールバック。
3355 fn normalize_bg_box(v: Option<alloc::string::String>) -> alloc::string::String {
3356 match v.as_deref().map(str::trim) {
3357 Some("padding-box") => alloc::string::String::from("padding-box"),
3358 Some("content-box") => alloc::string::String::from("content-box"),
3359 _ => alloc::string::String::from("border-box"),
3360 }
3361 }
3362 let bg_clip = normalize_bg_box(
3363 styled.value("background-clip")
3364 .or_else(|| styled.value("-webkit-background-clip")),
3365 );
3366 // `background-origin` の既定値は border-box ではなく padding-box(CSS 仕様)。
3367 let bg_origin = match styled.value("background-origin").as_deref().map(str::trim)
3368 {
3369 Some("border-box") => alloc::string::String::from("border-box"),
3370 Some("content-box") => alloc::string::String::from("content-box"),
3371 _ => alloc::string::String::from("padding-box"),
3372 };
3373 // `background-attachment: fixed` はスクロールしてもパターンが
3374 // ビューポートに固定されて見える効果。`local` はネストしたスクロール
3375 // コンテナ内での挙動差異があるが、この処理系では `scroll` と同一視する。
3376 let bg_attachment = match styled.value("background-attachment").as_deref().map(str::trim)
3377 {
3378 Some("fixed") => alloc::string::String::from("fixed"),
3379 _ => alloc::string::String::from("scroll"),
3380 };
3381 // CSS `cursor`: `text` のみ実際のカーソル形状へ反映する(他は非対応で
3382 // 既定の矢印にフォールバックするが、値自体は保持する)。
3383 let cursor_style = styled
3384 .value("cursor")
3385 .map(|v| String::from(v.trim()))
3386 .unwrap_or_default();
3387 let (outline_color, outline_width, outline_style) =
3388 match parse_outline(styled) {
3389 Some((c, w, s)) => (Some(c), w, s),
3390 None => (None, 0, String::from("solid")),
3391 };
3392 let outline_offset = styled
3393 .value("outline-offset")
3394 .map(|v| parse_px_val(v.trim()))
3395 .unwrap_or(0);
3396
3397 // Parse text-shadow: `<Xpx> <Ypx> [<Blur>px] [<color>]` のカンマ区切り
3398 // 複数シャドウに対応(box-shadow と同じ理由・同じ方式で最上位カンマ分割)。
3399 // `rem`が丸ごと未対応だった同型のバグ(box-shadowと同じ理由。
3400 // 詳細は`parse_shadow_len_tok`のdocコメント参照)。同関数を再利用。
3401 fn parse_one_text_shadow(seg: &str) -> Option<(i32, i32, i32, u32)> {
3402 let seg = seg.trim();
3403 if seg.is_empty() {
3404 return None;
3405 }
3406 let mut ox = 0i32;
3407 let mut oy = 0i32;
3408 let mut blur = 0i32;
3409 let mut sh_color = 0x80808080u32;
3410 let mut px_count = 0;
3411 for token in seg.split_whitespace() {
3412 if let Some(n) = parse_shadow_len_tok(token) {
3413 match px_count {
3414 0 => ox = n,
3415 1 => oy = n,
3416 2 => blur = n,
3417 _ => {}
3418 }
3419 px_count += 1;
3420 continue;
3421 }
3422 if let Some(c) = parse_color(token) {
3423 sh_color = c;
3424 }
3425 }
3426 if px_count == 0 {
3427 None
3428 } else {
3429 Some((ox, oy, blur, sh_color))
3430 }
3431 }
3432 let text_shadow: alloc::vec::Vec<(i32, i32, i32, u32)> = styled
3433 .value("text-shadow")
3434 .map_or_else(alloc::vec::Vec::new, |v| {
3435 let v = v.trim().to_lowercase();
3436 if v == "none" {
3437 alloc::vec::Vec::new()
3438 } else {
3439 v.split(',').filter_map(parse_one_text_shadow).collect()
3440 }
3441 });
3442
3443 // ::before / ::after content。`display:none` が指定されていれば
3444 // 仕様通りボックス自体を生成しない(`content:none` と同じ扱い)。
3445 let before_content = if styled.value("x-before-display").as_deref() == Some("none") {
3446 String::new()
3447 } else {
3448 styled.value("x-before-content").unwrap_or_default()
3449 };
3450 let after_content = if styled.value("x-after-display").as_deref() == Some("none") {
3451 String::new()
3452 } else {
3453 styled.value("x-after-content").unwrap_or_default()
3454 };
3455 // FontAwesome等のアイコンフォントは、アイコン専用クラス(`.fas`/`.far`/
3456 // `.fab`等)で要素自身に`font-family`を指定し、実際のグリフは同じ要素の
3457 // `::before{content:"\fXXX"}`から出す、という実装が一般的(`::before`は
3458 // 既定で親要素からfont-familyを継承するため、要素自身の指定で足りる)。
3459 // `x-before-font-family`(`::before`セレクタへの直接指定、稀)があれば
3460 // それを優先し、無ければ要素自身の`font-family`にフォールバックする。
3461 // 要素自身の font-family。カンマ区切りのまま渡し、
3462 // 描画側が先頭から順に登録済みフォントを探す。
3463 let font_family = styled
3464 .value("font-family")
3465 .map(|v| String::from(v.trim()))
3466 .unwrap_or_default();
3467 let before_font_family = styled
3468 .value("x-before-font-family")
3469 .or_else(|| styled.value("font-family"))
3470 .and_then(|v| v.split(',').next().map(|s| String::from(s.trim().trim_matches('"').trim_matches('\''))))
3471 .unwrap_or_default();
3472
3473 // empty-cells: hide — 空セル(子孫がテキストのみ、かつ空白のみ)の
3474 // 背景/境界線を非表示にする。`border-collapse:collapse` では常に
3475 // 境界線が表示される仕様だが、この簡略実装ではその区別は行わない
3476 // (collapse 時の empty-cells 適用除外は非対応、実用上の影響は軽微)。
3477 let is_empty_cell_hidden = (tag_name == "td" || tag_name == "th")
3478 && styled
3479 .value("empty-cells")
3480 .map(|v| v.trim().eq_ignore_ascii_case("hide"))
3481 .unwrap_or(false)
3482 && styled.node.children.iter().all(|c| match &c.node_type {
3483 crate::os_lib::dom::NodeType::Text(t) => t.trim().is_empty(),
3484 _ => false,
3485 });
3486
3487 // CSS transform: translate / scale(X/Y) / rotate 簡易パース
3488 let (
3489 transform_tx,
3490 transform_ty,
3491 transform_scale_x,
3492 transform_scale_y,
3493 transform_rotate_deg,
3494 transform_skew_x,
3495 transform_skew_y,
3496 ) = parse_transform(styled.value("transform").as_deref());
3497 let (transform_origin_x, transform_origin_y) =
3498 parse_transform_origin(styled.value("transform-origin").as_deref());
3499
3500 if (bg_color.is_some()
3501 || (border_color.is_some() && !border_style.is_none())
3502 || tag_name == "blockquote"
3503 || bg_image.is_some()
3504 || linear_gradient.is_some()
3505 || radial_gradient.is_some()
3506 || conic_gradient.is_some()
3507 || repeating_linear_gradient.is_some()
3508 || repeating_radial_gradient.is_some()
3509 || !box_shadow.is_empty()
3510 // 【2026-09-26 バグ修正】`::before`/`::after` の内容を持つ要素も
3511 // 描画要素にする。従来は背景・枠線が無いと箱を作らず、内容ごと
3512 // 捨てていた。Font Awesome の `<i class="fas fa-...">` は背景を
3513 // 持たないので、www.sugi-lab.net のアイコンが全部消えていた。
3514 // 本文の文字を持つ要素は対象外(疑似要素の幅がレイアウトに
3515 // 入っていないので、本文と重なって描かれるため)。
3516 || ((!before_content.is_empty() || !after_content.is_empty())
3517 && styled.node.children.iter().all(|c| match &c.node_type {
3518 crate::os_lib::dom::NodeType::Text(t) => t.trim().is_empty(),
3519 _ => true,
3520 })))
3521 && container_w > 0
3522 && container_h > 0
3523 && tag_name != "head"
3524 && tag_name != "title"
3525 && tag_name != "meta"
3526 && tag_name != "link"
3527 && tag_name != "style"
3528 && tag_name != "script"
3529 && !is_empty_cell_hidden
3530 {
3531 // 疑似要素の内容を描くときは要素の文字サイズ・色が要る
3532 // (無ければ従来どおり 0 = 文字は描かない箱)。
3533 let has_pseudo = !before_content.is_empty() || !after_content.is_empty();
3534 let pseudo_color = if has_pseudo {
3535 let (default_text_color, default_link_color) = self.default_text_colors();
3536 if current_href.is_empty() {
3537 next_color.unwrap_or(default_text_color)
3538 } else {
3539 next_color.unwrap_or(default_link_color)
3540 }
3541 } else {
3542 0
3543 };
3544 self.elements.push(RenderElement {
3545 text: String::new(),
3546 font_size: if has_pseudo { next_font_size } else { 0 },
3547 color: pseudo_color,
3548 bg_color,
3549 hover_bg_color: extract_hover_color_alt(
3550 styled,
3551 "background-color",
3552 "background",
3553 ),
3554 hover_color: None,
3555 hover_border_color: extract_hover_color(styled, "border-color"),
3556 filter: styled.value("filter").unwrap_or_default(),
3557 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
3558 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
3559 image_rendering: styled.value("image-rendering").unwrap_or_default(),
3560 text_stroke_width: extract_text_stroke(styled).0,
3561 text_stroke_color: extract_text_stroke(styled).1,
3562 marker_color: extract_marker_color(styled),
3563 marker_font_size: extract_marker_font_size(styled),
3564 marker_content: extract_marker_content(styled),
3565 focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
3566 focus_color: None,
3567 focus_border_color: extract_focus_color(styled, "border-color"),
3568 active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
3569 active_color: None,
3570 active_border_color: extract_active_color(styled, "border-color"),
3571 border_color,
3572 border_style,
3573 // 【2026-08-05 発見・修正】`false` の決め打ちだった。
3574 //
3575 // この要素の `::before` / `::after` の内容は
3576 // **この要素自身**の書体で描かれる。にもかかわらず
3577 // 太字・斜体を常に無効にしていたため、
3578 // `.fas { font-weight: 900 }` のアイコンが
3579 // regular(400) の書体で引かれ、字形が無くて
3580 // 箱(☐)になっていた。
3581 //
3582 // 実測: `[FONTPICK] font awesome 7 free|bold=false|
3583 // hit=Some("font awesome 7 free|400")`
3584 font_bold: styled
3585 .value("font-weight")
3586 .map(|v| {
3587 let t = v.trim().to_lowercase();
3588 t == "bold"
3589 || t == "bolder"
3590 || t.parse::<u32>().unwrap_or(400) >= 700
3591 })
3592 .unwrap_or(false),
3593 font_italic: styled
3594 .value("font-style")
3595 .map(|v| {
3596 let t = v.trim().to_lowercase();
3597 t == "italic" || t == "oblique"
3598 })
3599 .unwrap_or(false),
3600 underline: false,
3601 line_through: false,
3602 is_video: false,
3603 video_src: String::new(),
3604 is_link: false,
3605 href: String::new(),
3606 is_hr: false,
3607 is_img: false,
3608 canvas_ctx_id,
3609 img_alt: String::new(),
3610 is_input: false,
3611 is_textarea: false,
3612 is_button: false,
3613 button_icon: 0,
3614 element_id: el_id.clone(),
3615 scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
3616 onclick: onclick.clone(),
3617 form_action: String::new(),
3618 form_method: String::new(),
3619 width: container_w,
3620 x_offset: container_x,
3621 y_offset: container_y,
3622 height: container_h,
3623 border_radius,
3624 is_li: tag_name == "li",
3625 li_num: None,
3626 is_blockquote: tag_name == "blockquote",
3627 border_top_width: layout.dimensions.border.top,
3628 border_right_width: layout.dimensions.border.right,
3629 border_bottom_width: layout.dimensions.border.bottom,
3630 border_left_width: layout.dimensions.border.left,
3631 padding_top: layout.dimensions.padding.top,
3632 padding_right: layout.dimensions.padding.right,
3633 padding_bottom: layout.dimensions.padding.bottom,
3634 padding_left: layout.dimensions.padding.left,
3635 bg_clip,
3636 bg_origin,
3637 bg_attachment,
3638 cursor_style,
3639 border_top_color,
3640 border_right_color,
3641 border_bottom_color,
3642 border_left_color,
3643 border_top_style,
3644 border_right_style,
3645 border_bottom_style,
3646 border_left_style,
3647 bg_image,
3648 bg_images,
3649 bg_size,
3650 bg_repeat_x,
3651 bg_repeat_y,
3652 bg_pos_x,
3653 bg_pos_y,
3654 background_blend_mode,
3655 bg_layers,
3656 outline_color,
3657 outline_width,
3658 outline_offset,
3659 outline_style,
3660 pos_top,
3661 pos_left,
3662 pos_right,
3663 pos_bottom,
3664 z_index: z_index_val,
3665 paint_depth: self.inherited_paint_depth,
3666 is_fixed: is_fixed_pos,
3667 is_sticky: is_sticky_pos,
3668 sticky_top: if is_sticky_pos { pos_top } else { None },
3669 sticky_bottom: if is_sticky_pos { pos_bottom } else { None },
3670 box_shadow,
3671 linear_gradient,
3672 radial_gradient,
3673 conic_gradient,
3674 repeating_linear_gradient,
3675 repeating_radial_gradient,
3676 opacity: opacity_val,
3677 visibility_hidden,
3678 text_shadow,
3679 before_content,
3680 after_content,
3681 font_family,
3682 before_font_family,
3683 transform_tx,
3684 transform_ty,
3685 transform_scale_x,
3686 transform_scale_y,
3687 transform_rotate_deg,
3688 transform_skew_x,
3689 transform_skew_y,
3690 transform_origin_x,
3691 transform_origin_y,
3692 // `mask-image` はフルサポート(url()による画像マスク等)は非対応だが、
3693 // `circle()`/`ellipse()`/`polygon()` 等の図形関数構文は `clip-path` と
3694 // 全く同じ `parse_clip_shape` を再利用してマスキングできる。`clip-path`
3695 // が無指定のときのみ `mask-image` にフォールバックする(`clip-path` が
3696 // 優先、両方指定されるケースは稀なため簡略化)。`mask-image:url(...)`
3697 // のような非対応構文は `parse_clip_shape` がどのプレフィックスにも
3698 // 一致せず `None` を返すため、単に無視されるだけで安全。
3699 clip_path: styled
3700 .value("clip-path")
3701 .or_else(|| styled.value("mask-image"))
3702 .unwrap_or_default(),
3703 ..Default::default()
3704 });
3705 }
3706
3707 // 子要素をフラット化
3708 // select は option 子要素を独自に収集済みなので通常フラット化をスキップ。
3709 let skip_children = is_button || is_input || is_textarea || is_select;
3710 if !skip_children {
3711 let is_ul = tag_name == "ul";
3712 let is_ol = tag_name == "ol";
3713 let mut child_list_state = if is_ul {
3714 Some((false, 0))
3715 } else if is_ol {
3716 Some((true, 0))
3717 } else {
3718 list_state
3719 };
3720
3721 // overflow クリップ: 子孫の生成インデックス範囲に、この要素の
3722 // content box を clip 矩形として後付けする(入れ子は交差)。
3723 let clip_rect_for_children: Option<(i32, i32, i32, i32)> =
3724 if overflow_clips {
3725 let c = &layout.dimensions.content;
3726 Some((c.x, c.y, c.x + c.width, c.y + c.height))
3727 } else {
3728 None
3729 };
3730 let clip_start_idx = self.elements.len();
3731
3732 // <label> の関連付け先 id を算出(for 属性優先、無ければ内包 input)。
3733 // この label のサブツリーで生成された要素に後付けし、テキストクリックで
3734 // 該当 input をトグル/フォーカスできるようにする。
3735 let label_target: Option<String> = if tag_name == "label" {
3736 attributes
3737 .get("for")
3738 .cloned()
3739 .filter(|s| !s.is_empty())
3740 .or_else(|| find_labelable_descendant_id(layout))
3741 } else {
3742 None
3743 };
3744
3745 for child in &layout.children {
3746 let is_child_li = match &child.box_type {
3747 crate::os_lib::layout::BoxType::BlockNode(styled_child)
3748 | crate::os_lib::layout::BoxType::InlineNode(styled_child)
3749 | crate::os_lib::layout::BoxType::InlineBlockNode(
3750 styled_child,
3751 ) => match &styled_child.node.node_type {
3752 crate::os_lib::dom::NodeType::Element {
3753 tag_name: child_tag,
3754 ..
3755 } => child_tag == "li",
3756 _ => false,
3757 },
3758 _ => false,
3759 };
3760
3761 if is_child_li {
3762 if let Some((is_ol, count)) = child_list_state {
3763 child_list_state = Some((is_ol, count + 1));
3764 }
3765 }
3766
3767 // 子孫は自分の実効 z を祖先値として引き継ぐ。
3768 let saved_paint_z = self.inherited_paint_z;
3769 let saved_paint_depth = self.inherited_paint_depth;
3770 self.inherited_paint_z = z_index_val;
3771 self.inherited_paint_depth =
3772 self.inherited_paint_depth.saturating_add(1);
3773 self.flatten_layout(
3774 child,
3775 &next_form_action,
3776 &next_form_method,
3777 &next_href,
3778 next_font_bold,
3779 next_font_italic,
3780 next_underline,
3781 next_line_through,
3782 next_color,
3783 next_font_size,
3784 &next_parent_id,
3785 child_list_state,
3786 tag_name,
3787 next_decoration_color,
3788 next_decoration_style.clone(),
3789 next_decoration_thickness,
3790 next_decoration_underline_offset,
3791 next_overline,
3792 next_pointer_events_none,
3793 );
3794 self.inherited_paint_z = saved_paint_z;
3795 self.inherited_paint_depth = saved_paint_depth;
3796 }
3797
3798 // 【2026-09-26 バグ修正】`position:fixed` の子孫も画面に固定する。
3799 // 従来は固定要素自身にしか `is_fixed` を立てず、子孫(文字・画像)は
3800 // ページと一緒にスクロールしていた。www.sugi-lab.net の固定ヘッダで、
3801 // スクロールすると背景だけ残りロゴとナビの文字が消えていた
3802 // (QEMU の実画面撮影で発見)。
3803 if is_fixed_pos {
3804 for el in &mut self.elements[clip_start_idx..] {
3805 el.is_fixed = true;
3806 }
3807 }
3808
3809 // 生成された子孫要素にクリップ矩形を適用(既存 clip と交差)。
3810 // 軸ごとに独立して適用する: `overflow-x`/`overflow-y` の一方だけが
3811 // clip 対象の場合、クリップされない軸の境界は据え置く(両軸まとめて
3812 // 適用すると、クリップ不要な軸まで誤ってクリップされてしまうため)。
3813 if let Some((cx0, cy0, cx1, cy1)) = clip_rect_for_children {
3814 for i in clip_start_idx..self.elements.len() {
3815 let el = &mut self.elements[i];
3816 el.overflow_hidden = true;
3817 if x_clips {
3818 el.clip_x0 = el.clip_x0.max(cx0);
3819 el.clip_x1 = el.clip_x1.min(cx1);
3820 }
3821 if y_clips {
3822 el.clip_y0 = el.clip_y0.max(cy0);
3823 el.clip_y1 = el.clip_y1.min(cy1);
3824 }
3825 }
3826 }
3827
3828 // overflow:scroll/auto → サブスクロールコンテナとして子孫に ID を付与。
3829 // 内側に入れ子のスクロールコンテナが存在する子孫は上書きしない
3830 // (既に内側コンテナの ID が設定済みのため)。
3831 if is_scroll_container {
3832 // element_id が空の場合は自動生成(位置ベースの仮 ID)。
3833 let cid = if el_id.is_empty() {
3834 let c = &layout.dimensions.content;
3835 alloc::format!("_sc_{}_{}", c.x, c.y)
3836 } else {
3837 el_id.clone()
3838 };
3839 // コンテナ高さを超えるコンテンツ量を scroll_max_y として算出。
3840 let c = &layout.dimensions.content;
3841 let container_bottom = c.y + c.height;
3842 let content_bottom = self.elements[clip_start_idx..]
3843 .iter()
3844 .map(|e| e.y_offset + e.height)
3845 .max()
3846 .unwrap_or(container_bottom);
3847 let scroll_max = (content_bottom - container_bottom).max(0);
3848 // scroll_regions に初期エントリを作る(存在しなければ 0)。
3849 self.scroll_regions.entry(cid.clone()).or_insert(0);
3850 // スクロールバー描画・ホバー判定用のメタ情報を登録。
3851 self.scroll_containers.push((
3852 cid.clone(),
3853 scroll_max,
3854 c.x,
3855 c.y,
3856 c.width,
3857 c.height,
3858 ));
3859 // 子孫に scroll_container_id を付与(内側コンテナ済みは除く)。
3860 for i in clip_start_idx..self.elements.len() {
3861 let el = &mut self.elements[i];
3862 if el.scroll_container_id.is_empty() {
3863 el.scroll_container_id = cid.clone();
3864 }
3865 }
3866 }
3867
3868 // <label> サブツリーで生成された要素に関連付け先を付与
3869 // (内包 input 自体は除外: 自前のトグル動作を持つため)。
3870 if let Some(target) = label_target {
3871 for i in clip_start_idx..self.elements.len() {
3872 let el = &mut self.elements[i];
3873 if el.label_for.is_empty()
3874 && el.input_kind == 0
3875 && !el.is_input
3876 && !el.is_textarea
3877 {
3878 el.label_for = target.clone();
3879 }
3880 }
3881 }
3882 }
3883
3884 if is_hr {
3885 self.elements.push(RenderElement {
3886 text: String::new(),
3887 font_size: 16,
3888 color: 0xFF44475A,
3889 bg_color: None,
3890 hover_bg_color: extract_hover_color_alt(
3891 styled,
3892 "background-color",
3893 "background",
3894 ),
3895 hover_color: extract_hover_color(styled, "color"),
3896 hover_border_color: extract_hover_color(styled, "border-color"),
3897 filter: styled.value("filter").unwrap_or_default(),
3898 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
3899 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
3900 image_rendering: styled.value("image-rendering").unwrap_or_default(),
3901 text_stroke_width: extract_text_stroke(styled).0,
3902 text_stroke_color: extract_text_stroke(styled).1,
3903 marker_color: extract_marker_color(styled),
3904 marker_font_size: extract_marker_font_size(styled),
3905 marker_content: extract_marker_content(styled),
3906 focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
3907 focus_color: extract_focus_color(styled, "color"),
3908 focus_border_color: extract_focus_color(styled, "border-color"),
3909 active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
3910 active_color: extract_active_color(styled, "color"),
3911 active_border_color: extract_active_color(styled, "border-color"),
3912 border_color: None,
3913 border_style: super::BorderStyle::Solid,
3914 font_bold: false,
3915 underline: false,
3916 is_video: false,
3917 video_src: String::new(),
3918 is_link: false,
3919 href: String::new(),
3920 is_hr: true,
3921 is_img: false,
3922 canvas_ctx_id,
3923 img_alt: String::new(),
3924 is_input: false,
3925 is_textarea: false,
3926 is_button: false,
3927 button_icon: 0,
3928 element_id: String::new(),
3929 onclick: String::new(),
3930 form_action: String::new(),
3931 form_method: String::new(),
3932 width: layout.dimensions.content.width,
3933 x_offset: layout.dimensions.content.x,
3934 y_offset: layout.dimensions.content.y,
3935 height: match aspect_ratio_val {
3936 Some(ratio) if layout.dimensions.content.height <= 0 => {
3937 (layout.dimensions.content.width as f32 / ratio) as i32
3938 }
3939 _ => layout.dimensions.content.height,
3940 },
3941 aspect_ratio: aspect_ratio_val,
3942 border_radius: (0, 0, 0, 0),
3943 is_li: false,
3944 li_num: None,
3945 is_blockquote: false,
3946 z_index: self.inherited_paint_z,
3947 paint_depth: self.inherited_paint_depth,
3948 ..Default::default()
3949 });
3950 } else if is_img {
3951 let alt = attributes
3952 .get("alt")
3953 .cloned()
3954 .unwrap_or(String::from("Image"));
3955 let src = attributes.get("src").cloned().unwrap_or(String::new());
3956
3957 self.elements.push(RenderElement {
3958 text: String::new(),
3959 font_size: 16,
3960 color: 0xFFF8F8F2,
3961 bg_color: None,
3962 hover_bg_color: extract_hover_color_alt(
3963 styled,
3964 "background-color",
3965 "background",
3966 ),
3967 hover_color: extract_hover_color(styled, "color"),
3968 hover_border_color: extract_hover_color(styled, "border-color"),
3969 filter: styled.value("filter").unwrap_or_default(),
3970 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
3971 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
3972 image_rendering: styled.value("image-rendering").unwrap_or_default(),
3973 text_stroke_width: extract_text_stroke(styled).0,
3974 text_stroke_color: extract_text_stroke(styled).1,
3975 marker_color: extract_marker_color(styled),
3976 marker_font_size: extract_marker_font_size(styled),
3977 marker_content: extract_marker_content(styled),
3978 focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
3979 focus_color: extract_focus_color(styled, "color"),
3980 focus_border_color: extract_focus_color(styled, "border-color"),
3981 active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
3982 active_color: extract_active_color(styled, "color"),
3983 active_border_color: extract_active_color(styled, "border-color"),
3984 border_color: extract_border_color(styled, 0xFF44475A),
3985 border_style: extract_border_style(styled),
3986 font_bold: false,
3987 underline: false,
3988 is_video: false,
3989 video_src: src,
3990 is_link: false,
3991 href: String::new(),
3992 is_hr: false,
3993 is_img: true,
3994 img_lazy: attributes
3995 .get("loading")
3996 .is_some_and(|v| v.trim().eq_ignore_ascii_case("lazy")),
3997 canvas_ctx_id,
3998 img_alt: alt,
3999 is_input: false,
4000 is_textarea: false,
4001 is_button: false,
4002 button_icon: 0,
4003 element_id: el_id,
4004 scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
4005 onclick,
4006 form_action: String::new(),
4007 form_method: String::new(),
4008 width: layout.dimensions.content.width,
4009 x_offset: layout.dimensions.content.x,
4010 y_offset: layout.dimensions.content.y,
4011 height: layout.dimensions.content.height,
4012 border_radius,
4013 is_li: false,
4014 li_num: None,
4015 is_blockquote: false,
4016 // `mask-image` はフルサポート(url()による画像マスク等)は非対応だが、
4017 // `circle()`/`ellipse()`/`polygon()` 等の図形関数構文は `clip-path` と
4018 // 全く同じ `parse_clip_shape` を再利用してマスキングできる。`clip-path`
4019 // が無指定のときのみ `mask-image` にフォールバックする(`clip-path` が
4020 // 優先、両方指定されるケースは稀なため簡略化)。`mask-image:url(...)`
4021 // のような非対応構文は `parse_clip_shape` がどのプレフィックスにも
4022 // 一致せず `None` を返すため、単に無視されるだけで安全。
4023 clip_path: styled
4024 .value("clip-path")
4025 .or_else(|| styled.value("mask-image"))
4026 .unwrap_or_default(),
4027 object_fit: parse_object_fit(styled.value("object-fit").as_deref()),
4028 object_pos_x: parse_object_position(
4029 styled.value("object-position").as_deref(),
4030 )
4031 .0,
4032 object_pos_y: parse_object_position(
4033 styled.value("object-position").as_deref(),
4034 )
4035 .1,
4036 z_index: self.inherited_paint_z,
4037 paint_depth: self.inherited_paint_depth,
4038 ..Default::default()
4039 });
4040 } else if is_video {
4041 let src = attributes.get("src").cloned().unwrap_or(String::new());
4042 self.elements.push(RenderElement {
4043 text: String::new(),
4044 font_size: 16,
4045 color: 0xFFF8F8F2,
4046 bg_color: None,
4047 hover_bg_color: extract_hover_color_alt(
4048 styled,
4049 "background-color",
4050 "background",
4051 ),
4052 hover_color: extract_hover_color(styled, "color"),
4053 hover_border_color: extract_hover_color(styled, "border-color"),
4054 filter: styled.value("filter").unwrap_or_default(),
4055 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
4056 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
4057 image_rendering: styled.value("image-rendering").unwrap_or_default(),
4058 text_stroke_width: extract_text_stroke(styled).0,
4059 text_stroke_color: extract_text_stroke(styled).1,
4060 marker_color: extract_marker_color(styled),
4061 marker_font_size: extract_marker_font_size(styled),
4062 marker_content: extract_marker_content(styled),
4063 focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
4064 focus_color: extract_focus_color(styled, "color"),
4065 focus_border_color: extract_focus_color(styled, "border-color"),
4066 active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
4067 active_color: extract_active_color(styled, "color"),
4068 active_border_color: extract_active_color(styled, "border-color"),
4069 border_color: extract_border_color(styled, 0xFF6272A4),
4070 border_style: extract_border_style(styled),
4071 font_bold: false,
4072 underline: false,
4073 is_video: true,
4074 video_src: src,
4075 is_link: false,
4076 href: String::new(),
4077 is_hr: false,
4078 is_img: false,
4079 canvas_ctx_id,
4080 img_alt: String::new(),
4081 is_input: false,
4082 is_textarea: false,
4083 is_button: false,
4084 button_icon: 0,
4085 element_id: el_id,
4086 scroll_margin_top: styled.value("scroll-margin-top").map(|v| parse_px_val(&v)).unwrap_or(0),
4087 onclick,
4088 form_action: String::new(),
4089 form_method: String::new(),
4090 width: layout.dimensions.content.width,
4091 x_offset: layout.dimensions.content.x,
4092 y_offset: layout.dimensions.content.y,
4093 height: layout.dimensions.content.height,
4094 border_radius,
4095 is_li: false,
4096 li_num: None,
4097 is_blockquote: false,
4098 z_index: self.inherited_paint_z,
4099 paint_depth: self.inherited_paint_depth,
4100 ..Default::default()
4101 });
4102 } else if is_progress {
4103 // input_kind=4 として checkbox/radio/select と同じ仕組みに便乗し、
4104 // 比率(0.0..1.0)の文字列表現を input_value に格納する
4105 // (RenderElement に新規フィールドを増やさない簡略実装)。
4106 self.elements.push(RenderElement {
4107 text: String::new(),
4108 font_size: 16,
4109 color: 0xFFF8F8F2,
4110 bg_color: None,
4111 hover_bg_color: extract_hover_color_alt(
4112 styled,
4113 "background-color",
4114 "background",
4115 ),
4116 hover_color: extract_hover_color(styled, "color"),
4117 hover_border_color: extract_hover_color(styled, "border-color"),
4118 filter: styled.value("filter").unwrap_or_default(),
4119 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
4120 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
4121 image_rendering: styled.value("image-rendering").unwrap_or_default(),
4122 text_stroke_width: extract_text_stroke(styled).0,
4123 text_stroke_color: extract_text_stroke(styled).1,
4124 border_color: extract_border_color(styled, 0xFF6272A4),
4125 border_style: extract_border_style(styled),
4126 is_video: false,
4127 video_src: String::new(),
4128 is_link: false,
4129 href: String::new(),
4130 is_hr: false,
4131 is_img: false,
4132 canvas_ctx_id,
4133 img_alt: String::new(),
4134 is_input: false,
4135 is_textarea: false,
4136 is_button: false,
4137 input_kind: 4,
4138 input_value: alloc::format!("{}", progress_ratio),
4139 element_id: el_id,
4140 onclick,
4141 width: layout.dimensions.content.width,
4142 x_offset: layout.dimensions.content.x,
4143 y_offset: layout.dimensions.content.y,
4144 height: layout.dimensions.content.height,
4145 border_radius,
4146 z_index: self.inherited_paint_z,
4147 paint_depth: self.inherited_paint_depth,
4148 ..Default::default()
4149 });
4150 } else if tag_name == "iframe" {
4151 let src = attributes.get("src").cloned().unwrap_or(String::new());
4152 let width = layout.dimensions.content.width;
4153 let height = layout.dimensions.content.height;
4154 self.elements.push(RenderElement {
4155 is_iframe: true,
4156 iframe_src: src,
4157 width,
4158 height,
4159 x_offset: layout.dimensions.content.x,
4160 y_offset: layout.dimensions.content.y,
4161 border_color: extract_border_color(styled, 0xFF6272A4),
4162 border_style: extract_border_style(styled),
4163 border_radius,
4164 element_id: el_id,
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 // 【2026-09-26】white-space に従って空白を畳む(normal/nowrap では
4452 // 改行・タブを含む連続を空白 1 つに)。従来は畳まずにタブ展開までしており、
4453 // HTML の字下げが行頭の大きな空白になって文を押し出していた。
4454 // タブ展開(下)は pre 系で残ったタブにだけ効く。
4455 {
4456 let ws_mode = styled.value("white-space").unwrap_or_default();
4457 display_text = crate::os_lib::layout::line_break::collapse_white_space(
4458 &display_text,
4459 &ws_mode,
4460 );
4461 }
4462 // tab-size: タブ文字(U+0009)を N 個の半角スペースへ展開する
4463 // (実ブラウザのようなタブ位置ストップ計算はせず、単純な固定幅展開)。
4464 if display_text.contains('\t') {
4465 let tab_size = styled
4466 .value("tab-size")
4467 .and_then(|v| v.trim().parse::<usize>().ok())
4468 .filter(|n| *n > 0)
4469 .unwrap_or(8)
4470 .min(64);
4471 display_text = display_text.replace('\t', &" ".repeat(tab_size));
4472 }
4473 let is_link = !current_href.is_empty();
4474 let (default_text_color, default_link_color) = self.default_text_colors();
4475 let default_color = if is_link {
4476 default_link_color
4477 } else {
4478 default_text_color
4479 };
4480 let mut final_color = current_color.unwrap_or(default_color);
4481 if is_link {
4482 final_color = default_link_color;
4483 }
4484 let mut final_bg = None;
4485 // 【2026-08-03】テキスト要素へ `font-family` を渡す。
4486 //
4487 // `RenderElement` の生成箇所は 12 あるが、
4488 // `font_family` を設定していたのは 2 箇所だけで、
4489 // **本文テキストの要素は空のまま**だった。
4490 // そのため Web フォントを取得・登録できても
4491 // 描画で選ばれなかった(実測: 使われたのは
4492 // アイコンフォントだけ)。
4493 //
4494 // `font-family` は継承プロパティなので、カスケード済みの
4495 // `styled` から引けば子要素でも解決済みの値が得られる。
4496 let final_font_family = styled
4497 .value("font-family")
4498 .map(|v| String::from(v.trim()))
4499 .unwrap_or_default();
4500 let mut final_size = current_font_size;
4501 let mut final_bold = current_font_bold;
4502 let mut final_italic = current_font_italic;
4503 let mut final_underline = current_underline;
4504 let mut final_line_through = current_line_through;
4505 let mut final_overline = current_overline;
4506 let mut final_decoration_color = current_decoration_color;
4507 let mut final_decoration_style = current_decoration_style.clone();
4508 let mut final_decoration_thickness = current_decoration_thickness;
4509 let mut final_decoration_underline_offset = current_decoration_underline_offset;
4510 if let Some(c_str) = styled.specified_values.get("color") {
4511 if let Some(c_val) = parse_color(c_str) {
4512 final_color = c_val;
4513 }
4514 }
4515 if let Some(bg_val) =
4516 extract_color(styled, "background-color", "background")
4517 {
4518 final_bg = Some(bg_val);
4519 }
4520 if let Some(sz_str) = styled.specified_values.get("font-size") {
4521 if let Some(sz_val) = parse_font_size(sz_str) {
4522 final_size = sz_val;
4523 }
4524 }
4525 if let Some(fw_str) = styled.specified_values.get("font-weight") {
4526 let fw_l = fw_str.trim().to_lowercase();
4527 if fw_l == "bold"
4528 || fw_l == "bolder"
4529 || fw_l.parse::<u32>().unwrap_or(400) >= 700
4530 {
4531 final_bold = true;
4532 }
4533 }
4534 // letter-spacing/word-spacing の calc() 解決用の基準幅(% 解決の基準。
4535 // letter-spacing/word-spacing は通常 % を取らないため主に
4536 // `calc(1px + 1px)` 等の用途)。この時点ではまだ available_w が
4537 // 未計算のため、同じフォールバック規則をここでも使う。
4538 let ls_ws_base_w = if layout.dimensions.content.width > 0 {
4539 layout.dimensions.content.width
4540 } else {
4541 800
4542 };
4543 // letter-spacing(文字間 px、負も可)。normal/未指定/不正は 0。
4544 // calc()/min()/max()/clamp() にも対応(os_lib::layout の段落高さ計測側
4545 // と同期させる必要があるため同じ規則を使う)。
4546 let final_ls = match styled.value("letter-spacing") {
4547 None => 0i32,
4548 Some(s) => {
4549 let t = s.trim();
4550 if t.is_empty() || t.eq_ignore_ascii_case("normal") {
4551 0
4552 } else if t.starts_with("calc(")
4553 || t.starts_with("min(")
4554 || t.starts_with("max(")
4555 || t.starts_with("clamp(")
4556 {
4557 crate::os_lib::css::eval_css_math(t, ls_ws_base_w).unwrap_or(0)
4558 } else if let Some(num) = t.strip_suffix("px") {
4559 num.trim()
4560 .parse::<f32>()
4561 .ok()
4562 .map(|f| f as i32)
4563 .unwrap_or(0)
4564 } else {
4565 t.parse::<f32>().ok().map(|f| f as i32).unwrap_or(0)
4566 }
4567 }
4568 };
4569 // word-spacing(半角スペース1文字ごとに加える px、負も可)。normal/未指定/不正は 0。
4570 let final_ws = match styled.value("word-spacing") {
4571 None => 0i32,
4572 Some(s) => {
4573 let t = s.trim();
4574 if t.is_empty() || t.eq_ignore_ascii_case("normal") {
4575 0
4576 } else if t.starts_with("calc(")
4577 || t.starts_with("min(")
4578 || t.starts_with("max(")
4579 || t.starts_with("clamp(")
4580 {
4581 crate::os_lib::css::eval_css_math(t, ls_ws_base_w).unwrap_or(0)
4582 } else if let Some(num) = t.strip_suffix("px") {
4583 num.trim()
4584 .parse::<f32>()
4585 .ok()
4586 .map(|f| f as i32)
4587 .unwrap_or(0)
4588 } else {
4589 t.parse::<f32>().ok().map(|f| f as i32).unwrap_or(0)
4590 }
4591 }
4592 };
4593 // text-emphasis-style: "none"/未指定/空は無し。クォート付きカスタム
4594 // マークはクォートを剥がして1文字目だけを使う簡略実装。
4595 let final_emphasis_style = match styled.value("text-emphasis-style") {
4596 None => String::new(),
4597 Some(s) => {
4598 let t = s.trim();
4599 if t.is_empty() || t.eq_ignore_ascii_case("none") {
4600 String::new()
4601 } else {
4602 String::from(t.trim_matches(|c| c == '"' || c == '\''))
4603 }
4604 }
4605 };
4606 let final_emphasis_color = styled
4607 .value("text-emphasis-color")
4608 .and_then(|s| crate::os_lib::css::parse_color(&s));
4609 if let Some(fs_str) = styled.specified_values.get("font-style") {
4610 let fs_l = fs_str.trim().to_lowercase();
4611 if fs_l == "italic" || fs_l == "oblique" {
4612 final_italic = true;
4613 } else if fs_l == "normal" {
4614 final_italic = false;
4615 }
4616 }
4617 if let Some(td_str) = styled.specified_values.get("text-decoration") {
4618 let (u, lt, ov, is_none, style, color) =
4619 parse_text_decoration_shorthand(td_str);
4620 if is_none {
4621 final_underline = false;
4622 final_line_through = false;
4623 final_overline = false;
4624 } else {
4625 if u {
4626 final_underline = true;
4627 }
4628 if lt {
4629 final_line_through = true;
4630 }
4631 if ov {
4632 final_overline = true;
4633 }
4634 if let Some(s) = style {
4635 final_decoration_style = s;
4636 }
4637 if let Some(c) = color {
4638 final_decoration_color = Some(c);
4639 }
4640 }
4641 }
4642 if let Some(tdl) = styled.specified_values.get("text-decoration-line") {
4643 let (u, lt, ov, is_none, _, _) = parse_text_decoration_shorthand(tdl);
4644 if is_none {
4645 final_underline = false;
4646 final_line_through = false;
4647 final_overline = false;
4648 } else {
4649 if u {
4650 final_underline = true;
4651 }
4652 if lt {
4653 final_line_through = true;
4654 }
4655 if ov {
4656 final_overline = true;
4657 }
4658 }
4659 }
4660 if let Some(dc) = styled.specified_values.get("text-decoration-color") {
4661 if let Some(c) = parse_color(dc.trim()) {
4662 final_decoration_color = Some(c);
4663 }
4664 }
4665 if let Some(ds) = styled.specified_values.get("text-decoration-style") {
4666 let ds_l = ds.trim().to_lowercase();
4667 if matches!(ds_l.as_str(), "solid" | "dotted" | "dashed" | "wavy" | "double") {
4668 final_decoration_style = ds_l;
4669 }
4670 }
4671 if let Some(dt) = styled.specified_values.get("text-decoration-thickness") {
4672 let dt_t = dt.trim();
4673 if !dt_t.eq_ignore_ascii_case("auto") && !dt_t.eq_ignore_ascii_case("from-font") {
4674 final_decoration_thickness = parse_px_val(dt_t).max(1);
4675 }
4676 }
4677 if let Some(uo) = styled.specified_values.get("text-underline-offset") {
4678 let uo_t = uo.trim();
4679 if !uo_t.eq_ignore_ascii_case("auto") {
4680 final_decoration_underline_offset = parse_px_val(uo_t);
4681 }
4682 }
4683
4684 let is_actual_link = is_link;
4685 let link_href = String::from(current_href);
4686
4687 // text-indent: このテキストノードの最初の行だけを右へずらす簡易実装。
4688 // 折返し幅(available_w)自体は変更しないため、vector_font 側の行数
4689 // 計算との同期は崩れない(インデント分だけ最初の行の右端が浅く狭まる
4690 // 近似になるが、実用上の見た目への影響は小さい)。`%`/`em`が丸ごと
4691 // 未対応だったバグの詳細は`resolve_text_indent`のdocコメント参照。
4692 let text_indent_px: i32 = styled
4693 .value("text-indent")
4694 .map(|s| resolve_text_indent(&s, layout.dimensions.content.width, final_size))
4695 .unwrap_or(0);
4696
4697 // ::first-line: このテキストノードの最初の行(line_index==0)にのみ
4698 // color/font-size/font-weight/font-style を上書きする簡易実装。
4699 // RenderElement は既に行ごとに分割されているため、::before/::after と
4700 // 同じ合成プロパティ経由の値を line_index==0 の時だけ適用すればよい。
4701 let fl_color = styled
4702 .value("x-first-line-color")
4703 .and_then(|s| parse_color(&s));
4704 let fl_size = styled
4705 .value("x-first-line-font-size")
4706 .and_then(|s| parse_font_size(&s));
4707 let fl_bold = styled.value("x-first-line-font-weight").map(|s| {
4708 let l = s.trim().to_lowercase();
4709 l == "bold" || l == "bolder" || l.parse::<u32>().unwrap_or(400) >= 700
4710 });
4711 let fl_italic = styled.value("x-first-line-font-style").map(|s| {
4712 let l = s.trim().to_lowercase();
4713 l == "italic" || l == "oblique"
4714 });
4715
4716 // ::first-letter: このテキストノードの最初の1文字だけを別の RenderElement
4717 // として切り出し、color/font-size/font-weight/font-style を上書きする。
4718 let flt_color = styled
4719 .value("x-first-letter-color")
4720 .and_then(|s| parse_color(&s));
4721 let flt_size = styled
4722 .value("x-first-letter-font-size")
4723 .and_then(|s| parse_font_size(&s));
4724 let flt_bold = styled.value("x-first-letter-font-weight").map(|s| {
4725 let l = s.trim().to_lowercase();
4726 l == "bold" || l == "bolder" || l.parse::<u32>().unwrap_or(400) >= 700
4727 });
4728 let flt_italic = styled.value("x-first-letter-font-style").map(|s| {
4729 let l = s.trim().to_lowercase();
4730 l == "italic" || l == "oblique"
4731 });
4732 let has_first_letter_override = flt_color.is_some()
4733 || flt_size.is_some()
4734 || flt_bold.is_some()
4735 || flt_italic.is_some();
4736
4737 // white-space / text-overflow / word-break from this node's own CSS
4738 let node_white_space_nowrap = styled
4739 .specified_values
4740 .get("white-space")
4741 .map(|v| v.trim() == "nowrap")
4742 .unwrap_or(false);
4743 let node_white_space_pre_wrap = styled
4744 .specified_values
4745 .get("white-space")
4746 .map(|v| v.trim() == "pre-wrap" || v.trim() == "pre-line")
4747 .unwrap_or(false);
4748 let node_word_break_all = styled
4749 .value("word-break")
4750 .map(|v| v.trim() == "break-all" || v.trim() == "break-word")
4751 .unwrap_or(false)
4752 || styled
4753 .value("overflow-wrap")
4754 .map(|v| v.trim() == "break-word" || v.trim() == "anywhere")
4755 .unwrap_or(false)
4756 || styled
4757 .value("word-wrap")
4758 .map(|v| v.trim() == "break-word" || v.trim() == "anywhere")
4759 .unwrap_or(false);
4760 let node_text_overflow_ellipsis = styled
4761 .specified_values
4762 .get("text-overflow")
4763 .map(|v| v.trim() == "ellipsis")
4764 .unwrap_or(false);
4765 // `text-overflow:ellipsis` は `direction:rtl` では省略記号を先頭(視覚上の
4766 // 左)に出し、末尾ではなく先頭側の文字を切り詰めるべき(CSS仕様)。
4767 // 以前はこの区別が無く、rtl でも常に末尾を切り詰め末尾に "…" を
4768 // 付けていたため、rtl テキストの省略位置が仕様と逆になっていた。
4769 let node_is_rtl = styled
4770 .value("direction")
4771 .map(|v| v.trim().eq_ignore_ascii_case("rtl"))
4772 .unwrap_or(false);
4773
4774 if !display_text.trim().is_empty() {
4775 let available_w = if layout.dimensions.content.width > 0 {
4776 layout.dimensions.content.width
4777 } else {
4778 800
4779 };
4780 let mut current_line = String::new();
4781 let mut current_line_w = 0i32;
4782
4783 // line-height の解釈
4784 let mut line_height = (final_size + 4) as i32;
4785 if let Some(lh_str) = styled.value("line-height") {
4786 line_height = parse_line_height(&lh_str, final_size);
4787 }
4788
4789 // white-space: nowrap — emit entire text as one RenderElement
4790 if node_white_space_nowrap {
4791 let text_w = crate::kernel::vector_font::get_vector_string_width_ls_ws(
4792 &display_text,
4793 final_size,
4794 final_ls,
4795 final_ws,
4796 ) as i32;
4797 let line_y = layout.dimensions.content.y;
4798 self.elements.push(RenderElement {
4799 text: display_text.clone(),
4800 font_family: final_font_family.clone(),
4801 font_size: fl_size.unwrap_or(final_size),
4802 letter_spacing: final_ls,
4803 word_spacing: final_ws,
4804 text_emphasis_style: final_emphasis_style.clone(),
4805 text_emphasis_color: final_emphasis_color,
4806 color: fl_color.unwrap_or(final_color),
4807 bg_color: final_bg,
4808 font_bold: fl_bold.unwrap_or(final_bold),
4809 font_italic: fl_italic.unwrap_or(final_italic),
4810 underline: final_underline,
4811 line_through: final_line_through,
4812 overline: final_overline,
4813 decoration_color: final_decoration_color,
4814 decoration_style: final_decoration_style.clone(),
4815 decoration_thickness: final_decoration_thickness,
4816 decoration_underline_offset: final_decoration_underline_offset,
4817 is_link: is_actual_link,
4818 pointer_events_none: current_pointer_events_none,
4819 href: link_href.clone(),
4820 width: text_w,
4821 x_offset: layout.dimensions.content.x,
4822 y_offset: line_y,
4823 height: line_height,
4824 white_space_nowrap: true,
4825 text_overflow_ellipsis: node_text_overflow_ellipsis,
4826 text_overflow_rtl: node_is_rtl,
4827 z_index: self.inherited_paint_z,
4828 paint_depth: self.inherited_paint_depth,
4829 ..Default::default()
4830 });
4831 if is_actual_link {
4832 self.links.push(HyperlinkArea {
4833 href: link_href.clone(),
4834 x0: layout.dimensions.content.x,
4835 y0: line_y,
4836 x1: layout.dimensions.content.x + text_w,
4837 y1: line_y + line_height,
4838 });
4839 }
4840 // skip normal text rendering
4841 } else {
4842 // 重要: この折返し規則 ('\n' 強制改行・行頭/行末禁則・改行時に
4843 // 先頭文字を即消費) は vector_font::get_string_wrapped_size と
4844 // 完全に同一でなければならない。ズレると layout の段落高さと
4845 // 行数が食い違い <hr> 等が重なる。変更時は必ず両方を同期すること。
4846 let mut line_index = 0;
4847 let line_clamp_max: Option<usize> = styled
4848 .value("-webkit-line-clamp")
4849 .or_else(|| styled.value("line-clamp"))
4850 .and_then(|s| s.trim().parse::<usize>().ok());
4851 let mut prev_char: Option<char> = None;
4852 let mut prev_advance = 0i32;
4853
4854 // ::first-letter: 先頭1文字を別 RenderElement として切り出し、
4855 // 残りのテキストは通常どおり折返す(先頭文字ぶんだけ x をずらす)。
4856 // 注意: 折返し判定に使う文字列から1文字減るため、極めて稀に
4857 // os_lib::layout 側の段落高さ計算(元の文字列全体を使う)と
4858 // 改行位置が1文字ぶんずれる可能性がある簡略実装(text-indent と同種の近似)。
4859 let mut first_letter_x_shift = 0i32;
4860 let first_letter_iter_buf;
4861 // CSS の ::first-letter は前置の句読点・引用符(例: `"Hello`
4862 // の `"H`、`«Bonjour»` の `«B`)を実際の最初の文字と一体で扱う
4863 // 仕様(CSS Pseudo-Elements §3.2)のため、先に句読点類を読み飛ばして
4864 // fc_str へ蓄積してから実際の最初の文字を1つ加える。
4865 fn is_first_letter_leading_punct(c: char) -> bool {
4866 matches!(
4867 c,
4868 '!' | '"' | '#' | '$' | '%' | '&' | '\'' | '(' | ')'
4869 | '*' | '+' | ',' | '-' | '.' | '/' | ':' | ';'
4870 | '<' | '=' | '>' | '?' | '@' | '[' | '\\' | ']'
4871 | '^' | '_' | '`' | '{' | '|' | '}' | '~'
4872 | '¡' | '¿' | '«' | '»'
4873 | '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}'
4874 )
4875 }
4876 let wrap_text: &str = if has_first_letter_override {
4877 let mut chars_iter = display_text.chars();
4878 let mut fc_str = String::new();
4879 loop {
4880 let mut probe = chars_iter.clone();
4881 match probe.next() {
4882 Some(c) if is_first_letter_leading_punct(c) => {
4883 fc_str.push(c);
4884 chars_iter = probe;
4885 }
4886 _ => break,
4887 }
4888 }
4889 if let Some(fc) = chars_iter.next() {
4890 let fc_size = flt_size.unwrap_or(final_size);
4891 let fc_color = flt_color.unwrap_or(final_color);
4892 let fc_bold = flt_bold.unwrap_or(final_bold);
4893 let fc_italic = flt_italic.unwrap_or(final_italic);
4894 fc_str.push(fc);
4895 let fc_w = crate::kernel::vector_font::get_vector_string_width(
4896 &fc_str, fc_size,
4897 ) as i32;
4898 let line_y = layout.dimensions.content.y;
4899 self.elements.push(RenderElement {
4900 text: fc_str,
4901 font_family: final_font_family.clone(),
4902 font_size: fc_size,
4903 color: fc_color,
4904 bg_color: final_bg,
4905 font_bold: fc_bold,
4906 font_italic: fc_italic,
4907 underline: final_underline,
4908 line_through: final_line_through,
4909 overline: final_overline,
4910 decoration_color: final_decoration_color,
4911 decoration_style: final_decoration_style.clone(),
4912 decoration_thickness: final_decoration_thickness,
4913 decoration_underline_offset: final_decoration_underline_offset,
4914 is_link: is_actual_link,
4915 pointer_events_none: current_pointer_events_none,
4916 href: link_href.clone(),
4917 width: fc_w,
4918 x_offset: layout.dimensions.content.x + text_indent_px,
4919 y_offset: line_y,
4920 height: line_height,
4921 z_index: self.inherited_paint_z,
4922 paint_depth: self.inherited_paint_depth,
4923 ..Default::default()
4924 });
4925 first_letter_x_shift = fc_w;
4926 first_letter_iter_buf = String::from(chars_iter.as_str());
4927 &first_letter_iter_buf
4928 } else {
4929 display_text.as_str()
4930 }
4931 } else {
4932 display_text.as_str()
4933 };
4934
4935 // 【2026-09-26】折り返し位置は計測側と共通の純粋関数
4936 // `line_break::break_positions` で先に決める(単語ごと次の行へ送る・
4937 // 禁則・行末空白のぶら下げ)。字幅も描画と同じ書体で 1 回だけ測り、
4938 // ループではその値を使う。従来は単語を送らずはみ出させており、
4939 // 英語の文献リストで窓からはみ出した分が描画時に勝手に改行されて重なった。
4940 let wrap_chars: alloc::vec::Vec<char> = wrap_text.chars().collect();
4941 let wrap_advs: alloc::vec::Vec<i32> = wrap_chars
4942 .iter()
4943 .map(|&c| {
4944 if c == '\n' {
4945 return 0;
4946 }
4947 let mut cs = String::new();
4948 cs.push(c);
4949 crate::kernel::vector_font::get_vector_string_width_family(
4950 &cs,
4951 final_size,
4952 &final_font_family,
4953 final_bold,
4954 0,
4955 0,
4956 ) as i32
4957 + final_ls
4958 + if c == ' ' { final_ws } else { 0 }
4959 })
4960 .collect();
4961 let wrap_breaks = crate::os_lib::layout::line_break::break_positions(
4962 &wrap_chars,
4963 &wrap_advs,
4964 available_w,
4965 node_word_break_all,
4966 |c| !node_word_break_all && crate::kernel::vector_font::is_line_start_forbidden(c),
4967 |c| !node_word_break_all && crate::kernel::vector_font::is_line_end_forbidden(c),
4968 );
4969 for (ci, c) in wrap_chars.iter().copied().enumerate() {
4970 if c == '\n' {
4971 if node_white_space_pre_wrap {
4972 // pre-wrap: treat '\n' as an actual newline
4973 } else {
4974 // collapse mode: '\n' inside text that somehow survived tree.rs flattening
4975 // (usually tree.rs flattens it to a space, but if it got here, we still break)
4976 }
4977 let line_y =
4978 layout.dimensions.content.y + line_index * line_height;
4979 self.elements.push(RenderElement {
4980 text: current_line.clone(),
4981 font_family: final_font_family.clone(),
4982 font_size: if line_index == 0 { fl_size.unwrap_or(final_size) } else { final_size },
4983 letter_spacing: final_ls,
4984 word_spacing: final_ws,
4985 text_emphasis_style: final_emphasis_style.clone(),
4986 text_emphasis_color: final_emphasis_color,
4987 color: if line_index == 0 { fl_color.unwrap_or(final_color) } else { final_color },
4988 bg_color: final_bg,
4989 hover_bg_color: extract_hover_color_alt(
4990 styled,
4991 "background-color",
4992 "background",
4993 ),
4994 hover_color: extract_hover_color(styled, "color"),
4995 hover_border_color: extract_hover_color(
4996 styled,
4997 "border-color",
4998 ),
4999 filter: styled.value("filter").unwrap_or_default(),
5000 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
5001 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
5002 image_rendering: styled.value("image-rendering").unwrap_or_default(),
5003 text_stroke_width: extract_text_stroke(styled).0,
5004 text_stroke_color: extract_text_stroke(styled).1,
5005 marker_color: extract_marker_color(styled),
5006 marker_font_size: extract_marker_font_size(styled),
5007 marker_content: extract_marker_content(styled),
5008 focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
5009 focus_color: extract_focus_color(styled, "color"),
5010 focus_border_color: extract_focus_color(styled, "border-color"),
5011 active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
5012 active_color: extract_active_color(styled, "color"),
5013 active_border_color: extract_active_color(styled, "border-color"),
5014 border_color: None,
5015 border_style: super::BorderStyle::None,
5016 font_bold: if line_index == 0 { fl_bold.unwrap_or(final_bold) } else { final_bold },
5017 font_italic: if line_index == 0 { fl_italic.unwrap_or(final_italic) } else { final_italic },
5018 underline: final_underline,
5019 line_through: final_line_through,
5020 overline: final_overline,
5021 decoration_color: final_decoration_color,
5022 decoration_style: final_decoration_style.clone(),
5023 decoration_thickness: final_decoration_thickness,
5024 decoration_underline_offset: final_decoration_underline_offset,
5025 is_video: false,
5026 video_src: String::new(),
5027 is_link: is_actual_link,
5028 pointer_events_none: current_pointer_events_none,
5029 href: link_href.clone(),
5030 is_hr: false,
5031 is_img: false,
5032 canvas_ctx_id: None,
5033 img_alt: String::new(),
5034 is_input: false,
5035 is_textarea: false,
5036 is_button: false,
5037 button_icon: 0,
5038 element_id: String::new(),
5039 onclick: String::new(),
5040 form_action: String::new(),
5041 form_method: String::new(),
5042 width: current_line_w,
5043 x_offset: layout.dimensions.content.x + if line_index == 0 { text_indent_px + first_letter_x_shift } else { 0 },
5044 y_offset: line_y,
5045 height: line_height,
5046 border_radius: (0, 0, 0, 0),
5047 is_li: parent_tag == "li" && line_index == 0,
5048 li_num: if parent_tag == "li" && line_index == 0 {
5049 if let Some((true, count)) = list_state {
5050 Some(count)
5051 } else {
5052 None
5053 }
5054 } else {
5055 None
5056 },
5057 list_style_type: extract_list_style(styled),
5058 list_style_inside: extract_list_style_inside(styled),
5059 list_style_image: extract_list_style_image(styled),
5060 is_blockquote: false,
5061 z_index: self.inherited_paint_z,
5062 paint_depth: self.inherited_paint_depth,
5063 ..Default::default()
5064 });
5065
5066 if is_actual_link {
5067 self.links.push(HyperlinkArea {
5068 href: link_href.clone(),
5069 x0: layout.dimensions.content.x,
5070 y0: line_y,
5071 x1: layout.dimensions.content.x + current_line_w,
5072 y1: line_y + line_height,
5073 });
5074 }
5075
5076 line_index += 1;
5077 // `-webkit-line-clamp`(テキスト中の明示的な`\n`による
5078 // 改行フラッシュ経路)。同種の改行フラッシュがもう1箇所
5079 // (自然な折返しオーバーフロー経路。約80行下)にもあり、
5080 // そちらにしかこのチェックが配線されていなかったため、
5081 // `\n`を含むテキストでは`-webkit-line-clamp`が効かない
5082 // バグだった(2026-07-22発見・修正)。
5083 if let Some(max) = line_clamp_max {
5084 if line_index >= max as i32 {
5085 break;
5086 }
5087 }
5088 current_line.clear();
5089 current_line_w = 0;
5090 prev_char = None;
5091 prev_advance = 0;
5092 continue;
5093 }
5094
5095 // 描画と同じ書体で測った字幅(折り返し位置の計算と同じ値)。
5096 let advance = wrap_advs.get(ci).copied().unwrap_or(0);
5097
5098 if !current_line.is_empty() && wrap_breaks.binary_search(&ci).is_ok()
5099 {
5100 // 折り返し位置で改行する(禁則・単語送りは計算済み)。
5101 let send_prev = false;
5102 let actual_w;
5103 let mut next_line = String::new();
5104 let mut next_line_w = 0;
5105
5106 if send_prev {
5107 if let Some(pc) = prev_char {
5108 current_line.pop();
5109 next_line.push(pc);
5110 next_line_w = prev_advance;
5111 }
5112 actual_w = current_line_w - prev_advance;
5113 } else {
5114 actual_w = current_line_w;
5115 }
5116
5117 let line_y =
5118 layout.dimensions.content.y + line_index * line_height;
5119 self.elements.push(RenderElement {
5120 text: current_line.clone(),
5121 font_family: final_font_family.clone(),
5122 font_size: if line_index == 0 { fl_size.unwrap_or(final_size) } else { final_size },
5123 letter_spacing: final_ls,
5124 word_spacing: final_ws,
5125 text_emphasis_style: final_emphasis_style.clone(),
5126 text_emphasis_color: final_emphasis_color,
5127 color: if line_index == 0 { fl_color.unwrap_or(final_color) } else { final_color },
5128 bg_color: final_bg,
5129 hover_bg_color: extract_hover_color_alt(
5130 styled,
5131 "background-color",
5132 "background",
5133 ),
5134 hover_color: extract_hover_color(styled, "color"),
5135 hover_border_color: extract_hover_color(
5136 styled,
5137 "border-color",
5138 ),
5139 filter: styled.value("filter").unwrap_or_default(),
5140 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
5141 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
5142 image_rendering: styled.value("image-rendering").unwrap_or_default(),
5143 text_stroke_width: extract_text_stroke(styled).0,
5144 text_stroke_color: extract_text_stroke(styled).1,
5145 marker_color: extract_marker_color(styled),
5146 marker_font_size: extract_marker_font_size(styled),
5147 marker_content: extract_marker_content(styled),
5148 focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
5149 focus_color: extract_focus_color(styled, "color"),
5150 focus_border_color: extract_focus_color(styled, "border-color"),
5151 active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
5152 active_color: extract_active_color(styled, "color"),
5153 active_border_color: extract_active_color(styled, "border-color"),
5154 border_color: None,
5155 border_style: super::BorderStyle::None,
5156 font_bold: if line_index == 0 { fl_bold.unwrap_or(final_bold) } else { final_bold },
5157 font_italic: if line_index == 0 { fl_italic.unwrap_or(final_italic) } else { final_italic },
5158 underline: final_underline,
5159 line_through: final_line_through,
5160 overline: final_overline,
5161 decoration_color: final_decoration_color,
5162 decoration_style: final_decoration_style.clone(),
5163 decoration_thickness: final_decoration_thickness,
5164 decoration_underline_offset: final_decoration_underline_offset,
5165 is_video: false,
5166 video_src: String::new(),
5167 is_link: is_actual_link,
5168 pointer_events_none: current_pointer_events_none,
5169 href: link_href.clone(),
5170 is_hr: false,
5171 is_img: false,
5172 canvas_ctx_id: None,
5173 img_alt: String::new(),
5174 is_input: false,
5175 is_textarea: false,
5176 is_button: false,
5177 button_icon: 0,
5178 element_id: String::new(),
5179 onclick: String::new(),
5180 form_action: String::new(),
5181 form_method: String::new(),
5182 width: actual_w,
5183 x_offset: layout.dimensions.content.x + if line_index == 0 { text_indent_px + first_letter_x_shift } else { 0 },
5184 y_offset: line_y,
5185 height: line_height,
5186 border_radius: (0, 0, 0, 0),
5187 is_li: parent_tag == "li" && line_index == 0,
5188 li_num: if parent_tag == "li" && line_index == 0 {
5189 if let Some((true, count)) = list_state {
5190 Some(count)
5191 } else {
5192 None
5193 }
5194 } else {
5195 None
5196 },
5197 list_style_type: extract_list_style(styled),
5198 list_style_inside: extract_list_style_inside(styled),
5199 list_style_image: extract_list_style_image(styled),
5200 is_blockquote: false,
5201 z_index: self.inherited_paint_z,
5202 paint_depth: self.inherited_paint_depth,
5203 ..Default::default()
5204 });
5205
5206 if is_actual_link {
5207 self.links.push(HyperlinkArea {
5208 href: link_href.clone(),
5209 x0: layout.dimensions.content.x,
5210 y0: line_y,
5211 x1: layout.dimensions.content.x + actual_w,
5212 y1: line_y + line_height,
5213 });
5214 }
5215
5216 line_index += 1;
5217 if let Some(max) = line_clamp_max {
5218 if line_index >= max as i32 {
5219 break;
5220 }
5221 }
5222 current_line = next_line;
5223 // 折り返した位置の空白は次の行頭に持ち越さない
5224 // (CSS: 行頭の空白は取り除く。2026-09-26)。
5225 if c == ' ' && current_line.is_empty() {
5226 current_line_w = next_line_w;
5227 } else {
5228 current_line.push(c);
5229 current_line_w = next_line_w + advance;
5230 }
5231
5232 prev_char = Some(c);
5233 prev_advance = advance;
5234 continue;
5235 }
5236
5237 current_line.push(c);
5238 current_line_w += advance;
5239 prev_char = Some(c);
5240 prev_advance = advance;
5241 }
5242
5243 if !current_line.is_empty() {
5244 let line_y =
5245 layout.dimensions.content.y + line_index * line_height;
5246 self.elements.push(RenderElement {
5247 text: current_line.clone(),
5248 font_family: final_font_family.clone(),
5249 font_size: final_size,
5250 letter_spacing: final_ls,
5251 word_spacing: final_ws,
5252 text_emphasis_style: final_emphasis_style.clone(),
5253 text_emphasis_color: final_emphasis_color,
5254 color: final_color,
5255 bg_color: final_bg,
5256 hover_bg_color: extract_hover_color_alt(
5257 styled,
5258 "background-color",
5259 "background",
5260 ),
5261 hover_color: extract_hover_color(styled, "color"),
5262 hover_border_color: extract_hover_color(
5263 styled,
5264 "border-color",
5265 ),
5266 filter: styled.value("filter").unwrap_or_default(),
5267 mix_blend_mode: styled.value("mix-blend-mode").unwrap_or_default(),
5268 backdrop_filter: styled.value("backdrop-filter").unwrap_or_default(),
5269 image_rendering: styled.value("image-rendering").unwrap_or_default(),
5270 text_stroke_width: extract_text_stroke(styled).0,
5271 text_stroke_color: extract_text_stroke(styled).1,
5272 marker_color: extract_marker_color(styled),
5273 marker_font_size: extract_marker_font_size(styled),
5274 marker_content: extract_marker_content(styled),
5275 focus_bg_color: extract_focus_color_alt(styled, "background-color", "background"),
5276 focus_color: extract_focus_color(styled, "color"),
5277 focus_border_color: extract_focus_color(styled, "border-color"),
5278 active_bg_color: extract_active_color_alt(styled, "background-color", "background"),
5279 active_color: extract_active_color(styled, "color"),
5280 active_border_color: extract_active_color(styled, "border-color"),
5281 border_color: None,
5282 border_style: super::BorderStyle::None,
5283 font_bold: final_bold,
5284 font_italic: final_italic,
5285 underline: final_underline,
5286 line_through: final_line_through,
5287 overline: final_overline,
5288 decoration_color: final_decoration_color,
5289 decoration_style: final_decoration_style.clone(),
5290 decoration_thickness: final_decoration_thickness,
5291 decoration_underline_offset: final_decoration_underline_offset,
5292 is_video: false,
5293 video_src: String::new(),
5294 is_link: is_actual_link,
5295 pointer_events_none: current_pointer_events_none,
5296 href: link_href.clone(),
5297 is_hr: false,
5298 is_img: false,
5299 canvas_ctx_id: None,
5300 img_alt: String::new(),
5301 is_input: false,
5302 is_textarea: false,
5303 is_button: false,
5304 button_icon: 0,
5305 element_id: String::new(),
5306 onclick: String::new(),
5307 form_action: String::new(),
5308 form_method: String::new(),
5309 width: current_line_w,
5310 x_offset: layout.dimensions.content.x + if line_index == 0 { text_indent_px + first_letter_x_shift } else { 0 },
5311 y_offset: line_y,
5312 height: line_height,
5313 border_radius: (0, 0, 0, 0),
5314 is_li: parent_tag == "li" && line_index == 0,
5315 li_num: if parent_tag == "li" && line_index == 0 {
5316 if let Some((true, count)) = list_state {
5317 Some(count)
5318 } else {
5319 None
5320 }
5321 } else {
5322 None
5323 },
5324 is_blockquote: false,
5325 z_index: self.inherited_paint_z,
5326 paint_depth: self.inherited_paint_depth,
5327 ..Default::default()
5328 });
5329
5330 if is_actual_link {
5331 self.links.push(HyperlinkArea {
5332 href: link_href.clone(),
5333 x0: layout.dimensions.content.x,
5334 y0: line_y,
5335 x1: layout.dimensions.content.x + current_line_w,
5336 y1: line_y + line_height,
5337 });
5338 }
5339 }
5340 } // end else (non-nowrap)
5341 }
5342 }
5343 }
5344 }
5345 _ => {
5346 // 匿名ボックス等、要素を生成しない中間ノードでも子はツリー上 1 段深い。
5347 // ここで深さを進めないと、祖先より子孫が浅く見えて描画順が逆転する。
5348 let saved_paint_depth = self.inherited_paint_depth;
5349 self.inherited_paint_depth = self.inherited_paint_depth.saturating_add(1);
5350 for child in &layout.children {
5351 self.flatten_layout(
5352 child,
5353 current_form_action,
5354 current_form_method,
5355 current_href,
5356 current_font_bold,
5357 current_font_italic,
5358 current_underline,
5359 current_line_through,
5360 current_color,
5361 current_font_size,
5362 parent_id,
5363 list_state,
5364 parent_tag,
5365 current_decoration_color,
5366 current_decoration_style.clone(),
5367 current_decoration_thickness,
5368 current_decoration_underline_offset,
5369 current_overline,
5370 current_pointer_events_none,
5371 );
5372 }
5373 self.inherited_paint_depth = saved_paint_depth;
5374 }
5375 }
5376 }
5377}
5378
5379fn extract_text_stroke(styled: &crate::os_lib::css::StyledNode) -> (i32, Option<u32>) {
5380 let val_str = styled
5381 .value("text-stroke")
5382 .or_else(|| styled.value("-webkit-text-stroke"))
5383 .unwrap_or_default();
5384 if val_str.is_empty() {
5385 let width_str = styled.value("-webkit-text-stroke-width").unwrap_or_default();
5386 let color_str = styled.value("-webkit-text-stroke-color").unwrap_or_default();
5387 let w = parse_px_val(&width_str);
5388 let c = parse_color(&color_str);
5389 return (w, c);
5390 }
5391 let mut width = 0;
5392 let mut color = None;
5393 for p in val_str.split_whitespace() {
5394 let w = parse_px_val(p);
5395 if w > 0 {
5396 width = w;
5397 } else if let Some(c) = parse_color(p) {
5398 color = Some(c);
5399 }
5400 }
5401 (width, color)
5402}