From c69fafae2111e9e6c6853cc6318b084be3e7a6b5 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:59:43 +0000 Subject: [PATCH] Fix modern stylesheet and image geometry --- crates/obscura-browser/src/page.rs | 242 ++++++++++++++++----- crates/obscura-render/src/dom.rs | 24 +- crates/obscura-render/src/paint.rs | 47 +++- crates/obscura-render/tests/layout_test.rs | 33 +++ 4 files changed, 286 insertions(+), 60 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 0e4ccc5..729d124 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -438,14 +438,76 @@ fn materialize_linked_stylesheet_script(link_index: usize, css: &str) -> String var links = document.querySelectorAll('link[rel~="stylesheet"]'); var link = links[{link_index}]; if (!link || !link.parentNode) return; - var style = document.createElement('style'); - style.setAttribute('data-obscura-external-stylesheets', ''); - style.textContent = `{escaped_css}`; - link.parentNode.insertBefore(style, link.nextSibling); + var style = null; + function effectiveMedia() {{ + // Until the generic Element shim reflects HTMLLinkElement.media, + // `this.media = "all"` creates an own property while the parsed + // media="print" attribute remains unchanged. + if (Object.prototype.hasOwnProperty.call(link, 'media')) {{ + return String(link.media || ''); + }} + return link.getAttribute('media') || ''; + }} + function applies() {{ + var media = effectiveMedia().trim(); + if (!media) return true; + try {{ return window.matchMedia(media).matches; }} + catch (_) {{ return false; }} + }} + function syncSheet() {{ + var enabled = link.parentNode + && !link.disabled + && !link.hasAttribute('disabled') + && applies(); + if (!enabled) {{ + if (style && style.parentNode) style.parentNode.removeChild(style); + return; + }} + if (!style) {{ + style = document.createElement('style'); + style.setAttribute('data-obscura-external-stylesheets', ''); + style.textContent = `{escaped_css}`; + }} + if (!style.parentNode) {{ + link.parentNode.insertBefore(style, link.nextSibling); + }} + }} + + // A non-matching sheet still loads and fires its event. Its handler + // may then make the sheet applicable (the common + // media=print/onload="this.media='all'" async-CSS pattern). + syncSheet(); + try {{ link.dispatchEvent(new Event('load')); }} + finally {{ syncSheet(); }} }})()"# ) } +/// Discover linked author sheets in document order. +/// +/// Media queries control whether a loaded sheet participates in the cascade; +/// they do not suppress its fetch or `load` event. Keep the index among all +/// stylesheet links so the materialization script addresses the same node. +fn linked_stylesheet_requests(dom: &DomTree) -> Vec<(usize, String)> { + let link_ids = dom + .query_selector_all("link[rel~=\"stylesheet\"]") + .unwrap_or_default(); + let mut links = Vec::new(); + for (link_index, lid) in link_ids.into_iter().enumerate() { + if let Some(node) = dom.get_node(lid) { + // Disabled alternate sheets remain dormant until script enables + // them. Media-gated sheets are different: they still load. + if node.get_attribute("disabled").is_some() { + continue; + } + if let Some(href) = node.get_attribute("href") { + links.push((link_index, href.to_string())); + } + } + } + links +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct HydrationStats { elements: usize, @@ -772,53 +834,7 @@ impl Page { async fn fetch_stylesheets(&mut self) { let all_links = match &self.js { - Some(js) => { - js.with_dom(|dom| { - // `rel` is a space-separated token list. VitePress and - // other modern generators combine preload and stylesheet - // (`rel="preload stylesheet"`); exact-value matching - // silently skipped their entire CSS payload. - let link_ids = dom - .query_selector_all("link[rel~=\"stylesheet\"]") - .unwrap_or_default(); - let mut links = Vec::new(); - for (link_index, lid) in link_ids.into_iter().enumerate() { - if let Some(node) = dom.get_node(lid) { - // Skip disabled stylesheets: a `` - // does not apply until JS clears the attribute. Sites - // ship alternate themes this way (e.g. Google devsite's - // dark-theme.css is linked `disabled` and toggled by a - // theme switcher); applying it painted whole pages dark - // when Chromium renders them light. `disabled` is a - // boolean attribute, so any presence means off. - if node.get_attribute("disabled").is_some() { - continue; - } - // Respect the link's `media` gate. A `` must not apply on screen (the - // Guardian ships a print sheet that otherwise - // overrode the screen masthead), and a `` must not - // apply in our light desktop context (docs.python and - // other Sphinx sites link pydoctheme_dark.css this way; - // injecting it painted the page dark under a light - // Chromium). We render the default light context, so - // skip sheets gated to print-only or dark preference. - if let Some(media) = node.get_attribute("media") { - let compact: String = media.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect(); - let print_only = compact.contains("print") && !compact.contains("screen") && !compact.contains("all"); - if print_only || compact.contains("prefers-color-scheme:dark") { - continue; - } - } - if let Some(href) = node.get_attribute("href") { - links.push((link_index, href.to_string())); - } - } - } - links - }).unwrap_or_default() - } + Some(js) => js.with_dom(linked_stylesheet_requests).unwrap_or_default(), None => { tracing::info!("fetch_stylesheets: no js runtime"); return; @@ -2338,9 +2354,10 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool { #[cfg(test)] mod tests { use super::{ - materialize_linked_stylesheet_script, parse_import_url, rebase_css_urls, - should_restore_hydration_snapshot, split_css_imports, script_response_is_executable, - truncate_on_char_boundary, url_matches_cdp_pattern, HydrationStats, + linked_stylesheet_requests, materialize_linked_stylesheet_script, parse_import_url, + rebase_css_urls, should_restore_hydration_snapshot, split_css_imports, + script_response_is_executable, truncate_on_char_boundary, url_matches_cdp_pattern, + HydrationStats, }; use obscura_dom::parse_html; @@ -2484,6 +2501,123 @@ mod tests { ); } + #[test] + fn media_gated_stylesheets_are_fetched_but_disabled_sheets_are_not() { + let dom = parse_html( + r#" + + + "#, + ); + + assert_eq!( + linked_stylesheet_requests(&dom), + vec![ + (0, "screen.css".to_string()), + (1, "async.css".to_string()), + (2, "dark.css".to_string()), + ] + ); + } + + #[test] + fn print_media_onload_can_activate_a_fetched_stylesheet() { + let dom = parse_html( + r#" + + "#, + ); + let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new(); + runtime.set_dom(dom); + runtime.run_page_init(); + runtime + .execute_script( + "", + &materialize_linked_stylesheet_script(0, ".target{color:red}"), + ) + .expect("load and materialize async linked sheet"); + + let state = runtime + .with_dom(|dom| { + let link = dom + .query_selector("#async") + .expect("valid selector") + .expect("async link"); + let styles = dom + .query_selector_all("style[data-obscura-external-stylesheets]") + .expect("valid selector"); + ( + dom.get_node(link) + .and_then(|node| { + node.get_attribute("data-loaded").map(str::to_owned) + }), + styles.first().map(|&nid| dom.text_content(nid)), + ) + }) + .expect("live DOM"); + + assert_eq!( + state.0.as_deref(), + Some("yes"), + "link load handler must run" + ); + assert_eq!( + state.1.as_deref(), + Some(".target{color:red}"), + "the handler's `this.media = 'all'` must activate the sheet" + ); + } + + #[test] + fn true_print_stylesheet_loads_without_entering_screen_cascade() { + let dom = parse_html( + r#" + + "#, + ); + let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new(); + runtime.set_dom(dom); + runtime.run_page_init(); + runtime + .execute_script( + "", + &materialize_linked_stylesheet_script(0, "body{display:none}"), + ) + .expect("finish print linked sheet load"); + + let state = runtime + .with_dom(|dom| { + let link = dom + .query_selector("#print") + .expect("valid selector") + .expect("print link"); + ( + dom.get_node(link) + .and_then(|node| { + node.get_attribute("data-loaded").map(str::to_owned) + }), + dom.query_selector_all("style[data-obscura-external-stylesheets]") + .expect("valid selector") + .len(), + ) + }) + .expect("live DOM"); + + assert_eq!( + state.0.as_deref(), + Some("yes"), + "print link still fires load" + ); + assert_eq!( + state.1, 0, + "print-only CSS must stay out of screen cascade" + ); + } + #[test] fn external_stylesheets_keep_their_positions_between_inline_sheets() { let dom = parse_html( diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 295d972..8df5e62 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -4251,6 +4251,26 @@ fn is_full_span_column_subgrid(style: &crate::LayoutStyle) -> bool { ) } +/// Whether an item is wholly eligible for ordinary grid auto-placement. +/// +/// The style model retains an explicit `grid-area:auto` as `Some(Line { +/// Auto, Auto })`, while an omitted placement stays `None`. Both represent +/// the same indefinite row and column spans to the grid placement algorithm. +/// Raw named placements are never auto: an unresolved name must not enter the +/// bounded subgrid reduction merely because it has no numeric `Line` yet. +fn has_only_auto_grid_placement(style: &crate::LayoutStyle) -> bool { + if style.grid_column_raw.is_some() || style.grid_row_raw.is_some() { + return false; + } + let axis_is_auto = |line: Option<&taffy::Line>| { + line.is_none_or(|line| { + matches!(line.start, taffy::GridPlacement::Auto) + && matches!(line.end, taffy::GridPlacement::Auto) + }) + }; + axis_is_auto(style.grid_column.as_ref()) && axis_is_auto(style.grid_row.as_ref()) +} + /// Collect the deliberately bounded Grid Level 2 subset used by broad /// "aligned rows" components: a full-span column subgrid, optionally nested /// through more full-span column subgrids, whose final children auto-place one @@ -4331,7 +4351,9 @@ fn collect_column_subgrid_descendants( // This first subset intentionally excludes spanning and explicitly // placed items. It is the common data-row/card-row pattern and keeps // each descendant contribution attributable to one ancestor track. - if child_style.grid_column.is_some() + // Explicit `grid-area:auto` remains ordinary auto-placement in both + // axes and is therefore equivalent to omitting all placement values. + if !has_only_auto_grid_placement(child_style) || child_style.position == Some(taffy::Position::Absolute) || child_style.margin_auto[1] || child_style.margin_auto[3] diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 69ea183..fd1ba33 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -3907,11 +3907,12 @@ fn background_image_rect( _ => None, } }); - let (width, height) = if let Some(size) = expression_size { - size - } else if let Some(size) = explicit_size { - size - } else if let Some(fit) = fit { + // `cover` and `contain` are complete sizing algorithms, not contextual + // lengths. The style layer deliberately retains the authored token string + // for CSS math, so a keyword can also appear in `size_expression`; run the + // typed fit first or the unresolved token path falls back to the intrinsic + // size and silently bypasses fitting. + let (width, height) = if let Some(fit) = fit { let (iw, ih) = intrinsic?; let scale = match fit { crate::ObjectFit::Cover => (box_rect.width / iw).max(box_rect.height / ih), @@ -3919,6 +3920,10 @@ fn background_image_rect( _ => 1.0, }; (iw * scale, ih * scale) + } else if let Some(size) = expression_size { + size + } else if let Some(size) = explicit_size { + size } else { intrinsic.unwrap_or((box_rect.width, box_rect.height)) }; @@ -6394,6 +6399,38 @@ mod tests { ); } + #[test] + fn cover_and_contain_background_sizes_fit_the_positioning_area() { + let image = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='200'%20height='100'%3E%3Crect%20width='200'%20height='100'%20fill='blue'/%3E%3C/svg%3E"; + let tree = parse_html(&format!( + r#" +
+
+ "# + )); + let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); + + let cover_edge = pixmap.pixel(50, 10).expect("cover edge"); + assert!( + cover_edge.blue() > 200 && cover_edge.red() < 40, + "cover must scale the 2:1 image to the owner's 200px block axis: {cover_edge:?}" + ); + + let contain_outside = pixmap.pixel(150, 60).expect("contain letterbox"); + assert!( + contain_outside.red() > 200 && contain_outside.blue() < 40, + "contain must leave the area outside its centered 100x50 image unpainted: {contain_outside:?}" + ); + let contain_center = pixmap.pixel(150, 100).expect("contain center"); + assert!( + contain_center.blue() > 200 && contain_center.red() < 40, + "contain must paint the fitted image through the center: {contain_center:?}" + ); + } + #[test] fn ordered_background_gradients_paint_every_translucent_layer() { let tree = parse_html( diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index aa99d4e..282e88f 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -2410,6 +2410,7 @@ fn nested_full_span_column_subgrid_shares_ancestor_tracks() { grid-column:1 / -1; grid-template-columns:subgrid; } + .inner > * { grid-area:auto } .inner { column-gap:20px } .leaf { height:20px } .a { width:40px } @@ -2490,12 +2491,16 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() { } #narrow { width:200px } #partial { width:500px } + #placed { width:500px } + #named { width:500px } .subgrid, .inner { display:grid; grid-column:1 / -1; grid-template-columns:subgrid; } #partial-row { grid-column:1 / 3 } + #placed-b { grid-column:2 } + #named-b { grid-column:missing-line } .inner { column-gap:20px } .leaf { height:20px } .a { width:40px } @@ -2516,6 +2521,20 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
"#, ); let layout = layout_dom(&tree, (800.0, 600.0)); @@ -2524,6 +2543,10 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() { let narrow_b = rect("narrow-b"); let partial = rect("partial"); let partial_b = rect("partial-b"); + let placed = rect("placed"); + let placed_b = rect("placed-b"); + let named = rect("named"); + let named_b = rect("named-b"); assert!( (narrow.height - 60.0).abs() < 0.01 && (narrow_b.x - narrow.x).abs() < 0.01, @@ -2533,6 +2556,16 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() { (partial.height - 60.0).abs() < 0.01 && (partial_b.x - partial.x).abs() < 0.01, "partial-span subgrid must retain the stacked fallback: {partial:?} {partial_b:?}" ); + assert!( + (placed.height - 40.0).abs() < 0.01 && placed_b.x > placed.x, + "an explicitly placed leaf must retain the existing placement fallback: \ + {placed:?} {placed_b:?}" + ); + assert!( + (named.height - 60.0).abs() < 0.01 && (named_b.x - named.x).abs() < 0.01, + "an unresolved named placement must not enter the numeric subgrid reduction: \ + {named:?} {named_b:?}" + ); } #[test]