atmos/os_lib/css/cascade.rs
1// 分割: css.rs より機械的に移動(2026-07-16 リファクタ フェーズ3)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5
6/// 計算量: **O(R×Sel + N × (C × M + K))**
7///
8/// - R: ルール数、Sel: セレクタ数(索引構築ぶん)
9/// - N: DOM ノード数
10/// - C: 1 ノードあたりの候補ルール数(索引で絞った後)
11/// - M: 1 ルールの照合コスト(セレクタ長・祖先チェーンの深さに比例)
12/// - K: ノードごとの固定コスト(継承マップ/CSS 変数/カウンタの複製)
13///
14/// 実測(sugi-lab.net: R=2403, N=358): 索引構築 27 tick、走査 291 tick。
15/// **照合(C×M)は既に全体の一部でしかなく、K が支配的**。
16/// 詳細は `spec/resource_loading.md`。
17pub fn style_tree<'a>(root: &'a Node, stylesheet: &StyleSheet) -> StyledNode<'a> {
18 // ツリー走査の間、ローカルに右端キー索引を構築し、各ノードのカスケードで
19 // 照合するルールを絞り込む(グローバル Mutex ロックを完全排除)。
20 let rule_index = super::rule_index::RuleIndex::build(&stylesheet.rules);
21 let mut ancestors = Vec::new();
22 style_tree_with_ancestors_vars(
23 root,
24 stylesheet,
25 Some(&rule_index),
26 &mut ancestors,
27 None,
28 None,
29 &alloc::rc::Rc::new(BTreeMap::new()),
30 &alloc::rc::Rc::new(BTreeMap::new()),
31 &BTreeMap::new(),
32 )
33 .0
34}
35
36/// HTML5レガシー`width`/`height`属性値をCSS長さへ変換する("rules for parsing
37/// dimension values"の簡略版)。`%`で終われば百分率、それ以外は先頭の数字列を
38/// pxとして扱う(`"400"` → `"400px"`。末尾の単位無しゴミは許容)。
39fn html_legacy_dimension_to_css(raw: &str) -> Option<String> {
40 let s = raw.trim();
41 if s.is_empty() {
42 return None;
43 }
44 if let Some(pct) = s.strip_suffix('%') {
45 let pct = pct.trim();
46 return pct.parse::<f32>().ok().map(|_| alloc::format!("{}%", pct));
47 }
48 let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
49 if digits.is_empty() {
50 return None;
51 }
52 Some(alloc::format!("{}px", digits))
53}
54
55#[derive(Debug, Clone, Copy)]
56pub(crate) struct AncestorContext<'a> {
57 pub(crate) node: &'a Node,
58 pub(crate) sibling_index: Option<usize>,
59 pub(crate) sibling_count: Option<usize>,
60}
61
62#[inline]
63fn insert_ua_pairs(specified_values: &mut BTreeMap<String, String>, pairs: &[(&'static str, &'static str)]) {
64 for &(k, v) in pairs {
65 specified_values.insert(String::from(k), String::from(v));
66 }
67}
68
69pub(crate) fn compute_styles<'a>(
70 root: &'a Node,
71 stylesheet: &StyleSheet,
72 ancestors: &[AncestorContext<'a>],
73 sibling_index: Option<usize>,
74 sibling_count: Option<usize>,
75 active_pseudo_states: &[&str],
76) -> BTreeMap<String, String> {
77 let (vals, _, _, _) = compute_styles_with_vars(
78 root,
79 stylesheet,
80 None,
81 ancestors,
82 sibling_index,
83 sibling_count,
84 active_pseudo_states,
85 &alloc::rc::Rc::new(BTreeMap::new()),
86 &BTreeMap::new(),
87 );
88 vals
89}
90
91/// `root` にスタイルを適用する。`counters_in` は直前までの文書順カウンタ状態
92/// (`counter-reset`/`counter-increment`は全カウンタをフラットに共有する簡略化実装で、
93/// 実 CSS 仕様のようなネストごとのスコープ分離は行わない)。戻り値の第3要素は
94/// この要素自身の counter-reset/counter-increment 適用後のカウンタ状態。
95pub(crate) fn compute_styles_with_vars<'a>(
96 root: &'a Node,
97 stylesheet: &StyleSheet,
98 rule_index: Option<&super::rule_index::RuleIndex>,
99 ancestors: &[AncestorContext<'a>],
100 sibling_index: Option<usize>,
101 sibling_count: Option<usize>,
102 active_pseudo_states: &[&str],
103 parent_vars: &alloc::rc::Rc<BTreeMap<String, String>>,
104 counters_in: &BTreeMap<String, i64>,
105) -> (
106 BTreeMap<String, String>,
107 alloc::rc::Rc<BTreeMap<String, String>>,
108 BTreeMap<String, i64>,
109 alloc::collections::BTreeSet<String>,
110) {
111 let mut specified_values = BTreeMap::new();
112 // 【2026-08-05 性能】以前は全ノードで `parent_vars.clone()` していた。
113 //
114 // CSS 変数を定義するノードは `:root` などごく一部なのに、
115 // 358 ノードすべてが 10 数個の `String` ペアを複製していた
116 // (1 レイアウトあたり数千回の確保)。
117 // `Rc` にして**書き込むノードだけ**が複製するようにする
118 // (`Rc::make_mut` が必要なときだけ実体を複製する)。
119 let mut css_vars: alloc::rc::Rc<BTreeMap<String, String>> = parent_vars.clone();
120 let mut counters_out = if counters_in.is_empty() {
121 BTreeMap::new()
122 } else {
123 counters_in.clone()
124 };
125 let mut counters_reset_names = alloc::collections::BTreeSet::new();
126
127 match &root.node_type {
128 NodeType::Element {
129 tag_name,
130 attributes,
131 classes: _,
132 id: _,
133 } => {
134 match tag_name.as_str() {
135 "h1" => {
136 insert_ua_pairs(&mut specified_values, &[
137 ("display", "block"),
138 ("font-size", "32"),
139 ("font-weight", "bold"),
140 ("margin-top", "21"),
141 ("margin-bottom", "21"),
142 ]);
143 }
144 "h2" => {
145 insert_ua_pairs(&mut specified_values, &[
146 ("display", "block"),
147 ("font-size", "24"),
148 ("font-weight", "bold"),
149 ("margin-top", "20"),
150 ("margin-bottom", "20"),
151 ]);
152 }
153 "h3" => {
154 insert_ua_pairs(&mut specified_values, &[
155 ("display", "block"),
156 ("font-size", "18"),
157 ("font-weight", "bold"),
158 ("margin-top", "18"),
159 ("margin-bottom", "18"),
160 ]);
161 }
162 "p" => {
163 insert_ua_pairs(&mut specified_values, &[
164 ("display", "block"),
165 ("margin-top", "16"),
166 ("margin-bottom", "16"),
167 ]);
168 }
169 "body" => {
170 insert_ua_pairs(&mut specified_values, &[
171 ("display", "block"),
172 ("margin", "8"),
173 ]);
174 }
175 "ul" | "ol" => {
176 insert_ua_pairs(&mut specified_values, &[
177 ("display", "block"),
178 ("margin-left", "20px"),
179 ]);
180 // UA既定として `list-item` カウンタをこのリストのスコープでリセットする
181 // (実 CSS の `ol`/`ul` UA スタイルシートにある `counter-reset: list-item`
182 // に相当)。ネストした `<ol>` はこのリセットにより自動的に 1 から再スタート
183 // する(既存の counter-reset ネストスコープ折り畳みロジックがそのまま効く)。
184 // author が `counter-reset` を明示指定すればここは通常のカスケードで
185 // 上書きされる(同一キーの specified_values 挿入は後勝ちのため)。
186 //
187 // `<ol start="N">`(HTML属性、丸ごと未対応だった): `counter-increment`
188 // は表示前に加算されるため、初期値を `N-1` にリセットして最初の `<li>` が
189 // ちょうど `N` になるようにする(`counter-reset:list-item 10` で最初の
190 // `<li>` が 11 になる既存の挙動と同じオフセット規則)。
191 let is_ol = tag_name.eq_ignore_ascii_case("ol");
192 let start_attr = if is_ol {
193 attributes.get("start").and_then(|s| s.trim().parse::<i64>().ok())
194 } else {
195 None
196 };
197 // `<ol reversed>`(HTML属性、丸ごと未対応だった): `<li>` 側の
198 // counter-increment を -1 にする(下記 "li" 分岐が祖先の reversed を
199 // 見て切り替える)ため、こちら側は初期値を「開始値+1」にする必要がある
200 // (増分が -1 のため、最初の li は reset-1=開始値になる)。開始値は
201 // 明示 `start` があればそれ、無ければ直下の `<li>` 子要素数
202 // (降順のデフォルトは要素数から1まで数える仕様)。
203 let reversed = is_ol && attributes.contains_key("reversed");
204 let start_val = if reversed {
205 let li_count = root
206 .children
207 .iter()
208 .filter(|c| {
209 matches!(&c.node_type, NodeType::Element { tag_name, .. } if tag_name.eq_ignore_ascii_case("li"))
210 })
211 .count() as i64;
212 start_attr.unwrap_or(li_count) + 1
213 } else {
214 start_attr.map(|n| n - 1).unwrap_or(0)
215 };
216 specified_values.insert(
217 String::from("counter-reset"),
218 alloc::format!("list-item {start_val}"),
219 );
220 }
221 "li" => {
222 specified_values.insert(String::from("display"), String::from("block"));
223 // UA既定: `li` は毎回 `list-item` カウンタを1つ進める
224 // (実 CSS の `li { counter-increment: list-item; }` に相当)。
225 // `content: counter(list-item)` で著者が独自の番号書式を組み立てられるようにする。
226 // 直近の祖先が `<ol reversed>` なら降順(-1)に切り替える
227 // (`<ol reversed>` 自体は上の "ol" 分岐で初期値側を調整済み)。
228 let parent_reversed = ancestors.last().is_some_and(|a| {
229 matches!(&a.node.node_type, NodeType::Element { tag_name, attributes, .. }
230 if tag_name.eq_ignore_ascii_case("ol") && attributes.contains_key("reversed"))
231 });
232 let incr = if parent_reversed { "list-item -1" } else { "list-item" };
233 specified_values
234 .insert(String::from("counter-increment"), String::from(incr));
235 }
236 "pre" => {
237 insert_ua_pairs(&mut specified_values, &[
238 ("display", "block"),
239 ("background-color", "#f6f8fa"),
240 ("color", "#24292e"),
241 ("border-color", "#d0d7de"),
242 ("border-style", "solid"),
243 ("border-width", "1px"),
244 ("border-radius", "6px"),
245 ("padding", "12px"),
246 ("margin-top", "10px"),
247 ("margin-bottom", "10px"),
248 ]);
249 }
250 "code" => {
251 insert_ua_pairs(&mut specified_values, &[
252 ("display", "inline"),
253 ("background-color", "#f0f0f0"),
254 ("color", "#d63384"),
255 ("padding", "2px 4px"),
256 ("border-radius", "3px"),
257 ]);
258 }
259 "blockquote" => {
260 insert_ua_pairs(&mut specified_values, &[
261 ("display", "block"),
262 ("margin-left", "20px"),
263 ("margin-right", "20px"),
264 ("margin-top", "10px"),
265 ("margin-bottom", "10px"),
266 ("border-left", "4px solid #BD93F9"),
267 ]);
268 }
269 "div" | "html" | "form" | "header" | "footer" | "main" | "section" | "nav"
270 | "hr" | "article" | "aside" | "figure" | "figcaption" | "fieldset" | "legend"
271 | "dl" | "dt" | "dd" | "details" | "summary" => {
272 specified_values.insert(String::from("display"), String::from("block"));
273 }
274 // `<dialog>`(丸ごと未対応だった)。仕様上 `open` 属性が無い間は既定で
275 // 非表示(`display: none`)、`open` 属性が付くと表示される。`show()`/
276 // `showModal()`/`close()` はこの `open` 属性を JS 側で付け外しするだけの
277 // 簡略実装(`::backdrop` 描画・モーダルフォーカストラップは対象外)。
278 "dialog" => {
279 specified_values.insert(
280 String::from("display"),
281 if attributes.contains_key("open") {
282 String::from("block")
283 } else {
284 String::from("none")
285 },
286 );
287 }
288 // 実際の描画(動画再生・SVG図形・埋め込み文書等)は非対応でも、既定 display だけは
289 // 仕様に合わせておく(無指定時に `_ => inline` へ落ちてレイアウトが崩れるのを防ぐ)。
290 "select" | "video" | "audio" | "iframe" | "svg" | "canvas" => {
291 specified_values.insert(String::from("display"), String::from("inline-block"));
292 }
293 "center" => {
294 insert_ua_pairs(&mut specified_values, &[
295 ("display", "block"),
296 ("text-align", "center"),
297 ]);
298 }
299 "img" => {
300 specified_values.insert(String::from("display"), String::from("inline-block"));
301 }
302 "a" => {
303 insert_ua_pairs(&mut specified_values, &[
304 ("display", "inline"),
305 ("text-decoration", "underline"),
306 ]);
307 }
308 "strong" | "b" => {
309 insert_ua_pairs(&mut specified_values, &[
310 ("display", "inline"),
311 ("font-weight", "bold"),
312 ]);
313 }
314 "em" | "i" => {
315 insert_ua_pairs(&mut specified_values, &[
316 ("display", "inline"),
317 ("font-style", "italic"),
318 ]);
319 }
320 "u" => {
321 insert_ua_pairs(&mut specified_values, &[
322 ("display", "inline"),
323 ("text-decoration", "underline"),
324 ]);
325 }
326 "s" | "del" | "strike" => {
327 insert_ua_pairs(&mut specified_values, &[
328 ("display", "inline"),
329 ("text-decoration", "line-through"),
330 ]);
331 }
332 "input" | "textarea" | "button" => {
333 insert_ua_pairs(&mut specified_values, &[
334 ("display", "inline-block"),
335 ("border-radius", "6px"),
336 ]);
337 }
338 "table" => {
339 insert_ua_pairs(&mut specified_values, &[
340 ("display", "table"),
341 ("border-color", "#6272A4"),
342 ]);
343 }
344 "thead" | "tbody" | "tfoot" => {
345 specified_values
346 .insert(String::from("display"), String::from("table-row-group"));
347 }
348 "tr" => {
349 specified_values.insert(String::from("display"), String::from("table-row"));
350 }
351 "td" => {
352 insert_ua_pairs(&mut specified_values, &[
353 ("display", "table-cell"),
354 ("padding", "4px 8px"),
355 ]);
356 }
357 "th" => {
358 insert_ua_pairs(&mut specified_values, &[
359 ("display", "table-cell"),
360 ("padding", "4px 8px"),
361 ("font-weight", "bold"),
362 ("text-align", "center"),
363 ]);
364 }
365 "caption" => {
366 insert_ua_pairs(&mut specified_values, &[
367 ("display", "block"),
368 ("text-align", "center"),
369 ]);
370 }
371 // `noscript`: この処理系は JS を実行する(スクリプト有効)ため、HTML5 仕様
372 // どおり常に非表示にする(スクリプト無効時のみ中身を表示する要素のため)。
373 "head" | "title" | "meta" | "link" | "style" | "script" | "template"
374 | "noscript" => {
375 specified_values.insert(String::from("display"), String::from("none"));
376 }
377 _ => {
378 specified_values.insert(String::from("display"), String::from("inline"));
379 }
380 }
381
382 // HTML5 のレガシー`width`/`height`属性(`<img>`/`<iframe>`/`<video>`/
383 // `<embed>`/`<object>`/`<canvas>`)。実ブラウザでは UA スタイルシート相当の
384 // 優先度(著者CSSより弱い)でサイズを決めるが、CSS指定が無い場合はこの
385 // 属性だけがサイズの手がかりになる。以前はどこにも配線されておらず、
386 // `<iframe width="100%" height="400">`のようなレガシー記法(実サイト
387 // www.sugi-lab.net の Access ページの地図埋め込み等で使用)が完全に
388 // 無視され、サイズ未指定要素として折りたたまれるバグだった
389 // (2026-07-21発見・修正)。`hidden`属性と同じ「タグ別UA既定値を
390 // 上書きする形で適用し、後続の著者スタイルカスケードでauthorが
391 // width/heightを明示指定すればそちらが優先される」という方針。
392 if matches!(
393 tag_name.as_str(),
394 "img" | "iframe" | "video" | "embed" | "object" | "canvas"
395 ) {
396 if let Some(w) = attributes.get("width").and_then(|v| html_legacy_dimension_to_css(v)) {
397 specified_values.insert(String::from("width"), w);
398 }
399 if let Some(h) = attributes.get("height").and_then(|v| html_legacy_dimension_to_css(v)) {
400 specified_values.insert(String::from("height"), h);
401 }
402 }
403
404 // HTML5 のグローバル真偽属性 `hidden`。実ブラウザでは UA スタイルシートの
405 // `[hidden] { display: none }` が担う。ここではタグ別 UA 既定値を上書きする形で
406 // 適用し、後続の著者スタイルカスケードで author が `display` を明示指定すれば
407 // そちらが(この処理系の「著者スタイル優先」という簡略化方針どおり)優先される。
408 if attributes.contains_key("hidden") {
409 specified_values.insert(String::from("display"), String::from("none"));
410 }
411
412 // Popover API(`popover` グローバル属性。丸ごと未対応だった)。仕様上
413 // `[popover]` 要素は既定で非表示、`showPopover()` 呼び出しで表示に
414 // 切り替わる(`:popover-open` 状態)。`<dialog>` の `open` 属性と同じ
415 // 「JS 側で内部専用属性を付け外しするだけ」の簡略実装とし、
416 // `_popover_open` という内部専用属性名を使う(`::backdrop` 描画・
417 // ライトディスミス・`beforetoggle`/`toggle` イベントは対象外)。
418 if attributes.contains_key("popover") && !attributes.contains_key("_popover_open") {
419 specified_values.insert(String::from("display"), String::from("none"));
420 }
421
422 // <details> が `open` 属性を持っていない場合、最初の <summary> 要素以外の子要素を非表示(display: none)にする。
423 if let Some(parent_ctx) = ancestors.last() {
424 if let NodeType::Element { tag_name: parent_tag, attributes: parent_attrs, .. } = &parent_ctx.node.node_type {
425 if parent_tag == "details" && !parent_attrs.contains_key("open") {
426 // 親の children の中で最初の <summary> 要素の位置を特定する。
427 let mut first_summary_idx = None;
428 for (idx, child) in parent_ctx.node.children.iter().enumerate() {
429 if let NodeType::Element { tag_name: child_tag, .. } = &child.node_type {
430 if child_tag == "summary" {
431 first_summary_idx = Some(idx);
432 break;
433 }
434 }
435 }
436
437 let is_first_summary = if let Some(fs_idx) = first_summary_idx {
438 sibling_index == Some(fs_idx)
439 } else {
440 false
441 };
442
443 if !is_first_summary {
444 specified_values.insert(String::from("display"), String::from("none"));
445 }
446 }
447 }
448 }
449
450 // HTML5 のグローバル真偽属性 `inert`(丸ごと未対応だった)。仕様上の完全な
451 // 意味論(フォーカス不可・アクセシビリティツリーから除外・検索対象外等)は
452 // フォーカス管理/イベント配送の複数箇所にまたがる大改修が必要なため対象外
453 // だが、「クリック等のポインタ操作を受け付けない」という中核効果は既存の
454 // `pointer-events` の実装済みの挙動をそのまま使って実現できる(実ブラウザの
455 // UA スタイルシートも `[inert] { pointer-events: none; }` 相当を持つ)。
456 // `hidden` と同様、著者スタイルが `pointer-events` を明示指定すればそちらが
457 // 優先される。
458 if attributes.contains_key("inert") {
459 specified_values.insert(String::from("pointer-events"), String::from("none"));
460 }
461
462 // HTML5 のグローバル属性 `dir="ltr"/"rtl"`。実 HTML では CSS `direction` の
463 // プレゼンテーショナルヒントとして働く。`auto`(内容の文字種から双方向判定する
464 // モード)はこの処理系にその判定機構が無いため非対応(値を無視し既定の継承へ
465 // フォールバックする)。`hidden` と同様、後続の著者スタイルカスケードで
466 // `direction` が明示指定されていればそちらが優先される。
467 if let Some(dv) = attributes.get("dir").map(|v| v.trim().to_lowercase()) {
468 if dv == "ltr" || dv == "rtl" {
469 specified_values.insert(String::from("direction"), dv);
470 }
471 }
472
473 // 値ごとに (important, specificity, source_order, value) を保持。
474 // 比較順: !important が最優先 → 特異度 → ソース順。
475 let mut applied: BTreeMap<String, (bool, Option<usize>, (u32, u32, u32), usize, String)> =
476 BTreeMap::new();
477
478 // 【2026-07-27 性能修正】祖先を辿る `is_node_disabled` と兄弟を数える
479 // `type_sibling_position` は**このノードについて一度計算すれば十分**
480 // なのに、従来は `matches_selector` の内部でルールごとに再計算して
481 // いた。実サイト規模(2403 ルール × 365 ノード ≒ 88 万回)ではこれが
482 // 支配的コストとなり、1 回のカスケードに約 46 秒かかっていた。
483 // ループの外で 1 回だけ求めて使い回す。
484 let node_ctx = crate::os_lib::css::selector::NodeMatchContext::new(
485 root,
486 ancestors,
487 sibling_index,
488 );
489
490 // 【2026-07-28 性能修正】右端キー索引でこのノードに関係し得るルールだけへ絞る。
491 // 実サイトは Font Awesome 込みで 2403 ルールあり、従来は全ノードが全ルールを
492 // 総当たりしていた(≒88 万回の照合)。1 回のレイアウトに約 50 秒かかり、
493 // JS タイマーが毎フレーム DOM を dirty にするため描画が一度も完了しなかった。
494 // 索引が使えない場合は従来どおり全ルールを見る(結果は変わらず遅いだけ)。
495 let rule_orders: alloc::vec::Vec<usize> = match &root.node_type {
496 NodeType::Element {
497 tag_name,
498 classes,
499 id,
500 ..
501 } => {
502 if let Some(idx) = rule_index {
503 idx.candidates(tag_name.as_str(), id.as_deref(), classes.as_slice())
504 .unwrap_or_else(|_| (0..stylesheet.rules.len()).collect())
505 } else {
506 (0..stylesheet.rules.len()).collect()
507 }
508 }
509 // 【2026-07-31 性能修正】テキストノードはどのセレクタにも一致しないため空リスト
510 _ => alloc::vec::Vec::new(),
511 };
512
513 for rule_order in rule_orders {
514 let rule = &stylesheet.rules[rule_order];
515 // 【2026-07-24追加】`@media`条件の動的判定。パース時の静的除外
516 // (旧実装)だと、CSS解析はコンテンツ変化時の一度きりしか走らない
517 // ため、その一度きりの評価時点のウィンドウ幅と実際の表示時の
518 // ウィンドウ幅がズレると、モバイルファーストCSSのナビゲーション等が
519 // 永久に非表示のままになるバグがあった。ここで毎回(レイアウトの
520 // たびに)動的に再評価することで、ウィンドウ幅の変化に追従する。
521 if let Some(q) = &rule.media_query {
522 if !crate::os_lib::css::media_query_matches(q) {
523 continue;
524 }
525 }
526 // 【2026-09-03】`@container` も同じく動的判定する。
527 //
528 // 基準はビューポートではなく「問い合わせコンテナ」なので、
529 // レイアウト側が `CONTAINER_HINT_*` へ現在のコンテナ寸法を
530 // 入れてからカスケードを呼ぶ。まだレイアウトが一度も
531 // 走っていない初回は 0 が入っており、その場合は
532 // ビューポート幅で代用する(0 のまま評価すると
533 // `min-width` 系が軒並み偽になり、コンテナクエリを持つ
534 // ページが初回描画で崩れるため)。
535 if let Some(q) = &rule.container_query {
536 let mut cw = crate::os_lib::layout::CONTAINER_HINT_W
537 .load(core::sync::atomic::Ordering::Relaxed);
538 let mut ch = crate::os_lib::layout::CONTAINER_HINT_H
539 .load(core::sync::atomic::Ordering::Relaxed);
540 if cw <= 0 {
541 cw = crate::os_lib::layout::VIEWPORT_HINT_W
542 .load(core::sync::atomic::Ordering::Relaxed);
543 }
544 if ch <= 0 {
545 ch = crate::os_lib::layout::VIEWPORT_HINT_H
546 .load(core::sync::atomic::Ordering::Relaxed);
547 }
548 let size = crate::os_lib::css::container_query::ContainerSize {
549 width: cw,
550 height: ch,
551 };
552 // 入れ子は `&&` 連結。すべて満たす必要がある。
553 let all_ok = q.split("&&").all(|one| {
554 // 保持形式は `名前|条件`。名前は現状使わない
555 // (最も近い祖先のコンテナを常に対象とする簡略実装)。
556 let cond = one.split_once('|').map(|(_, c)| c).unwrap_or(one);
557 crate::os_lib::css::container_query::condition_matches(cond, size)
558 });
559 if !all_ok {
560 continue;
561 }
562 }
563 let mut matched_spec: Option<(u32, u32, u32)> = None;
564 let mut matched_pseudo_el: Option<PseudoElement> = None;
565 for selector in &rule.selectors {
566 if matches_selector(
567 selector,
568 root,
569 ancestors,
570 sibling_index,
571 sibling_count,
572 active_pseudo_states,
573 &node_ctx,
574 ) {
575 let spec = selector_specificity(selector);
576 if matched_spec.is_none_or(|cur| spec > cur) {
577 matched_spec = Some(spec);
578 matched_pseudo_el = selector_pseudo_element(selector);
579 }
580 }
581 }
582 if let Some(spec) = matched_spec {
583 // ::before / ::after / ::marker にマッチしたルールは、要素自体には
584 // 対象プロパティ以外を適用しない(疑似要素専用の宣言をそのまま親要素へ
585 // 流し込まないため)。content は合成プロパティ(x-before-content /
586 // x-after-content)へ、::marker の color は x-marker-color へ振り替え、
587 // flatten_layout 側で読み出して描画する。
588 // 注意: "--" 始まりのキーは下の後処理で specified_values ではなく
589 // css_vars 側へ入ってしまい styled.value() から読めなくなるため、
590 // ダッシュ2つ始まりにしない。
591 for decl in &rule.declarations {
592 let key: Option<alloc::borrow::Cow<'_, str>> = match matched_pseudo_el {
593 Some(PseudoElement::Before) => {
594 matches!(decl.name.as_str(), "content" | "display" | "font-family")
595 .then(|| alloc::borrow::Cow::Owned(alloc::format!("x-before-{}", decl.name)))
596 }
597 Some(PseudoElement::After) => matches!(decl.name.as_str(), "content" | "display")
598 .then(|| alloc::borrow::Cow::Owned(alloc::format!("x-after-{}", decl.name))),
599 Some(PseudoElement::Marker) => {
600 matches!(decl.name.as_str(), "color" | "font-size" | "content")
601 .then(|| alloc::borrow::Cow::Owned(alloc::format!("x-marker-{}", decl.name)))
602 }
603 Some(PseudoElement::Placeholder) => {
604 (decl.name == "color").then(|| alloc::borrow::Cow::Borrowed("x-placeholder-color"))
605 }
606 Some(PseudoElement::FirstLine) => matches!(
607 decl.name.as_str(),
608 "color" | "font-size" | "font-weight" | "font-style"
609 )
610 .then(|| alloc::borrow::Cow::Owned(alloc::format!("x-first-line-{}", decl.name))),
611 Some(PseudoElement::FirstLetter) => matches!(
612 decl.name.as_str(),
613 "color" | "font-size" | "font-weight" | "font-style"
614 )
615 .then(|| alloc::borrow::Cow::Owned(alloc::format!("x-first-letter-{}", decl.name))),
616 None => Some(alloc::borrow::Cow::Borrowed(decl.name.as_str())),
617 };
618 let Some(key) = key else {
619 continue;
620 };
621 let should_replace = match applied.get(key.as_ref()) {
622 Some((cur_imp, cur_layer, cur_spec, cur_order, _)) => {
623 if decl.important != *cur_imp {
624 decl.important // important は非 important に常に勝つ
625 } else if decl.important {
626 // !important 同士はレイヤーを見ずに従来通り specificity/order
627 // で比較する(レイヤーとの優先順位反転は非対応の簡略実装)。
628 spec > *cur_spec
629 || (spec == *cur_spec && rule_order >= *cur_order)
630 } else {
631 // 通常宣言: `@layer` の優先度(無し=最優先、後のレイヤーほど
632 // 優先)→ specificity → source order の順で比較する。
633 let cur_rank = cur_layer.map(|l| l as i64).unwrap_or(i64::MAX);
634 let new_rank = rule.layer.map(|l| l as i64).unwrap_or(i64::MAX);
635 new_rank > cur_rank
636 || (new_rank == cur_rank
637 && (spec > *cur_spec
638 || (spec == *cur_spec && rule_order >= *cur_order)))
639 }
640 }
641 None => true,
642 };
643 if should_replace {
644 // content の実解析(attr()/counter() 展開)はこの要素自身の
645 // counter-reset/counter-increment 確定後まで遅延させる必要があるため、
646 // ここでは生の宣言値のまま保持する(下の apply_counter_decls 後に解決)。
647 applied.insert(
648 key.into_owned(),
649 (decl.important, rule.layer, spec, rule_order, decl.value.clone()),
650 );
651 }
652 }
653 }
654 }
655 // カスケード優先順位(CSS仕様): author !important > inline style !important
656 // > inline style(通常)> author 通常。インライン style="" の上書きより先に、
657 // どのキーが author 側の `!important` で確定したかを覚えておく必要がある
658 // (`applied` を消費してしまう前に記録する)。
659 let mut important_keys: alloc::collections::BTreeSet<String> =
660 alloc::collections::BTreeSet::new();
661 for (k, (is_important, _, _, _, _)) in &applied {
662 if *is_important {
663 important_keys.insert(k.clone());
664 }
665 }
666 for (k, (_, _, _, _, v)) in applied {
667 if k.starts_with("--") {
668 alloc::rc::Rc::make_mut(&mut css_vars).insert(k, v);
669 } else {
670 let resolved = if v.contains("var(") {
671 resolve_css_var(&v, &css_vars)
672 } else {
673 v
674 };
675 let resolved = if resolved.contains("env(") {
676 resolve_css_env(&resolved)
677 } else {
678 resolved
679 };
680 specified_values.insert(k, resolved);
681 }
682 }
683
684 if let Some(style_attr) = attributes.get("style") {
685 for decl in style_attr.split(';') {
686 let mut parts = decl.splitn(2, ':');
687 if let (Some(k), Some(v)) = (parts.next(), parts.next()) {
688 let k_trim = k.trim().to_string();
689 let raw_value = v.trim();
690 // `!important` を切り出す(スタイルシート側の宣言パースと同じ規則)。
691 let (v_trim, inline_important) = match raw_value.rfind('!') {
692 Some(pos)
693 if raw_value
694 .get(pos + 1..)
695 .unwrap_or("")
696 .trim()
697 .eq_ignore_ascii_case("important") =>
698 {
699 (
700 String::from(raw_value.get(..pos).unwrap_or("").trim()),
701 true,
702 )
703 }
704 _ => (String::from(raw_value), false),
705 };
706 if !k_trim.is_empty() {
707 // author 側が `!important` で確定済みのキーは、インライン側が
708 // `!important` でない限り上書きしない(CSS仕様のカスケード優先順位)。
709 if important_keys.contains(&k_trim) && !inline_important {
710 continue;
711 }
712 if k_trim.starts_with("--") {
713 alloc::rc::Rc::make_mut(&mut css_vars).insert(k_trim, v_trim);
714 } else {
715 let resolved = if v_trim.contains("var(") {
716 resolve_css_var(&v_trim, &css_vars)
717 } else {
718 v_trim
719 };
720 let resolved = if resolved.contains("env(") {
721 resolve_css_env(&resolved)
722 } else {
723 resolved
724 };
725 specified_values.insert(k_trim, resolved);
726 }
727 }
728 }
729 }
730 }
731 expand_border_shorthands(&mut specified_values);
732 expand_place_shorthands(&mut specified_values);
733 expand_grid_template_shorthand(&mut specified_values);
734 expand_text_emphasis_shorthand(&mut specified_values);
735 expand_font_shorthand(&mut specified_values);
736 expand_background_shorthand(&mut specified_values);
737
738 // counter-reset/counter-increment をこの要素の確定値から適用してから、
739 // 保留していた ::before/::after の content(counter() 参照を含む)を解決する。
740 counters_reset_names = apply_counter_decls(&specified_values, &mut counters_out);
741 // `quotes` は同一要素(::before/::after の元となる要素自身)への明示指定のみ対応。
742 // 祖先からの継承は、この時点ではまだ継承伝搬(style_tree_with_ancestors_vars 側で
743 // 後段に実行)が済んでいないため参照できない、という簡略化の制約がある。
744 let quotes_pairs = specified_values
745 .get("quotes")
746 .and_then(|v| parse_quotes_pairs(v));
747 // 入れ子レベル(`open-quote`/`close-quote` が何組目のペアを使うか)は、
748 // 祖先の `<q>` 要素の個数から近似する(`<q>` は quotation を表す代表的な要素で、
749 // 実用上のネスト深さと一致する)。以前は入れ子レベルを一切追跡せず常に最初の
750 // ペアだけを使っていたため、入れ子の `<q>` で内側/外側とも同じ引用符になる
751 // バグがあった。
752 let quote_depth = ancestors
753 .iter()
754 .filter(|a| {
755 matches!(&a.node.node_type, NodeType::Element { tag_name, .. } if tag_name == "q")
756 })
757 .count();
758 for key in ["x-before-content", "x-after-content", "x-marker-content"] {
759 if let Some(raw) = specified_values.get(key).cloned() {
760 let resolved = parse_content_value(
761 &raw,
762 attributes,
763 &counters_out,
764 quotes_pairs.as_deref(),
765 quote_depth,
766 &stylesheet.counter_styles,
767 );
768 specified_values.insert(String::from(key), resolved);
769 }
770 }
771 }
772 NodeType::Text(_) => {
773 specified_values.insert(String::from("display"), String::from("inline"));
774 }
775 }
776 (specified_values, css_vars, counters_out, counters_reset_names)
777}
778
779/// 親から子へ継承される CSS プロパティ(テキストノードのレイアウト計測に必須)
780pub(crate) const INHERITED_PROPS: &[&str] = &[
781 "color",
782 "-webkit-text-fill-color",
783 // 【2026-08-03 発見・修正】`font-family` が抜けていた。
784 // CSS 2.1 §15.3 で font-family は**継承プロパティ**である。
785 // 抜けていたため `body { font-family: 'Noto Sans JP', sans-serif }` が
786 // 子孫へ伝わらず、テキスト要素の family が常に空になっていた。
787 // その結果、Web フォントを取得・展開・登録できても
788 // **描画で一度も選ばれなかった**
789 // (実測: 使われたのは要素自身に family 指定のあるアイコンフォントだけ)。
790 "font-family",
791 "font-size",
792 "font-weight",
793 "font-style",
794 "line-height",
795 "text-align",
796 "letter-spacing",
797 "word-spacing",
798 "white-space",
799 "text-transform",
800 "list-style-type",
801 "list-style-image",
802 "list-style-position",
803 "list-style",
804 "tab-size",
805 "direction",
806 "visibility",
807 // `text-indent` は継承プロパティ(ブロック要素に指定した値が子孫のテキストノードへ
808 // 伝播すべき)だが、以前は INHERITED_PROPS に含まれておらず、通常の書き方
809 // (`p{text-indent:2em}` のように親要素へ指定しテキストノード自身には指定しない)
810 // では常に無視されるバグだった(`list-style-image`/`list-style-position` と同種)。
811 "text-indent",
812 // `border-collapse` も継承プロパティ(`layout.rs` が `TableNode` 自身の値しか見ないため、
813 // `body{border-collapse:collapse}` のような祖先要素への指定が配下の `<table>` へ
814 // 伝播しないバグがあった。`table` 要素自身への直接指定は元々動作するため見落とされていた)。
815 "border-collapse",
816 // `text-emphasis-style`/`text-emphasis-color` も継承プロパティ(実 CSS 仕様)。
817 "text-emphasis-style",
818 "text-emphasis-color",
819 // `cursor` も継承プロパティ(実 CSS 仕様。祖先要素への指定が子孫へ伝播する)。
820 "cursor",
821 // `word-break`/`overflow-wrap`/`word-wrap` も継承プロパティ(CSS Text Module Specs)。
822 "word-break",
823 "overflow-wrap",
824 "word-wrap",
825];
826
827pub(crate) fn style_tree_with_ancestors<'a>(
828 root: &'a Node,
829 stylesheet: &StyleSheet,
830 ancestors: &[AncestorContext<'a>],
831 sibling_index: Option<usize>,
832 sibling_count: Option<usize>,
833) -> StyledNode<'a> {
834 let mut stack = ancestors.to_vec();
835 style_tree_with_ancestors_vars(
836 root,
837 stylesheet,
838 None,
839 &mut stack,
840 sibling_index,
841 sibling_count,
842 &alloc::rc::Rc::new(BTreeMap::new()),
843 &alloc::rc::Rc::new(BTreeMap::new()),
844 &BTreeMap::new(),
845 )
846 .0
847}
848
849/// `counter-reset`/`counter-increment`(CSS カウンタ)は文書順(深さ優先)で全カウンタを
850/// フラット共有する簡略化実装のため、`counters_in`/戻り値で状態を兄弟間・親子間に
851/// 連鎖させる必要がある(`.map()` では各要素が独立してしまうため使えない)。
852pub(crate) fn style_tree_with_ancestors_vars<'a>(
853 root: &'a Node,
854 stylesheet: &StyleSheet,
855 rule_index: Option<&super::rule_index::RuleIndex>,
856 ancestors_stack: &mut Vec<AncestorContext<'a>>,
857 sibling_index: Option<usize>,
858 sibling_count: Option<usize>,
859 parent_vars: &alloc::rc::Rc<BTreeMap<String, String>>,
860 parent_inherited: &alloc::rc::Rc<BTreeMap<String, String>>,
861 counters_in: &BTreeMap<String, i64>,
862) -> (StyledNode<'a>, BTreeMap<String, i64>, alloc::collections::BTreeSet<String>) {
863 let (mut specified_values, css_vars, counters_after_self, counters_reset_self) = compute_styles_with_vars(
864 root,
865 stylesheet,
866 rule_index,
867 ancestors_stack.as_slice(),
868 sibling_index,
869 sibling_count,
870 &[],
871 parent_vars,
872 counters_in,
873 );
874
875 // `all: unset` 等の一括リセットは、個別プロパティの CSS-wide keyword 処理より先に
876 // 行う(`all` 自身も含め、その後に個別の inherit/initial 等が残っていれば通常どおり処理する)。
877 resolve_all_shorthand(&mut specified_values);
878 // CSS-wide keywords(initial/inherit/unset/revert)を除去してから継承伝搬を行う。
879 // 除去しておかないと、例えば `color:inherit` の文字列 "inherit" 自体が
880 // specified_values に残ってしまい、後続の色パース等が本来の値ではなく
881 // 壊れた文字列を見てしまう(かつ次の継承伝搬ステップが「既に指定済み」と
882 // 誤認して親の値で埋めなくなる)。
883 strip_css_wide_keywords(&mut specified_values);
884
885 // 継承プロパティの伝搬: 自身で指定されていないものは親の値を引き継ぐ。
886 // 特にテキストノードはこれが無いと layout が常に既定 16px で計測してしまい、
887 // flatten(描画)側の継承サイズと食い違って折り返し幅・行送りが崩れる。
888 for (k, v) in parent_inherited.iter() {
889 if !specified_values.contains_key(k) {
890 specified_values.insert(k.clone(), v.clone());
891 }
892 }
893 // `margin-block`/`margin-inline`/`padding-block`/`padding-inline`/`inset-block`/
894 // `inset-inline`(-start/-end をまとめて指定するショートハンド)が丸ごと未対応で、
895 // longhand の `-start`/`-end` 個別指定しかできなかった。`resolve_logical_properties`
896 // が理解する longhand へ展開してから渡す(展開自体は方向非依存: `-start`/`-end` は
897 // 1〜2値の shorthand が持つ「1番目=start(値省略時は end にも流用)/2番目=end」という
898 // 構文規則のみに従うため、`direction` 解決より前に済ませてよい)。
899 expand_logical_box_shorthands(&mut specified_values);
900 // 論理プロパティ(margin-inline-start 等)の解決は、`direction` の継承伝搬が終わった
901 // 直後(=物理プロパティへの変換時点で inherited な direction も見える状態)で行う。
902 resolve_logical_properties(&mut specified_values);
903 // currentColor キーワードの解決は、継承伝搬により `color` が確定した直後に行う必要がある
904 // (継承された色を参照する `border-color:currentColor` 等が正しく解決されるようにするため)。
905 resolve_current_color(&mut specified_values);
906 // 【2026-07-31 性能修正】:hover / :focus / :active の事前計算を、
907 // **そのノードの候補ルールが実際にその状態を参照している時だけ**走らせる。
908 // 仕様は `spec/dynamic_pseudo_states.md`。
909 //
910 // 従来はノード 1 個につきカスケードを 4 回(通常+3 状態)走らせていた。
911 // 実測で `style_tree` のノード走査は 358 ノードで約 600 tick かかっており、
912 // セレクタ照合は索引で既に 2.0% まで落ちている(照合はボトルネックではない)。
913 // 残っていたのがこの 4 倍の固定コスト。
914 //
915 // 判定は候補ルールの**セレクタ構文だけ**を見る(D-3)。
916 // 候補が取れない場合は安全側に倒して従来どおり走らせる(D-4)。
917 let (uses_hover, uses_focus, uses_active) = match &root.node_type {
918 NodeType::Element {
919 tag_name,
920 classes,
921 id,
922 ..
923 } => {
924 if let Some(idx) = rule_index {
925 match idx.candidates(tag_name.as_str(), id.as_deref(), classes.as_slice()) {
926 Ok(cands) => match idx.states_of(&cands) {
927 Some(m) => (
928 m & super::rule_index::STATE_HOVER != 0,
929 m & super::rule_index::STATE_FOCUS != 0,
930 m & super::rule_index::STATE_ACTIVE != 0,
931 ),
932 None => (true, true, true),
933 },
934 Err(_) => (true, true, true),
935 }
936 } else {
937 (true, true, true)
938 }
939 }
940 // テキストノードはどのセレクタにも一致しないため、
941 // どの状態でも差分は生じ得ない(`selector.rs` の `NodeType::Text(_) => false`)。
942 _ => (false, false, false),
943 };
944
945 let hover_opt = if !uses_hover {
946 None
947 } else {
948 let (hover_values_raw, _, _, _) = compute_styles_with_vars(
949 root,
950 stylesheet,
951 rule_index,
952 ancestors_stack.as_slice(),
953 sibling_index,
954 sibling_count,
955 &["hover"],
956 &css_vars,
957 &counters_after_self,
958 );
959 let mut has_hover_diff = false;
960 for (k, v) in &hover_values_raw {
961 if specified_values.get(k) != Some(v) {
962 has_hover_diff = true;
963 break;
964 }
965 }
966 if has_hover_diff {
967 Some(hover_values_raw)
968 } else {
969 None
970 }
971 };
972
973 let focus_opt = if !uses_focus {
974 None
975 } else {
976 let (focus_values_raw, _, _, _) = compute_styles_with_vars(
977 root,
978 stylesheet,
979 rule_index,
980 ancestors_stack.as_slice(),
981 sibling_index,
982 sibling_count,
983 &["focus"],
984 &css_vars,
985 &counters_after_self,
986 );
987 let mut has_focus_diff = false;
988 for (k, v) in &focus_values_raw {
989 if specified_values.get(k) != Some(v) {
990 has_focus_diff = true;
991 break;
992 }
993 }
994 if has_focus_diff {
995 Some(focus_values_raw)
996 } else {
997 None
998 }
999 };
1000
1001 let active_opt = if !uses_active {
1002 None
1003 } else {
1004 let (active_values_raw, _, _, _) = compute_styles_with_vars(
1005 root,
1006 stylesheet,
1007 rule_index,
1008 ancestors_stack.as_slice(),
1009 sibling_index,
1010 sibling_count,
1011 &["active"],
1012 &css_vars,
1013 &counters_after_self,
1014 );
1015 let mut has_active_diff = false;
1016 for (k, v) in &active_values_raw {
1017 if specified_values.get(k) != Some(v) {
1018 has_active_diff = true;
1019 break;
1020 }
1021 }
1022 if has_active_diff {
1023 Some(active_values_raw)
1024 } else {
1025 None
1026 }
1027 };
1028
1029 // 子へ渡す継承プロパティ集合(自身の確定値から抽出)
1030 let mut has_any_new_inherited = false;
1031 for k in INHERITED_PROPS {
1032 if let Some(v) = specified_values.get(*k) {
1033 if parent_inherited.get(*k) != Some(v) {
1034 has_any_new_inherited = true;
1035 break;
1036 }
1037 }
1038 }
1039 let inherited_for_children = if !has_any_new_inherited {
1040 parent_inherited.clone()
1041 } else {
1042 let mut map = BTreeMap::new();
1043 for k in INHERITED_PROPS {
1044 if let Some(v) = specified_values.get(*k) {
1045 map.insert(String::from(*k), v.clone());
1046 }
1047 }
1048 alloc::rc::Rc::new(map)
1049 };
1050
1051 ancestors_stack.push(AncestorContext {
1052 node: root,
1053 sibling_index,
1054 sibling_count,
1055 });
1056 let child_count = root.children.len();
1057 let mut counters_running = counters_after_self;
1058 let mut children = alloc::vec::Vec::with_capacity(child_count);
1059 for (idx, child) in root.children.iter().enumerate() {
1060 // 子へ渡す直前のスナップショット。子(またはその子孫)が counter-reset した
1061 // 名前は、次の兄弟へは「この子に入る前の値」まで復元してから渡す(CSS 仕様の
1062 // カウンタスコープ規則: counter-reset は新しいスコープを作り、その効果は
1063 // リセットした要素の外(後続の兄弟)へ漏れてはならない)。子から返る
1064 // 第3要素は「その子自身が counter-reset したか」だけを表す(子の孫でリセット
1065 // された分は既に子自身の同じループで吸収済みのため、ここでは子自身の分だけを
1066 // 見れば十分で、それより深い階層のリセットを重ねて伝播させる必要は無い)。
1067 // counter-reset を伴わない通常の counter-increment(同一リストの `<li>` を
1068 // 跨いで番号を積み上げる、 professionsカウンタ本来の用途)はこの対象にならないため
1069 // 従来通り兄弟間で正しく蓄積される。
1070 let counters_before_child = if counters_running.is_empty() {
1071 None
1072 } else {
1073 Some(counters_running.clone())
1074 };
1075 let (child_styled, counters_after_child, reset_names_in_child) =
1076 style_tree_with_ancestors_vars(
1077 child,
1078 stylesheet,
1079 rule_index,
1080 ancestors_stack,
1081 Some(idx),
1082 Some(child_count),
1083 &css_vars,
1084 &inherited_for_children,
1085 &counters_running,
1086 );
1087 counters_running = counters_after_child;
1088 if !reset_names_in_child.is_empty() {
1089 if let Some(before) = &counters_before_child {
1090 for name in &reset_names_in_child {
1091 match before.get(name) {
1092 Some(v) => {
1093 counters_running.insert(name.clone(), *v);
1094 }
1095 None => {
1096 counters_running.remove(name);
1097 }
1098 }
1099 }
1100 } else {
1101 for name in &reset_names_in_child {
1102 counters_running.remove(name);
1103 }
1104 }
1105 }
1106 children.push(child_styled);
1107 }
1108 ancestors_stack.pop();
1109
1110 (
1111 StyledNode {
1112 node: root,
1113 specified_values,
1114 hover_values: hover_opt,
1115 focus_values: focus_opt,
1116 active_values: active_opt,
1117 css_vars: css_vars.clone(),
1118 children,
1119 },
1120 counters_running,
1121 counters_reset_self,
1122 )
1123}
1124