From 10b849211643a1efe915affe15a8fa90fa5a946c Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:24:57 +0000 Subject: [PATCH] Improve modern layout and viewport rendering --- Cargo.lock | 1 + crates/obscura-browser/src/page.rs | 49 +- crates/obscura-cdp/src/domains/page.rs | 91 +- crates/obscura-cli/src/main.rs | 119 +- .../tests/screenshot_after_eval.rs | 73 ++ crates/obscura-dom/src/selector.rs | 16 + crates/obscura-js/js/bootstrap.js | 777 ++++++++++-- crates/obscura-js/src/lib.rs | 2 +- crates/obscura-js/src/ops.rs | 182 ++- crates/obscura-js/src/runtime.rs | 776 ++++++++++++ crates/obscura-render/Cargo.toml | 6 +- crates/obscura-render/src/css.rs | 443 ++++++- crates/obscura-render/src/dom.rs | 999 +++++++++++++++- crates/obscura-render/src/inline.rs | 503 ++++++-- crates/obscura-render/src/lib.rs | 50 +- crates/obscura-render/src/paint.rs | 1050 ++++++++++++++++- crates/obscura-render/src/style.rs | 647 +++++++++- .../tests/fonts/LICENSE-OFL-1.1.txt | 92 ++ .../obscura-render/tests/fonts/PROVENANCE.txt | 14 + .../tests/fonts/obscura-vf-test.woff2.b64 | 65 + crates/obscura-render/tests/layout_test.rs | 459 +++++++ vendor/taffy/src/compute/grid/track_sizing.rs | 14 +- 22 files changed, 5978 insertions(+), 450 deletions(-) create mode 100644 crates/obscura-cli/tests/screenshot_after_eval.rs create mode 100644 crates/obscura-render/tests/fonts/LICENSE-OFL-1.1.txt create mode 100644 crates/obscura-render/tests/fonts/PROVENANCE.txt create mode 100644 crates/obscura-render/tests/fonts/obscura-vf-test.woff2.b64 diff --git a/Cargo.lock b/Cargo.lock index 2e94ec5..c2fdc34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2070,6 +2070,7 @@ dependencies = [ "image", "obscura-dom", "resvg", + "swash", "taffy", "tiny-skia", "ureq", diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 48f6a20..e617e3b 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -1790,7 +1790,15 @@ impl Page { // the overwhelming majority of real markup. let base_url = self.resolve_base_url(); let base_url = base_url.as_ref().map(|u| u.as_str()); - self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport, base_url)).flatten() + let scroll = self + .js + .as_ref() + .map(|js| js.scroll_offset()) + .unwrap_or((0.0, 0.0)); + self.with_dom(|dom| { + obscura_js::screenshot_png_scrolled(dom, viewport, base_url, scroll) + }) + .flatten() } /// Absolute URLs the page pulled in via fetch()/XHR (issue #301). Empty @@ -2288,6 +2296,45 @@ mod tests { }; use obscura_dom::parse_html; + #[cfg(feature = "render")] + #[test] + fn page_screenshot_uses_the_live_window_scroll_offset() { + let context = std::sync::Arc::new(crate::BrowserContext::new("scroll-test".to_string())); + let mut page = super::Page::new("scroll-page".to_string(), context); + page.set_viewport((100.0, 80.0)); + + let dom = parse_html( + r#" +
+
+
+ "#, + ); + let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new(); + runtime.set_dom(dom); + runtime.set_url("https://example.test/scroll"); + runtime.set_viewport(100.0, 80.0); + runtime.run_page_init(); + page.js = Some(runtime); + page.url = Some(url::Url::parse("https://example.test/scroll").unwrap()); + + let before = page.screenshot(page.viewport).expect("top screenshot"); + assert_eq!( + page.evaluate( + "return (document.getElementById('second').scrollIntoView(), window.scrollY)" + ) + .as_f64(), + Some(80.0) + ); + let after = page.screenshot(page.viewport).expect("scrolled screenshot"); + + assert_ne!(before, after, "Page screenshot must paint the scrolled viewport"); + assert_eq!( + page.js.as_ref().expect("runtime").scroll_offset(), + (0.0, 80.0) + ); + } + #[test] fn truncate_never_splits_a_multibyte_char() { // A caller-supplied expression whose byte 80 lands inside a multi-byte diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 44f8f7c..e1849e6 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -406,25 +406,47 @@ pub async fn handle( .get_session_page(session_id) .map(|page| (page.viewport.0 as f64, page.viewport.1 as f64)) .unwrap_or((1280.0, 720.0)); - let content_height = ctx + let mut page_x = 0.0; + let mut page_y = 0.0; + let mut content_width = width; + let mut content_height = height; + if let Some(values) = ctx .get_session_page_mut(session_id) - .map(|p| p.evaluate("document.documentElement && document.documentElement.scrollHeight")) - .and_then(|v| v.as_f64()) - .filter(|n| *n > 0.0) - .unwrap_or(height); + .map(|page| { + page.evaluate( + "[window.scrollX, window.scrollY, \ + document.documentElement && document.documentElement.scrollWidth, \ + document.documentElement && document.documentElement.scrollHeight]", + ) + }) + .and_then(|value| value.as_array().cloned()) + { + page_x = values.first().and_then(Value::as_f64).unwrap_or(0.0); + page_y = values.get(1).and_then(Value::as_f64).unwrap_or(0.0); + content_width = values + .get(2) + .and_then(Value::as_f64) + .filter(|value| *value > 0.0) + .unwrap_or(width); + content_height = values + .get(3) + .and_then(Value::as_f64) + .filter(|value| *value > 0.0) + .unwrap_or(height); + } let layout_viewport = json!({ - "pageX": 0, "pageY": 0, + "pageX": page_x, "pageY": page_y, "clientWidth": width, "clientHeight": height, }); let visual_viewport = json!({ "offsetX": 0.0, "offsetY": 0.0, - "pageX": 0.0, "pageY": 0.0, + "pageX": page_x, "pageY": page_y, "clientWidth": width, "clientHeight": height, "scale": 1.0, "zoom": 1.0, }); let content_size = json!({ "x": 0.0, "y": 0.0, - "width": width, "height": content_height, + "width": content_width, "height": content_height, }); Ok(json!({ "layoutViewport": layout_viewport, @@ -606,6 +628,59 @@ mod tests { assert_eq!(content["height"].as_f64(), Some(720.0)); } + #[cfg(feature = "render")] + #[tokio::test] + async fn cdp_metrics_and_capture_follow_the_scrolled_viewport() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = format!("{page_id}-session"); + ctx.sessions.insert(session_id.clone(), page_id); + let session = Some(session_id); + ctx.get_session_page_mut(&session) + .expect("page") + .set_viewport((100.0, 80.0)); + + handle( + "navigate", + &json!({ + "url": "data:text/html,
", + "waitUntil": "load", + }), + &mut ctx, + &session, + ) + .await + .expect("navigate"); + + let top = handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("top capture"); + ctx.get_session_page_mut(&session) + .expect("page") + .evaluate("return (window.scrollTo(0, 80), window.scrollY)"); + + let metrics = handle("getLayoutMetrics", &json!({}), &mut ctx, &session) + .await + .expect("metrics"); + assert_eq!(metrics["layoutViewport"]["pageY"].as_f64(), Some(80.0)); + assert_eq!(metrics["visualViewport"]["pageY"].as_f64(), Some(80.0)); + assert_eq!(metrics["contentSize"]["width"].as_f64(), Some(100.0)); + assert!( + metrics["contentSize"]["height"].as_f64().unwrap_or_default() >= 160.0, + "contentSize must expose the scrollable document: {metrics}" + ); + + let scrolled = handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("scrolled capture"); + let top_data = top["data"].as_str().expect("top png"); + let scrolled_data = scrolled["data"].as_str().expect("scrolled png"); + assert_ne!( + top_data, scrolled_data, + "CDP captureScreenshot must paint the current scroll offset" + ); + } + #[tokio::test] async fn unknown_page_method_still_errors() { let mut ctx = CdpContext::new(); diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 6e6be9a..f2864e8 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -641,10 +641,21 @@ async fn run_fetch( // nor by the V8 watchdog (terminate_execution only unwinds JS bytecode, not // native Rust running beneath a V8->op call). As an absolute backstop so one // fetch can never wedge the worker, a daemon thread force-exits if the whole - // operation overruns timeout + wait + grace. A normal fetch returns first and - // the process exits before this fires. + // operation overruns navigation + every configured settle pass + grace. A + // normal fetch returns first and the process exits before this fires. { - let hard = Duration::from_secs(timeout_secs.saturating_add(wait_secs).saturating_add(10)); + let settle_passes = if eval.is_some() + && (screenshot.is_some() || selector.is_some() || dump_specified) + { + 2 + } else { + 1 + }; + let hard = Duration::from_secs( + timeout_secs + .saturating_add(wait_secs.saturating_mul(settle_passes)) + .saturating_add(10), + ); std::thread::spawn(move || { std::thread::sleep(hard); eprintln!("obscura: hard timeout exceeded ({}s); forcing exit", hard.as_secs()); @@ -671,11 +682,62 @@ async fn run_fetch( // pages stay fast. page.settle(wait_secs.saturating_mul(1000)).await; - // --screenshot renders the settled page to a PNG. Requires the render - // feature; without it, page.screenshot is absent and we report clearly. + let mut deferred_eval_output = None; + if let Some(ref expr) = eval { + // Bound the eval by the same budget as navigation so a runaway + // expression (infinite loop, never-settling sync work) cannot hang. + let result = page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)); + + // A bare --eval (no --selector, --dump, or --screenshot) returns the + // eval value directly, so synchronous expressions + // (JSON.stringify, ...) are unchanged. Screenshot captures continue + // below so an evaluation such as scrollTo() affects the painted + // viewport instead of being silently ignored. + if !dump_specified && selector.is_none() && screenshot.is_none() { + let rendered = match result { + serde_json::Value::String(s) => s, + serde_json::Value::Null => "null".to_string(), + other => other.to_string(), + }; + write_or_print(rendered, output.as_ref()).await?; + context.save_cookies(); + return Ok(()); + } + if screenshot.is_some() { + deferred_eval_output = Some(result); + } + + // --eval combined with --selector, --dump, and/or --screenshot + // typically kicks off async work (a fetch promise, a timer, a scroll + // listener) that writes the DOM. Drive the event loop again so that + // work completes, then fall through to selector/capture/dump instead + // of returning the still-pending eval value (issue #248). + page.settle(wait_secs.saturating_mul(1000)).await; + } + + if let Some(ref sel) = selector { + let found = wait_for_selector(&mut page, sel, wait_secs).await; + if !found { + eprintln!("Warning: selector '{}' not found after {}s", sel, wait_secs); + } + } + + // --screenshot renders the settled, optionally evaluated page to a PNG. + // Requires the render feature; without it, page.screenshot is absent and + // we report clearly. if let Some(ref path) = screenshot { #[cfg(feature = "render")] { + let capture_state = deferred_eval_output.as_ref().map(|_| { + page.evaluate( + "(()=>({\ + scrollX:window.scrollX,scrollY:window.scrollY,\ + innerWidth:window.innerWidth,innerHeight:window.innerHeight,\ + scrollWidth:document.documentElement?document.documentElement.scrollWidth:0,\ + scrollHeight:document.documentElement?document.documentElement.scrollHeight:0\ + }))()", + ) + }); // Default CSS-pixel viewport, matching the engine's innerWidth/Height. // OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to // capture below-the-fold content in one shot). @@ -684,6 +746,19 @@ async fn run_fetch( Some(bytes) => std::fs::write(path, &bytes)?, None => anyhow::bail!("screenshot failed: page has no DOM to render"), } + // A screenshot+eval command used to ignore the expression + // completely. Emit both its value and a standard state sampled + // after the post-eval settle so automation can record the exact + // live viewport that was painted. + if let Some(result) = deferred_eval_output { + println!( + "{}", + serde_json::json!({ + "evaluation": result, + "captureState": capture_state.unwrap_or(serde_json::Value::Null), + }) + ); + } if !quiet { eprintln!("Screenshot written: {} ({} bytes)", path.display(), std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)); } @@ -699,40 +774,6 @@ async fn run_fetch( } } - if let Some(ref expr) = eval { - // Bound the eval by the same budget as navigation so a runaway - // expression (infinite loop, never-settling sync work) cannot hang. - let result = page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)); - - // A bare --eval (no --selector, no --dump) returns the eval value - // directly, so synchronous expressions (JSON.stringify, ...) are - // unchanged. - if !dump_specified && selector.is_none() { - let rendered = match result { - serde_json::Value::String(s) => s, - serde_json::Value::Null => "null".to_string(), - other => other.to_string(), - }; - write_or_print(rendered, output.as_ref()).await?; - context.save_cookies(); - return Ok(()); - } - - // --eval combined with --selector and/or --dump: the eval typically - // kicks off async work (a fetch promise, a timer) that writes the DOM. - // Drive the event loop again so that work completes, then fall through - // to the selector wait and the dump path instead of returning the - // still-pending eval value (issue #248). - page.settle(wait_secs.saturating_mul(1000)).await; - } - - if let Some(ref sel) = selector { - let found = wait_for_selector(&mut page, sel, wait_secs).await; - if !found { - eprintln!("Warning: selector '{}' not found after {}s", sel, wait_secs); - } - } - let rendered = match dump { DumpFormat::Html => dump_html(&page), DumpFormat::Text => dump_text(&mut page), diff --git a/crates/obscura-cli/tests/screenshot_after_eval.rs b/crates/obscura-cli/tests/screenshot_after_eval.rs new file mode 100644 index 0000000..b685f71 --- /dev/null +++ b/crates/obscura-cli/tests/screenshot_after_eval.rs @@ -0,0 +1,73 @@ +#![cfg(feature = "render")] + +use std::process::Command; + +fn output_directory() -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!( + "obscura-screenshot-after-eval-{}-{unique}", + std::process::id() + )) +} + +#[test] +fn screenshot_is_captured_after_eval_scrolls_the_live_page() { + let directory = output_directory(); + std::fs::create_dir_all(&directory).expect("temporary output directory"); + let top = directory.join("top.png"); + let scrolled = directory.join("scrolled.png"); + let url = concat!( + "data:text/html,", + "
", + "
", + "" + ); + + let run = |path: &std::path::Path, eval: Option<&str>| { + let mut command = Command::new(env!("CARGO_BIN_EXE_obscura")); + command + .args(["fetch", url, "--screenshot"]) + .arg(path) + .args(["--wait", "0", "--timeout", "5", "--quiet"]) + .env("OBSCURA_SHOT_W", "100") + .env("OBSCURA_SHOT_H", "80"); + if let Some(expression) = eval { + command.args(["--eval", expression]); + } + command.output().expect("run obscura fetch") + }; + + let top_output = run(&top, None); + assert!( + top_output.status.success(), + "top capture failed: {}", + String::from_utf8_lossy(&top_output.stderr) + ); + + let scrolled_output = run( + &scrolled, + Some( + "(()=>{window.scrollTo(0,80);return JSON.stringify({y:window.scrollY})})()", + ), + ); + assert!( + scrolled_output.status.success(), + "scrolled capture failed: {}", + String::from_utf8_lossy(&scrolled_output.stderr) + ); + let state: serde_json::Value = + serde_json::from_slice(&scrolled_output.stdout).expect("evaluation state JSON"); + assert_eq!(state["captureState"]["scrollY"].as_f64(), Some(80.0)); + + let top_bytes = std::fs::read(&top).expect("top PNG"); + let scrolled_bytes = std::fs::read(&scrolled).expect("scrolled PNG"); + assert_ne!( + top_bytes, scrolled_bytes, + "the post-eval screenshot must paint the scrolled viewport" + ); + + std::fs::remove_dir_all(directory).expect("remove temporary output directory"); +} diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index 00692d1..88f3917 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -1031,6 +1031,22 @@ mod tests { assert_eq!(node.as_element().unwrap().local.as_ref(), "span"); } + #[test] + fn compiled_nested_is_descendant_matches_with_ancestor_filter() { + let tree = parse_html( + r#"
x
"#, + ); + let target = tree.get_element_by_id("target").unwrap(); + let compiled = tree + .compile_rule_selector(r#":is(.\*\*\:\[\.line\]\:block *).line"#) + .unwrap(); + let mut matcher = tree.matcher(); + for ancestor in tree.ancestors(target).into_iter().rev() { + matcher.push_ancestor(&tree, ancestor); + } + assert!(matcher.matches(&tree, target, &compiled)); + } + #[test] fn test_query_selector_attribute() { let tree = parse_html( diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 81807d9..3ef8fe4 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -657,8 +657,66 @@ globalThis.setInterval = (fn, delay = 0, ...args) => { }; globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); }; -globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); -globalThis.cancelAnimationFrame = globalThis.clearTimeout; + +// Animation callbacks are a rendering-phase batch, not zero-delay +// microtasks. In particular, a callback which queues itself must yield to +// timers, networking, and the embedder between frames. The old setTimeout(0) +// alias eventually used Promise.resolve(), so a normal animation loop formed +// an unbounded microtask chain and pinned V8 until the watchdog terminated it. +const _RAF_FRAME_DELAY_MS = 16; +let _rafPending = new Map(); +let _rafCurrentBatch = null; +let _rafFrameScheduled = false; +let _rafRunningFrame = false; + +function _scheduleAnimationFrame() { + if (_rafFrameScheduled || _rafRunningFrame || _rafPending.size === 0) return; + _rafFrameScheduled = true; + _scheduleAfter(_RAF_FRAME_DELAY_MS, () => { + _rafFrameScheduled = false; + if (_rafPending.size === 0) return; + + // Swap before invoking anything. A callback requested while this batch is + // running therefore belongs to the next frame. Every callback in this + // batch receives the same rendering timestamp. + const batch = _rafPending; + _rafPending = new Map(); + _rafCurrentBatch = batch; + _rafRunningFrame = true; + const timestamp = performance.now(); + try { + for (const [id, callback] of batch) { + // cancelAnimationFrame() may remove a later callback while an earlier + // callback in the same frame is running. + if (!batch.has(id)) continue; + batch.delete(id); + try { callback(timestamp); } + catch (e) { console.error("Animation frame error:", e); } + } + } finally { + _rafRunningFrame = false; + _rafCurrentBatch = null; + _scheduleAnimationFrame(); + } + }); +} + +globalThis.requestAnimationFrame = (fn) => { + if (typeof fn !== "function") { + throw new TypeError( + "Failed to execute 'requestAnimationFrame' on 'Window': parameter 1 is not of type 'Function'." + ); + } + const id = ++_tid; + _rafPending.set(id, fn); + _scheduleAnimationFrame(); + return id; +}; + +globalThis.cancelAnimationFrame = (id) => { + _rafPending.delete(id); + if (_rafCurrentBatch) _rafCurrentBatch.delete(id); +}; globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn)); class MessageChannel { @@ -2687,49 +2745,103 @@ class Element extends Node { // returning 100x20 there made every element appear off-screen and broke .click(). get clientWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; } get clientHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; } - get scrollWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; } - get scrollHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; } + get scrollWidth() { + if (!this._isViewportRoot()) return 100; + const metrics = this._renderScrollMetrics(); + return metrics ? metrics.scrollWidth : (globalThis.innerWidth || 1280); + } + get scrollHeight() { + if (!this._isViewportRoot()) return 20; + const metrics = this._renderScrollMetrics(); + return metrics ? metrics.scrollHeight : (globalThis.innerHeight || 720); + } _isViewportRoot() { const t = this.tagName; return t === 'HTML' || t === 'BODY'; } - // No layout engine, so there is no real overflow to scroll and the offset is - // deliberately NOT clamped: without real geometry any synthetic max is a - // guess, and a max derived from a stub scroll box pins scrollTop at 0, which - // deadlocks scroll-driven lazy loaders (no scroll -> no content -> no scroll). - // We track the offset so scrollTop/scrollLeft round-trip, and fire a scroll - // event on direct assignment -- lazy loaders that set `el.scrollTop = N` rely - // on that event, and scrollTo/scrollBy below would otherwise be its only source. - get scrollTop() { return this._scrollTop || 0; } + _renderScrollMetrics() { + if (typeof Deno.core.ops.op_layout_metrics !== 'function') return null; + try { + const raw = Deno.core.ops.op_layout_metrics(); + return raw ? JSON.parse(raw) : null; + } catch (_e) { + return null; + } + } + _renderScrollOffset() { + if (typeof Deno.core.ops.op_scroll_offset !== 'function') return null; + try { + const raw = Deno.core.ops.op_scroll_offset(); + return raw ? JSON.parse(raw) : null; + } catch (_e) { + return null; + } + } + _setRenderScroll(x, y) { + if (typeof Deno.core.ops.op_scroll_to !== 'function') return null; + try { + const raw = Deno.core.ops.op_scroll_to(+x || 0, +y || 0); + return raw ? JSON.parse(raw) : null; + } catch (_e) { + return null; + } + } + // Render builds clamp the viewport root against measured document overflow. + // Nested element scrollers still use synthetic, deliberately unclamped state: + // their contents do not yet participate in scroll-container layout, and a + // guessed maximum would pin lazy-loading scrollers at zero. + get scrollTop() { + if (this._isViewportRoot()) { + const offset = this._renderScrollOffset(); + if (offset) return offset.y || 0; + } + return this._scrollTop || 0; + } set scrollTop(v) { v = +v; const nv = Number.isFinite(v) && v > 0 ? v : 0; - const changed = nv !== (this._scrollTop || 0); - this._scrollTop = nv; + const old = this.scrollTop; + let actual = nv; + if (this._isViewportRoot()) { + const offset = this._renderScrollOffset(); + const updated = offset && this._setRenderScroll(offset.x, nv); + if (updated) actual = updated.y || 0; + } + const changed = actual !== old; + this._scrollTop = actual; if (changed && !this._scrollSuppress) this._fireScroll(); + if (changed && this._isViewportRoot() && + typeof globalThis.__obscura_recompute_intersections === "function") { + globalThis.__obscura_recompute_intersections(); + } + } + get scrollLeft() { + if (this._isViewportRoot()) { + const offset = this._renderScrollOffset(); + if (offset) return offset.x || 0; + } + return this._scrollLeft || 0; } - get scrollLeft() { return this._scrollLeft || 0; } set scrollLeft(v) { v = +v; const nv = Number.isFinite(v) && v > 0 ? v : 0; - const changed = nv !== (this._scrollLeft || 0); - this._scrollLeft = nv; + const old = this.scrollLeft; + let actual = nv; + if (this._isViewportRoot()) { + const offset = this._renderScrollOffset(); + const updated = offset && this._setRenderScroll(nv, offset.y); + if (updated) actual = updated.x || 0; + } + const changed = actual !== old; + this._scrollLeft = actual; if (changed && !this._scrollSuppress) this._fireScroll(); + if (changed && this._isViewportRoot() && + typeof globalThis.__obscura_recompute_intersections === "function") { + globalThis.__obscura_recompute_intersections(); + } } getBoundingClientRect() { globalThis.__obscura_click_target = this; - // documentElement and body span the full viewport. Without this every - // hit test against them clips down to a 100x20 synthetic cell and - // Document.elementFromPoint can never recurse into their children. - if (this._isViewportRoot()) { - const vw = globalThis.innerWidth || 1280; - const vh = globalThis.innerHeight || 720; - return { - x: 0, y: 0, width: vw, height: vh, - top: 0, right: vw, bottom: vh, left: 0, - toJSON() { return this; }, - }; - } // Real layout when the render feature is compiled in: ask the Rust layout // cache for this element's border box. The op is absent in the default // build, so probe with typeof and fall through to the synthetic rect below. @@ -2740,15 +2852,32 @@ class Element extends Node { var _r = JSON.parse(_geom); if (_r && typeof _r.width === 'number') { var _x = _r.x, _y = _r.y, _w = _r.width, _h = _r.height; - return { + var _rect = { x: _x, y: _y, width: _w, height: _h, top: _y, right: _x + _w, bottom: _y + _h, left: _x, toJSON() { return this; }, }; + Object.defineProperty(_rect, "__obscuraViewportFixed", { + value: !!_r.viewportFixed, + enumerable: false, + }); + return _rect; } } } catch (_e) {} } + // Default (non-render) builds keep viewport-sized roots. Without this + // synthetic fallback every hit test against them clips down to a 100x20 + // cell and Document.elementFromPoint cannot recurse into their children. + if (this._isViewportRoot()) { + const vw = globalThis.innerWidth || 1280; + const vh = globalThis.innerHeight || 720; + return { + x: 0, y: 0, width: vw, height: vh, + top: 0, right: vw, bottom: vh, left: 0, + toJSON() { return this; }, + }; + } // No layout engine (default build) or this node has no box: synthesize a // deterministic position from the node id so Playwright's actionability // polling still gets a stable, distinct rect for hit-testing (issue #45). @@ -2790,7 +2919,40 @@ class Element extends Node { set ariaHidden(v) { if (v == null) this.removeAttribute('aria-hidden'); else this.setAttribute('aria-hidden', String(v)); } get ariaSelected() { return this.getAttribute('aria-selected'); } set ariaSelected(v) { if (v == null) this.removeAttribute('aria-selected'); else this.setAttribute('aria-selected', String(v)); } - scrollIntoView() { globalThis.__obscura_click_target = this; } + scrollIntoView(arg) { + globalThis.__obscura_click_target = this; + const rect = this.getBoundingClientRect(); + // A viewport-fixed subtree is already expressed in the viewport's + // coordinate space and cannot be brought closer by moving the document. + if (rect.__obscuraViewportFixed) return; + + let block = "start", inline = "nearest"; + if (arg === false) block = "end"; + else if (arg && typeof arg === "object") { + if (["start", "center", "end", "nearest"].includes(arg.block)) block = arg.block; + if (["start", "center", "end", "nearest"].includes(arg.inline)) inline = arg.inline; + } + const currentX = globalThis.scrollX || 0; + const currentY = globalThis.scrollY || 0; + const vw = globalThis.innerWidth || 1280; + const vh = globalThis.innerHeight || 720; + const align = (mode, start, end, size, viewportSize, current) => { + if (mode === "start") return current + start; + if (mode === "center") return current + start - (viewportSize - size) / 2; + if (mode === "end") return current + end - viewportSize; + // CSSOM View's nearest alignment: do nothing when fully visible or when + // the box spans both viewport edges; otherwise move the closer edge in. + if ((start >= 0 && end <= viewportSize) || (start < 0 && end > viewportSize)) { + return current; + } + if (start < 0) return current + start; + if (end > viewportSize) return current + end - viewportSize; + return current; + }; + const left = align(inline, rect.left, rect.right, rect.width, vw, currentX); + const top = align(block, rect.top, rect.bottom, rect.height, vh, currentY); + globalThis.scrollTo({ left, top, behavior: arg && arg.behavior }); + } // scrollTo/scrollBy/scroll accept either (x, y) or a ScrollToOptions object. // The setters fire a scroll event of their own, so suppress the per-axis ones // here and emit a single event for the whole movement, the way a real browser @@ -5498,104 +5660,182 @@ globalThis.NodeFilter = { // ResizeObserver is defined earlier with real per-target firing; the stub // that previously lived here was a no-op that clobbered the real class. // -// IntersectionObserver: without a layout engine we can't compute real -// intersection geometry, so every observed target is treated as fully -// in-viewport (`isIntersecting: true`, `intersectionRatio: 1`). Real -// libraries lean on this in three patterns we must support: -// -// 1. Lazy load: observe(img) -> first intersection -> load src -> unobserve. -// One fire is enough — covered by the initial microtask fire. -// 2. Infinite scroll: observe(sentinel) -> on intersection load more -> -// new sentinel mounts -> fire again. Needs re-fires as DOM grows. -// 3. Reveal-on-scroll animations: observe(card) -> isIntersecting flips -// true once and an animation runs. One fire is enough. -// -// To cover (2) without spinning forever, we burst-fire at an exponential -// backoff schedule and ALSO re-fire whenever the DOM mutates (a strong -// signal that the page just rendered something new). Per-observer total -// fire cap stops us from looping on a never-disconnected observer. +// Viewport IntersectionObserver. Render builds provide real, scroll-relative +// target boxes through getBoundingClientRect. Element scroll-container roots +// remain intentionally unsupported until their offsets participate in layout. globalThis.__intersectionObservers = []; +function _ioRect(x, y, width, height) { + return { + x, y, width, height, + top: y, left: x, right: x + width, bottom: y + height, + toJSON() { return this; }, + }; +} +function _ioMargins(value) { + const parts = String(value || "0px").trim().split(/\s+/); + if (parts.length < 1 || parts.length > 4) return null; + const parsed = parts.map((part) => { + const match = /^([-+]?(?:\d+(?:\.\d*)?|\.\d+))(px|%)$/.exec(part); + return match ? { value: Number(match[1]), unit: match[2] } : null; + }); + if (parsed.some((part) => !part)) return null; + if (parsed.length === 1) return [parsed[0], parsed[0], parsed[0], parsed[0]]; + if (parsed.length === 2) return [parsed[0], parsed[1], parsed[0], parsed[1]]; + if (parsed.length === 3) return [parsed[0], parsed[1], parsed[2], parsed[1]]; + return parsed; +} +function _ioUnsupportedRoot() { + const error = new Error( + "Obscura IntersectionObserver element roots require element scroll-container layout" + ); + error.name = "NotSupportedError"; + return error; +} globalThis.IntersectionObserver = class IntersectionObserver { constructor(callback, options) { + if (typeof callback !== "function") { + throw new TypeError("IntersectionObserver callback must be a function"); + } this._callback = callback; this._options = options || {}; + if (this._options.root != null) throw _ioUnsupportedRoot(); + this._margins = _ioMargins(this._options.rootMargin || "0px"); + if (!this._margins) throw new SyntaxError("Invalid IntersectionObserver rootMargin"); + const raw = this._options.threshold == null + ? [0] + : (Array.isArray(this._options.threshold) ? this._options.threshold : [this._options.threshold]); + this._thresholds = [...new Set(raw.map(Number))].sort((a, b) => a - b); + if (!this._thresholds.length) this._thresholds = [0]; + if (this._thresholds.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { + throw new RangeError("IntersectionObserver threshold must be between 0 and 1"); + } this._targets = new Set(); + this._previous = new Map(); + this._records = []; this._connected = true; - this._fireCount = 0; + this._deliveryPending = false; globalThis.__intersectionObservers.push(this); } - _fireFor(targets) { - if (!this._connected || !targets.length || this._fireCount >= 256) return; - this._fireCount++; - const records = targets.map(target => ({ - target, - isIntersecting: true, - intersectionRatio: 1, - boundingClientRect: target.getBoundingClientRect - ? target.getBoundingClientRect() - : { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 }, - intersectionRect: target.getBoundingClientRect - ? target.getBoundingClientRect() - : { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 }, - rootBounds: { x: 0, y: 0, width: 1280, height: 720, top: 0, left: 0, right: 1280, bottom: 720 }, - time: Date.now(), - })); - try { this._callback(records, this); } catch (e) { /* IO callbacks must not propagate */ } + _rootBounds() { + const width = globalThis.innerWidth || 1280; + const height = globalThis.innerHeight || 720; + const resolve = (margin, basis) => + margin.unit === "%" ? margin.value * basis / 100 : margin.value; + // IntersectionObserver resolves every rootMargin percentage against the + // root rectangle's width, including the block-axis sides. + const top = resolve(this._margins[0], width); + const right = resolve(this._margins[1], width); + const bottom = resolve(this._margins[2], width); + const left = resolve(this._margins[3], width); + return _ioRect(-left, -top, width + left + right, height + top + bottom); } - observe(el) { - if (!el || !this._connected) return; - if (this._targets.has(el)) return; - this._targets.add(el); - Promise.resolve().then(() => this._fireFor([el])); - // Exponential burst to cover infinite-scroll sentinels that "re-arm" - // after content lands. Without a real scroll/layout signal, we fake the - // re-fire schedule. Beyond ~10s the page has usually settled. - [120, 500, 1500, 3500, 7000].forEach(delay => { - setTimeout(() => { - if (this._connected && this._targets.has(el)) this._fireFor([el]); - }, delay); + _entry(target) { + const rect = target.getBoundingClientRect(); + const root = this._rootBounds(); + const left = Math.max(rect.left, root.left); + const top = Math.max(rect.top, root.top); + const right = Math.min(rect.right, root.right); + const bottom = Math.min(rect.bottom, root.bottom); + const edgesTouch = right >= left && bottom >= top; + const width = Math.max(0, right - left); + const height = Math.max(0, bottom - top); + const targetArea = Math.max(0, rect.width) * Math.max(0, rect.height); + const area = width * height; + const isIntersecting = edgesTouch; + return { + target, + isIntersecting, + intersectionRatio: targetArea > 0 ? area / targetArea : (isIntersecting ? 1 : 0), + boundingClientRect: _ioRect(rect.x, rect.y, rect.width, rect.height), + intersectionRect: _ioRect(left, top, width, height), + rootBounds: root, + time: performance.now(), + }; + } + _thresholdIndex(ratio) { + let index = 0; + while (index < this._thresholds.length && this._thresholds[index] <= ratio) index++; + return index; + } + _queueChanged(target, forceInitial) { + const entry = this._entry(target); + const previous = this._previous.get(target); + const changed = forceInitial || !previous || + previous.isIntersecting !== entry.isIntersecting || + this._thresholdIndex(previous.intersectionRatio) !== + this._thresholdIndex(entry.intersectionRatio); + this._previous.set(target, { + isIntersecting: entry.isIntersecting, + intersectionRatio: entry.intersectionRatio, + }); + if (changed) this._records.push(entry); + } + _check(targets, forceInitial) { + if (!this._connected) return; + for (const target of targets) { + if (this._targets.has(target)) this._queueChanged(target, !!forceInitial); + } + if (!this._records.length || this._deliveryPending) return; + this._deliveryPending = true; + Promise.resolve().then(() => { + this._deliveryPending = false; + if (!this._connected || !this._records.length) return; + const records = this.takeRecords(); + try { this._callback(records, this); } catch (e) {} }); } - unobserve(el) { this._targets.delete(el); } + observe(el) { + if (!el || !this._connected || this._targets.has(el)) return; + this._targets.add(el); + this._previous.delete(el); + this._check([el], true); + } + unobserve(el) { + this._targets.delete(el); + this._previous.delete(el); + } disconnect() { this._connected = false; this._targets.clear(); - const idx = globalThis.__intersectionObservers.indexOf(this); - if (idx >= 0) globalThis.__intersectionObservers.splice(idx, 1); + this._previous.clear(); + this._records.length = 0; + const index = globalThis.__intersectionObservers.indexOf(this); + if (index >= 0) globalThis.__intersectionObservers.splice(index, 1); } - takeRecords() { return []; } - get root() { return this._options.root || null; } - get rootMargin() { return this._options.rootMargin || "0px 0px 0px 0px"; } - get thresholds() { - const t = this._options.threshold; - if (t == null) return [0]; - return Array.isArray(t) ? t.slice() : [t]; + takeRecords() { return this._records.splice(0); } + get root() { return null; } + get rootMargin() { + return this._margins.map((margin) => `${margin.value}${margin.unit}`).join(" "); } + get thresholds() { return this._thresholds.slice(); } }; -// When the DOM mutates (e.g. infinite scroll loads a batch of items), re-fire -// every active IntersectionObserver so libraries observing dynamic content -// see a fresh isIntersecting=true event. Uses the same per-observer fire cap -// to prevent runaway loops if the page is mutating in a tight cycle. (function() { - const reFire = () => { - for (const obs of globalThis.__intersectionObservers) { - if (!obs._connected) continue; - const ts = [...obs._targets]; - if (ts.length) obs._fireFor(ts); - } - }; - // Lazy-attach a single MutationObserver on document.body once the page is - // ready, debounced via a microtask so a flurry of mutations only triggers - // one IO sweep. let pending = false; + const recompute = () => { + if (pending) return; + pending = true; + Promise.resolve().then(() => { + pending = false; + for (const observer of globalThis.__intersectionObservers) { + if (observer._connected && observer._targets.size) { + observer._check([...observer._targets], false); + } + } + }); + }; + globalThis.__obscura_recompute_intersections = recompute; + globalThis.addEventListener("resize", recompute); const wireUp = () => { if (!globalThis.document?.body) return; - const mo = new MutationObserver(() => { - if (pending) return; - pending = true; - Promise.resolve().then(() => { pending = false; reFire(); }); - }); - try { mo.observe(globalThis.document.body, {childList: true, subtree: true}); } catch {} + const observer = new MutationObserver(recompute); + try { + observer.observe(globalThis.document.body, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + } catch {} }; if (globalThis.document?.body) wireUp(); else Promise.resolve().then(wireUp); @@ -6718,21 +6958,312 @@ globalThis.screenLeft = 0; globalThis.screenTop = 0; globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; globalThis.scrollX = 0; globalThis.scrollY = 0; +// Keep the JavaScript capability surface aligned with the declarations the +// renderer actually implements. Reporting an unknown declaration as supported +// is not harmless: Tailwind and other framework sheets use negative probes to +// select legacy-browser fallbacks, which can replace their modern cascade. +const _CSS_SUPPORTED_DECLARATIONS = new Set(( + "display width height min-width min-height max-width max-height box-sizing aspect-ratio " + + "margin margin-top margin-right margin-bottom margin-left margin-inline margin-inline-start " + + "margin-inline-end margin-block margin-block-start margin-block-end padding padding-top " + + "padding-right padding-bottom padding-left padding-inline padding-inline-start padding-inline-end " + + "padding-block padding-block-start padding-block-end border-radius border border-width " + + "border-top-width border-right-width border-bottom-width border-left-width border-top border-right " + + "border-bottom border-left background background-color background-image background-size " + + "background-position background-clip -webkit-background-clip mask-image -webkit-mask-image " + + "mask-size -webkit-mask-size mask-repeat -webkit-mask-repeat color -webkit-text-fill-color fill " + + "stroke stroke-width border-color font-size font font-weight font-family font-style text-align " + + "text-transform text-decoration text-decoration-line line-height align-items justify-items " + + "place-items align-self justify-self place-self align-content justify-content place-content " + + "flex-direction flex-wrap flex-grow flex-shrink flex-basis flex order position float object-fit " + + "top right bottom left inset overflow overflow-x overflow-y scrollbar-gutter visibility opacity animation " + + "animation-name animation-fill-mode animation-iteration-count z-index clear vertical-align " + + "list-style list-style-type gap grid-gap row-gap grid-row-gap column-gap grid-column-gap " + + "border-spacing border-collapse grid-template-columns grid-template-rows grid-template-areas " + + "grid-template grid grid-auto-flow grid-area grid-column grid-row grid-column-start " + + "grid-column-end grid-row-start grid-row-end transform filter backdrop-filter " + + "-webkit-backdrop-filter perspective contain will-change content-visibility box-shadow " + + "-webkit-box-shadow" +).split(/\s+/)); + +const _CSS_SUPPORTED_COLOR_NAMES = new Set(( + "transparent white black gray grey silver lightgray lightgrey darkgray darkgrey whitesmoke " + + "gainsboro red green lime blue navy yellow orange purple maroon teal aqua cyan fuchsia magenta " + + "olive darkblue mediumblue royalblue dodgerblue cornflowerblue steelblue deepskyblue skyblue " + + "lightskyblue lightblue powderblue cadetblue slateblue darkslateblue midnightblue indigo " + + "darkgreen forestgreen seagreen mediumseagreen limegreen yellowgreen olivedrab darkolivegreen " + + "greenyellow lightgreen palegreen springgreen mediumaquamarine aquamarine turquoise " + + "mediumturquoise darkcyan crimson firebrick darkred indianred tomato orangered coral salmon " + + "lightsalmon darksalmon hotpink deeppink pink lightpink palevioletred mediumvioletred violet " + + "orchid plum mediumpurple blueviolet darkviolet darkorchid darkmagenta lavender thistle gold " + + "goldenrod darkgoldenrod khaki darkkhaki peachpuff moccasin papayawhip wheat tan burlywood " + + "sandybrown peru chocolate sienna saddlebrown brown rosybrown darkorange lightyellow " + + "lightgoldenrodyellow lemonchiffon beige ivory azure mintcream honeydew snow seashell linen " + + "oldlace floralwhite ghostwhite aliceblue lavenderblush mistyrose cornsilk antiquewhite bisque " + + "blanchedalmond navajowhite dimgray dimgrey slategray slategrey lightslategray lightslategrey " + + "darkslategray darkslategrey" +).split(/\s+/)); + +function _cssSupportsColor(value) { + const raw = value.trim(); + const lower = raw.toLowerCase(); + if (_CSS_SUPPORTED_COLOR_NAMES.has(lower)) return true; + if (/^#[0-9a-f]{3,4}(?:[0-9a-f]{2}){0,2}$/i.test(lower)) { + return [4, 5, 7, 9].includes(lower.length); + } + if (lower.startsWith("var(") && lower.endsWith(")")) { + const comma = _cssTopLevelComma(raw.slice(4, -1)); + return comma >= 0 && _cssSupportsColor(raw.slice(4 + comma + 1, -1)); + } + if (/^rgba?\(/.test(lower) && lower.endsWith(")")) { + // Chromium accepts relative colors and Tailwind uses this exact syntax as + // a browser-family probe. Painting every relative-color expression is a + // separate concern from recognizing its valid conditional syntax. + if (/\bfrom\b/.test(lower)) { + return /^rgba?\(\s*from\s+\S[\s\S]*\)$/i.test(raw); + } + const parts = lower.slice(lower.indexOf("(") + 1, -1) + .split(/[,\s/]+/).filter(Boolean); + return parts.length >= 3 && parts.slice(0, 3) + .every((part) => /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)%?$/.test(part)); + } + if (/^hsla?\(/.test(lower) && lower.endsWith(")")) { + const parts = lower.slice(lower.indexOf("(") + 1, -1) + .split(/[,\s/]+/).filter(Boolean); + return parts.length >= 3 && parts.slice(0, 3).every((part) => + /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg|%)?$/.test(part)); + } + if (/^okl(?:ab|ch)\(/.test(lower) && lower.endsWith(")")) { + const parts = lower.slice(lower.indexOf("(") + 1, -1) + .split(/[,\s/]+/).filter(Boolean); + return parts.length >= 3 && parts.slice(0, 3).every((part) => + /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg|%)?$/.test(part)); + } + if (lower.startsWith("light-dark(") && lower.endsWith(")")) { + const parts = _cssSplitTopLevel( + raw.slice("light-dark(".length, -1), + "," + ); + return !!parts && parts.length === 2 && + parts.every((part) => part.trim() && _cssSupportsColor(part)); + } + if (lower.startsWith("color-mix(") && lower.endsWith(")")) { + const parts = _cssSplitTopLevel(lower.slice("color-mix(".length, -1), ","); + if (!parts || parts.length < 3 || !/^in\s+\S+$/i.test(parts[0].trim())) return false; + const color = (part) => _cssSupportsColor(part.trim().replace(/\s+[-+]?(?:\d+(?:\.\d*)?|\.\d+)%\s*$/, "")); + return color(parts[1]) && color(parts[2]); + } + return false; +} + +function _cssTopLevelComma(text) { + let depth = 0, quote = ""; + for (let i = 0; i < text.length; i++) { + const character = text[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") depth++; + else if (character === ")") depth--; + else if (character === "," && depth === 0) return i; + if (depth < 0) return -1; + } + return -1; +} + +function _cssSupportsDeclaration(name, value) { + name = name.trim().toLowerCase(); + value = value.trim(); + if (!value) return false; + if (name.startsWith("--")) return name.length > 2; + if (!_CSS_SUPPORTED_DECLARATIONS.has(name)) return false; + const lower = value.toLowerCase(); + if (["initial", "inherit", "unset", "revert", "revert-layer"].includes(lower)) return true; + if (name === "display") { + return ["none", "flex", "inline-flex", "inline", "inline-block", "grid", + "inline-grid", "block", "flow-root", "contents"].includes(lower); + } + if (name === "position") { + return ["static", "relative", "absolute", "fixed", "sticky"].includes(lower); + } + if (name === "box-sizing") return ["content-box", "border-box"].includes(lower); + if (name === "float") return ["none", "left", "right"].includes(lower); + if (name === "object-fit") { + return ["fill", "contain", "cover", "none", "scale-down"].includes(lower); + } + if (name === "visibility") return ["visible", "hidden", "collapse"].includes(lower); + if (name === "scrollbar-gutter") { + return lower === "auto" || lower === "stable" || lower === "stable both-edges"; + } + if (["color", "-webkit-text-fill-color", "background-color", "border-color"].includes(name)) { + return _cssSupportsColor(value); + } + return true; +} + +// Return the contents only when one pair of parentheses encloses the complete +// expression. Declaration leaves such as `(display:grid)` are then evaluated +// by the same path as the two-argument overload. +function _cssEnclosingGroup(text) { + if (!text.startsWith("(")) return null; + let depth = 0, quote = ""; + for (let i = 0; i < text.length; i++) { + const character = text[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") depth++; + else if (character === ")") { + depth--; + if (depth < 0) return null; + if (depth === 0) return i === text.length - 1 ? text.slice(1, i) : null; + } + } + return null; +} + +function _cssSplitTopLevel(text, operator) { + const parts = []; + const isWord = /^[a-z]+$/i.test(operator); + let start = 0, depth = 0, quote = ""; + for (let i = 0; i < text.length; i++) { + const character = text[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if (character === "(" || character === "[") depth++; + else if (character === ")" || character === "]") { + depth--; + if (depth < 0) return null; + } else if (depth === 0 && + text.slice(i, i + operator.length).toLowerCase() === operator.toLowerCase() && + (!isWord || (i > 0 && /\s/.test(text[i - 1]) && + i + operator.length < text.length && /\s/.test(text[i + operator.length])))) { + const part = text.slice(start, i).trim(); + if (!part) return null; + parts.push(part); + i += operator.length - 1; + start = i + 1; + } + } + if (depth !== 0 || quote || !parts.length) return null; + const tail = text.slice(start).trim(); + if (!tail) return null; + parts.push(tail); + return parts; +} + +function _cssHasTopLevelComma(text) { + let depth = 0, quote = ""; + for (let i = 0; i < text.length; i++) { + const character = text[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "\\") { i++; continue; } + if (character === "'" || character === '"') quote = character; + else if (character === "(" || character === "[") depth++; + else if (character === ")" || character === "]") depth--; + else if (character === "," && depth === 0) return true; + if (depth < 0) return false; + } + return false; +} + +const _CSS_SUPPORTED_SIMPLE_PSEUDOS = new Set(( + "hover active focus focus-visible focus-within enabled disabled checked link any-link visited " + + "first-child last-child only-child root empty scope first-of-type last-of-type only-of-type " + + "before after" +).split(/\s+/)); +const _CSS_SUPPORTED_FUNCTIONAL_PSEUDOS = new Set(( + "nth-child nth-of-type nth-last-child nth-last-of-type is where has host not" +).split(/\s+/)); + +function _cssSupportsSelector(selector) { + selector = selector.trim(); + if (!selector || /[{};]/.test(selector)) return false; + const split = _cssSplitTopLevel(selector, ","); + if (!split && _cssHasTopLevelComma(selector)) return false; + const selectors = split || [selector]; + return selectors.every((part) => { + part = part.trim(); + if (!part || /^[>+~]/.test(part) || /[>+~]\s*$/.test(part)) return false; + let parens = 0, brackets = 0, quote = ""; + for (let i = 0; i < part.length; i++) { + const character = part[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "\\") { i++; continue; } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parens++; + else if (character === ")") parens--; + else if (character === "[") brackets++; + else if (character === "]") brackets--; + else if (character === ":" && brackets === 0) { + const doubleColon = part[i + 1] === ":"; + let end = i + (doubleColon ? 2 : 1); + const start = end; + while (end < part.length && /[a-z0-9_-]/i.test(part[end])) end++; + if (end === start) return false; + const name = part.slice(start, end).toLowerCase(); + const functional = part[end] === "("; + if (doubleColon) { + if (functional || !["before", "after"].includes(name)) return false; + } else if (functional) { + if (!_CSS_SUPPORTED_FUNCTIONAL_PSEUDOS.has(name)) return false; + } else if (!_CSS_SUPPORTED_SIMPLE_PSEUDOS.has(name)) { + return false; + } + i = end - 1; + } + if (parens < 0 || brackets < 0) return false; + } + return !quote && parens === 0 && brackets === 0; + }); +} + +function _cssSupportsCondition(condition) { + condition = condition.trim(); + if (!condition) return false; + const grouped = _cssEnclosingGroup(condition); + if (grouped !== null) return _cssSupportsCondition(grouped); + if (/^not\s/i.test(condition)) return !_cssSupportsCondition(condition.slice(3).trim()); + const orParts = _cssSplitTopLevel(condition, "or"); + if (orParts) return orParts.some(_cssSupportsCondition); + const andParts = _cssSplitTopLevel(condition, "and"); + if (andParts) return andParts.every(_cssSupportsCondition); + if (/^selector\(/i.test(condition) && condition.endsWith(")")) { + return _cssSupportsSelector(condition.slice(condition.indexOf("(") + 1, -1)); + } + const colon = condition.indexOf(":"); + if (colon < 0) return false; + return _cssSupportsDeclaration(condition.slice(0, colon), condition.slice(colon + 1)); +} + globalThis.CSS = { supports(prop, value){ try { - var p, v; - if (arguments.length >= 2) { p = String(prop).trim(); v = String(value).trim(); } - else { - var cond = String(prop).trim().replace(/^\(+|\)+$/g, "").trim(); - var idx = cond.indexOf(":"); - if (idx === -1) return false; - p = cond.slice(0, idx).trim(); v = cond.slice(idx + 1).trim(); + if (arguments.length >= 2) { + return _cssSupportsDeclaration(String(prop), String(value)); } - if (!p || !v) return false; - // The engine renders standard CSS; report it as supported so feature-gated - // SPAs don't bail to /unsupported. (Previous stub always returned false.) - return true; + return _cssSupportsCondition(String(prop)); } catch (e) { return false; } }, escape(s){ return s; } @@ -8290,8 +8821,9 @@ URL.revokeObjectURL = function(url) { // // The page offset is stored on the scrolling element rather than in separate // window state, so window.scrollY and document.scrollingElement.scrollTop are -// two views of one value, which is what pages assume. As with #431 there is no -// layout, so the offset still cannot be clamped to a real maximum. +// two views of one value, which is what pages assume. Render builds clamp that +// shared root offset against measured document overflow; non-render builds keep +// the legacy synthetic offset used by automation-only consumers. function _scrollRoot() { const doc = globalThis.document; return (doc && doc.scrollingElement) || null; @@ -8299,6 +8831,8 @@ function _scrollRoot() { function _windowScroll(x, y, relative) { const root = _scrollRoot(); if (!root) return; + const beforeLeft = root.scrollLeft || 0; + const beforeTop = root.scrollTop || 0; let left, top; if (x !== null && typeof x === 'object') { left = x.left; top = x.top; } else { left = x; top = y; } @@ -8308,6 +8842,9 @@ function _windowScroll(x, y, relative) { if (top !== undefined) { root.scrollTop = (relative ? (root.scrollTop || 0) : 0) + (+top || 0); } + if ((root.scrollLeft || 0) === beforeLeft && (root.scrollTop || 0) === beforeTop) { + return; + } // Async, matching the element path #431 added. Dispatched at the document // AND the window: a page scroll event reaches both in Chrome, but // Document.dispatchEvent here runs only its own listeners and does not diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index 7f6e2b3..4c14136 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -14,4 +14,4 @@ pub use v8_flags::set_v8_flags; // Screenshot rasterization (PNG bytes) from the render layer. Available when the // render feature (which enables obscura-render/paint) is compiled in. #[cfg(feature = "render")] -pub use obscura_render::screenshot_png; +pub use obscura_render::{screenshot_png, screenshot_png_scrolled}; diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index f889932..81ad657 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -64,6 +64,14 @@ pub struct JsNetworkEvent { pub timestamp: f64, } +#[cfg(feature = "render")] +pub struct LayoutCache { + rects: std::collections::HashMap, + viewport_fixed: std::collections::HashSet, + sticky: obscura_render::StickyLayout, + content_size: (f32, f32), +} + pub struct ObscuraState { pub dom: Option, pub url: String, @@ -104,14 +112,17 @@ pub struct ObscuraState { // Network.requestWillBeSent / responseReceived for them (issue #406). pub js_network_events: Vec, /// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The - /// cache is computed on first geometry read and cleared on navigation. It is - /// not invalidated on every DOM mutation yet (a follow-up), so geometry read - /// mid-script may lag a frame; reading after settle is reliable. + /// cache is computed on first geometry read and cleared on navigation, + /// viewport changes, and DOM/style mutations that can affect layout. #[cfg(feature = "render")] - pub layout_cache: - Option>, + pub layout_cache: Option, #[cfg(feature = "render")] pub viewport: (f32, f32), + /// Root scrolling offset in CSS pixels. With render enabled this is + /// clamped against the cached document overflow and is the single source + /// read by CSSOM geometry and screenshot paint. + #[cfg(feature = "render")] + pub scroll_offset: (f32, f32), } impl ObscuraState { @@ -141,6 +152,8 @@ impl ObscuraState { layout_cache: None, #[cfg(feature = "render")] viewport: (1280.0, 720.0), + #[cfg(feature = "render")] + scroll_offset: (0.0, 0.0), } } } @@ -180,8 +193,26 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri } fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> String { - let gs = state.borrow::().clone(); - let gs = gs.borrow(); + let shared = state.borrow::().clone(); + #[cfg(feature = "render")] + if matches!( + cmd.as_str(), + "set_attribute" + | "remove_attribute" + | "append_child" + | "remove_child" + | "insert_before" + | "set_inner_html" + | "set_inner_html_context" + | "set_text_content" + ) { + // Any attribute can participate in an author selector, so even a + // seemingly non-geometric attribute may change computed layout. + // Detached-node creation and every read-only op deliberately skip this + // invalidation; the next geometry/scroll read rebuilds at most once. + shared.borrow_mut().layout_cache = None; + } + let gs = shared.borrow(); let dom = match &gs.dom { Some(d) => d, None => return "null".to_string(), @@ -2010,7 +2041,12 @@ pub fn build_extension() -> Extension { // Only registered when the render feature is compiled in. bootstrap.js // probes with typeof before calling, so the op's absence is a clean fallback. #[cfg(feature = "render")] - ops.push(op_layout_geometry()); + { + ops.push(op_layout_geometry()); + ops.push(op_layout_metrics()); + ops.push(op_scroll_offset()); + ops.push(op_scroll_to()); + } Extension { name: "obscura_dom", ops: std::borrow::Cow::Owned(ops), @@ -2018,35 +2054,127 @@ pub fn build_extension() -> Extension { } } +#[cfg(feature = "render")] +fn ensure_layout_cache(state: &mut ObscuraState) -> Option<&LayoutCache> { + if state.layout_cache.is_none() { + let dom = state.dom.as_ref()?; + let laid = obscura_render::layout_dom(dom, state.viewport); + let viewport_fixed = laid.viewport_fixed_nodes(dom); + let sticky = laid.root_sticky_layout(dom, state.viewport); + let content_size = laid.scrolling_content_size(dom, state.viewport); + let mut rects = laid.rects; + for (&id, rect) in &mut rects { + if let Some((tx, ty)) = laid.translates.get(&id) { + rect.x += tx; + rect.y += ty; + } + } + state.layout_cache = Some(LayoutCache { + rects, + viewport_fixed, + sticky, + content_size, + }); + } + state.layout_cache.as_ref() +} + +#[cfg(feature = "render")] +pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) { + let viewport = state.viewport; + let content_size = ensure_layout_cache(state) + .map(|cache| cache.content_size) + .unwrap_or(viewport); + let max_x = (content_size.0 - viewport.0).max(0.0); + let max_y = (content_size.1 - viewport.1).max(0.0); + let x = if requested.0.is_finite() { + requested.0.clamp(0.0, max_x) + } else { + 0.0 + }; + let y = if requested.1.is_finite() { + requested.1.clamp(0.0, max_y) + } else { + 0.0 + }; + state.scroll_offset = (x, y); + state.scroll_offset +} + /// Real border-box geometry for an element from the obscura-render layout /// cache. The cache is computed lazily on first read and cleared on navigation -/// (see `set_dom`). Returns JSON `{"x","y","width","height"}` in CSS pixels, or -/// an empty string when the node has no box. Feature-gated. +/// (see `set_dom`). Coordinates are viewport-relative after the shared root +/// scroll offset, except for viewport-fixed subtrees. Returns JSON +/// `{"x","y","width","height"}` in CSS pixels, or an empty string when the +/// node has no box. Feature-gated. #[cfg(feature = "render")] #[op2] #[string] fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String { let shared = state.borrow::().clone(); - { - let mut gs = shared.borrow_mut(); - if gs.layout_cache.is_none() { - if let Some(dom) = &gs.dom { - let viewport = gs.viewport; - gs.layout_cache = Some(obscura_render::layout_dom(dom, viewport).rects); - } - } - } - let gs = shared.borrow(); let nid: u32 = nid_str.parse().unwrap_or(0); - if let Some(rect) = gs - .layout_cache - .as_ref() - .and_then(|c| c.get(&obscura_dom::tree::NodeId::new(nid))) - { + let nid = obscura_dom::tree::NodeId::new(nid); + let mut gs = shared.borrow_mut(); + let scroll = gs.scroll_offset; + let viewport = gs.viewport; + if let Some(cache) = ensure_layout_cache(&mut gs) { + let Some(rect) = cache.rects.get(&nid) else { + return String::new(); + }; + let sticky_offset = cache.sticky.translation_for(nid, viewport, scroll); + let (x, y) = if cache.viewport_fixed.contains(&nid) { + (rect.x, rect.y) + } else { + ( + rect.x + sticky_offset.0 - scroll.0, + rect.y + sticky_offset.1 - scroll.1, + ) + }; + let viewport_fixed = cache.viewport_fixed.contains(&nid); return format!( - "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{}}}", - rect.x, rect.y, rect.width, rect.height + "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}", + x, y, rect.width, rect.height, viewport_fixed ); } String::new() } + +/// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in +/// render builds; default scraping builds retain their deliberately unbounded +/// synthetic scrolling behavior. +#[cfg(feature = "render")] +#[op2] +#[string] +fn op_layout_metrics(state: &OpState) -> String { + let shared = state.borrow::().clone(); + let mut gs = shared.borrow_mut(); + let viewport = gs.viewport; + let content = ensure_layout_cache(&mut gs) + .map(|cache| cache.content_size) + .unwrap_or(viewport); + format!( + "{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}", + content.0, content.1, viewport.0, viewport.1 + ) +} + +#[cfg(feature = "render")] +#[op2] +#[string] +fn op_scroll_offset(state: &OpState) -> String { + let shared = state.borrow::().clone(); + let mut gs = shared.borrow_mut(); + let requested = gs.scroll_offset; + let (x, y) = clamp_scroll_offset(&mut gs, requested); + format!("{{\"x\":{},\"y\":{}}}", x, y) +} + +#[cfg(feature = "render")] +#[op2] +#[string] +fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String { + let shared = state.borrow::().clone(); + let mut gs = shared.borrow_mut(); + let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32)); + format!("{{\"x\":{},\"y\":{}}}", x, y) +} diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 3885eea..6e944f9 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -10,6 +10,8 @@ use obscura_dom::DomTree; pub use deno_core::v8::IsolateHandle; use crate::module_loader::ObscuraModuleLoader; +#[cfg(feature = "render")] +use crate::ops::clamp_scroll_offset; use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody}; static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); @@ -195,6 +197,7 @@ impl ObscuraJsRuntime { #[cfg(feature = "render")] { gs.layout_cache = None; + gs.scroll_offset = (0.0, 0.0); } } @@ -285,6 +288,12 @@ impl ObscuraJsRuntime { if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { return; } + #[cfg(feature = "render")] + { + let mut state = self.state.borrow_mut(); + state.viewport = (width as f32, height as f32); + state.layout_cache = None; + } let _ = self.runtime.execute_script( "", format!( @@ -294,11 +303,22 @@ impl ObscuraJsRuntime { if(globalThis.visualViewport){{\ globalThis.visualViewport.width={width};\ globalThis.visualViewport.height={height};\ + }}\ + if(typeof globalThis.__obscura_recompute_intersections==='function'){{\ + globalThis.__obscura_recompute_intersections();\ }}", ), ); } + /// Current clamped root scroll offset shared by CSSOM geometry and paint. + #[cfg(feature = "render")] + pub fn scroll_offset(&self) -> (f32, f32) { + let mut state = self.state.borrow_mut(); + let requested = state.scroll_offset; + clamp_scroll_offset(&mut state, requested) + } + /// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.) /// have been set. Must be called once per page setup, after all set_* methods. pub fn run_page_init(&mut self) { @@ -1871,6 +1891,146 @@ mod tests { ); } + #[test] + fn animation_frame_requires_a_callable_callback() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#"(() => { + try { + requestAnimationFrame(null); + return [false, ""]; + } catch (error) { + return [error instanceof TypeError, error.name]; + } + })()"#, + ) + .unwrap(); + assert_eq!(result, serde_json::json!([true, "TypeError"])); + } + + #[tokio::test(flavor = "current_thread")] + async fn animation_frames_are_ordered_batches_with_rendering_timestamps() { + let mut rt = setup_runtime(""); + rt.execute_script( + "animation-frame-order", + r#" + globalThis.__rafEvents = []; + globalThis.__rafStamps = []; + Promise.resolve().then(() => __rafEvents.push("microtask-before")); + setTimeout(() => __rafEvents.push("timer"), 1); + requestAnimationFrame((timestamp) => { + __rafEvents.push("raf-a"); + __rafStamps.push(timestamp); + Promise.resolve().then(() => __rafEvents.push("microtask-in-raf")); + requestAnimationFrame((nextTimestamp) => { + __rafEvents.push("raf-next"); + __rafStamps.push(nextTimestamp); + }); + }); + requestAnimationFrame((timestamp) => { + __rafEvents.push("raf-b"); + __rafStamps.push(timestamp); + }); + "#, + ) + .unwrap(); + + rt.run_event_loop_bounded(150).await.unwrap(); + let result = rt + .evaluate( + r#"[ + __rafEvents, + __rafStamps.length, + __rafStamps[0] === __rafStamps[1], + __rafStamps[2] > __rafStamps[1] + ]"#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([ + [ + "microtask-before", + "timer", + "raf-a", + "raf-b", + "microtask-in-raf", + "raf-next" + ], + 3, + true, + true + ]) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn cancel_animation_frame_removes_pending_and_current_batch_callbacks() { + let mut rt = setup_runtime(""); + rt.execute_script( + "animation-frame-cancel", + r#" + globalThis.__rafEvents = []; + const pending = requestAnimationFrame(() => __rafEvents.push("pending")); + cancelAnimationFrame(pending); + let sameBatch; + requestAnimationFrame(() => { + __rafEvents.push("first"); + cancelAnimationFrame(sameBatch); + }); + sameBatch = requestAnimationFrame(() => __rafEvents.push("same-batch")); + "#, + ) + .unwrap(); + + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("__rafEvents").unwrap(), + serde_json::json!(["first"]) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn self_requeueing_animation_frame_yields_to_timer_tasks() { + let mut rt = setup_runtime(""); + rt.execute_script( + "animation-frame-yield", + r#" + globalThis.__rafCount = 0; + globalThis.__rafStopped = false; + globalThis.__timerAfterAnimation = false; + let frameId = 0; + function frame() { + __rafCount++; + frameId = requestAnimationFrame(frame); + } + frameId = requestAnimationFrame(frame); + setTimeout(() => { + cancelAnimationFrame(frameId); + __rafStopped = true; + }, 55); + setTimeout(() => { + __timerAfterAnimation = true; + }, 65); + "#, + ) + .unwrap(); + + rt.run_event_loop_bounded(200).await.unwrap(); + let result = rt + .evaluate("[__rafCount, __rafStopped, __timerAfterAnimation]") + .unwrap(); + let values = result.as_array().unwrap(); + let frame_count = values[0].as_u64().unwrap(); + assert!( + (2..=5).contains(&frame_count), + "expected a few paced animation frames before cancellation, got {frame_count}" + ); + assert_eq!(values[1], serde_json::json!(true)); + assert_eq!(values[2], serde_json::json!(true)); + } + #[test] fn test_document_url() { let mut rt = setup_runtime(""); @@ -1892,6 +2052,38 @@ mod tests { assert_eq!(count.as_f64().unwrap() as i64, 3); } + #[test] + fn css_supports_matches_capabilities_and_boolean_conditions() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#"JSON.stringify([ + CSS.supports("-webkit-hyphens", "none"), + CSS.supports("margin-trim", "inline"), + CSS.supports("-moz-orient", "inline"), + CSS.supports("color", "rgb(from red r g b)"), + CSS.supports("(((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b))))"), + CSS.supports("display", "grid"), + CSS.supports("(display:grid) and (selector(.card > *))"), + CSS.supports("not (unknown-engine-prop:value)"), + CSS.supports("selector(.card >)"), + CSS.supports("selector(:obscura-unknown)"), + CSS.supports("selector(.card,)"), + CSS.supports("scrollbar-gutter", "stable"), + CSS.supports("scrollbar-gutter", "floating"), + CSS.supports("color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"), + CSS.supports("(color:light-dark(red, light-dark(white, black)))"), + CSS.supports("color", "light-dark(red)"), + CSS.supports("color", "light-dark(red, rgb(1, 2, 3)") + ])"#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!("[false,false,false,true,false,true,true,true,false,false,false,true,false,true,true,false,false]") + ); + } + #[test] fn test_get_element_by_id() { let mut rt = setup_runtime(r#"
Content
"#); @@ -2500,6 +2692,7 @@ mod tests { /// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the /// dominant infinite-scroll idiom never advanced the page offset. + #[cfg(not(feature = "render"))] #[test] fn window_scroll_methods_move_the_page_offset() { let mut rt = setup_runtime(r#"
"#); @@ -2528,6 +2721,7 @@ mod tests { /// Issue #468: the page offset is one value, readable and writable through /// either `window.scrollY` or `document.scrollingElement.scrollTop`. + #[cfg(not(feature = "render"))] #[test] fn window_scroll_offset_is_shared_with_the_scrolling_element() { let mut rt = setup_runtime(r#"
"#); @@ -2549,6 +2743,7 @@ mod tests { /// Issue #468: a scroll event must reach listeners on both the window and /// the document — that is the signal lazy loaders wait for. + #[cfg(not(feature = "render"))] #[tokio::test(flavor = "current_thread")] async fn window_scroll_fires_a_scroll_event() { let mut rt = setup_runtime(r#"
"#); @@ -2571,6 +2766,587 @@ mod tests { assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400])); } + #[cfg(feature = "render")] + #[test] + fn rendered_window_scroll_clamps_and_geometry_is_viewport_relative() { + let dom = parse_html( + r#" +
+
+
+ fixed +
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(320.0, 200.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const target = document.getElementById("target"); + const fixed = document.getElementById("fixed"); + const fixedChild = document.getElementById("fixed-child"); + const before = { + target: target.getBoundingClientRect(), + fixed: fixed.getBoundingClientRect(), + fixedChild: fixedChild.getBoundingClientRect(), + }; + window.scrollTo(99999, 99999); + const maxX = document.documentElement.scrollWidth - innerWidth; + const maxY = document.documentElement.scrollHeight - innerHeight; + const after = { + target: target.getBoundingClientRect(), + fixed: fixed.getBoundingClientRect(), + fixedChild: fixedChild.getBoundingClientRect(), + }; + return [ + innerWidth, innerHeight, + document.documentElement.clientWidth, + document.documentElement.clientHeight, + document.documentElement.scrollWidth, + document.documentElement.scrollHeight, + window.scrollX, window.scrollY, + document.scrollingElement.scrollLeft, + document.scrollingElement.scrollTop, + maxX, maxY, + Math.abs(after.target.left - (before.target.left - maxX)) < 0.01, + Math.abs(after.target.top - (before.target.top - maxY)) < 0.01, + Math.abs(after.fixed.left - before.fixed.left) < 0.01, + Math.abs(after.fixed.top - before.fixed.top) < 0.01, + Math.abs(after.fixedChild.left - before.fixedChild.left) < 0.01, + Math.abs(after.fixedChild.top - before.fixedChild.top) < 0.01, + ]; + "#, + ) + .unwrap(); + let values = result.as_array().expect("array"); + assert_eq!(&values[0..4], &serde_json::json!([320, 200, 320, 200]).as_array().unwrap()[..]); + let scroll_width = values[4].as_f64().expect("scrollWidth"); + let scroll_height = values[5].as_f64().expect("scrollHeight"); + assert!(scroll_width >= 600.0, "scrollWidth was {scroll_width}"); + assert!(scroll_height >= 1000.0, "scrollHeight was {scroll_height}"); + assert_eq!(values[6], values[10]); + assert_eq!(values[7], values[11]); + assert_eq!(values[8], values[10]); + assert_eq!(values[9], values[11]); + assert!(values[12..].iter().all(|value| value == &serde_json::json!(true))); + } + + /// Chromium 150 reference (800x513 CSS-pixel viewport): + /// top=[60,20,20,-267], bottom=[448,448,231,31,-496] at the sampled + /// root scroll offsets. This keeps sticky distinct from fixed positioning, + /// verifies subtree movement, bottom-only sticking, and the containing + /// block's lower boundary without depending on a live site. + #[cfg(feature = "render")] + #[test] + fn root_scroll_sticky_geometry_matches_chromium_constraints() { + let dom = parse_html( + r#" +
+
+
+
+
+
+
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(800.0, 513.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const top = document.getElementById("top"); + const child = document.getElementById("top-child"); + const bottom = document.getElementById("bottom"); + const fixed = document.getElementById("fixed"); + const sample = y => { + window.scrollTo(0, y); + return [ + window.scrollY, + top.getBoundingClientRect().top, + child.getBoundingClientRect().top, + bottom.getBoundingClientRect().top, + fixed.getBoundingClientRect().top, + ]; + }; + return [sample(0), sample(100), sample(400), sample(600), sample(9999)]; + "#, + ) + .unwrap(); + let rows = result.as_array().expect("rows"); + let number = |row: usize, column: usize| { + rows[row].as_array().unwrap()[column].as_f64().unwrap() + }; + let close = |actual: f64, expected: f64| { + assert!( + (actual - expected).abs() < 0.05, + "expected {expected}, got {actual}" + ); + }; + + close(number(0, 1), 60.0); + close(number(0, 3), 448.0); + close(number(1, 1), 20.0); + close(number(1, 2), 20.0); + close(number(1, 3), 448.0); + close(number(2, 1), 20.0); + close(number(2, 3), 231.0); + close(number(3, 1), 20.0); + close(number(3, 3), 31.0); + close(number(4, 0), 1127.0); + close(number(4, 1), -267.0); + close(number(4, 2), -267.0); + close(number(4, 3), -496.0); + for row in 0..rows.len() { + close(number(row, 4), 20.0); + } + } + + /// Chromium 150 horizontal reference for the same constraint algorithm: + /// the sticky subtree pins at x=20, remains distinct from fixed, then + /// leaves with its 500px containing block at the right boundary. + #[cfg(feature = "render")] + #[test] + fn root_scroll_sticky_supports_the_inline_axis() { + let dom = parse_html( + r#" +
+
+
+
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(800.0, 513.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const sticky = document.getElementById("sticky"); + const child = document.getElementById("child"); + const fixed = document.getElementById("fixed"); + const sample = x => { + window.scrollTo(x, 0); + return [ + window.scrollX, + sticky.getBoundingClientRect().left, + child.getBoundingClientRect().left, + fixed.getBoundingClientRect().left, + ]; + }; + return [sample(0), sample(100), sample(400), sample(800)]; + "#, + ) + .unwrap(); + let rows = result.as_array().unwrap(); + let expected = [ + [0.0, 60.0, 60.0, 20.0], + [100.0, 20.0, 20.0, 20.0], + [400.0, 20.0, 20.0, 20.0], + [800.0, -340.0, -340.0, 20.0], + ]; + for (row, expected) in rows.iter().zip(expected) { + for (actual, expected) in row.as_array().unwrap().iter().zip(expected) { + let actual = actual.as_f64().unwrap(); + assert!( + (actual - expected).abs() < 0.05, + "expected {expected}, got {actual}" + ); + } + } + } + + #[cfg(feature = "render")] + #[test] + fn rendered_layout_cache_is_invalidated_by_style_mutations() { + let dom = parse_html( + r#" +
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const box = document.getElementById("box"); + const before = [ + document.documentElement.scrollHeight, + box.getBoundingClientRect().height, + ]; + box.setAttribute("style", "height:900px;width:80px"); + const after = [ + document.documentElement.scrollHeight, + box.getBoundingClientRect().height, + box.getBoundingClientRect().width, + ]; + return [before, after]; + "#, + ) + .unwrap(); + let values = result.as_array().expect("result"); + let before = values[0].as_array().expect("before"); + let after = values[1].as_array().expect("after"); + assert!(after[0].as_f64().unwrap() > before[0].as_f64().unwrap()); + assert_eq!(before[1].as_f64(), Some(300.0)); + assert_eq!(after[1].as_f64(), Some(900.0)); + assert_eq!(after[2].as_f64(), Some(80.0)); + } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn rendered_window_scroll_events_require_actual_movement() { + let dom = parse_html( + r#" +
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(320.0, 200.0); + rt.run_page_init(); + + let moved = rt + .evaluate_for_cdp( + r#" + new Promise(resolve => { + let win = 0, doc = 0; + window.addEventListener("scroll", () => win++); + document.addEventListener("scroll", () => doc++); + window.scrollTo(0, 100); + setTimeout(() => resolve([win, doc, window.scrollY]), 5); + }) + "#, + true, + true, + ) + .await + .unwrap(); + assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 100])); + + rt.evaluate("window.scrollTo(0, 99999)").unwrap(); + rt.run_event_loop_bounded(20).await.unwrap(); + let no_op = rt + .evaluate_for_cdp( + r#" + new Promise(resolve => { + let win = 0, doc = 0; + window.addEventListener("scroll", () => win++); + document.addEventListener("scroll", () => doc++); + const before = window.scrollY; + window.scrollTo(0, 99999); + setTimeout(() => resolve([win, doc, before, window.scrollY]), 5); + }) + "#, + true, + true, + ) + .await + .unwrap(); + let values = no_op.value.unwrap(); + let values = values.as_array().expect("array"); + assert_eq!(&values[0..2], &serde_json::json!([0, 0]).as_array().unwrap()[..]); + assert_eq!(values[2], values[3]); + } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn intersection_observer_tracks_viewport_threshold_crossings() { + let dom = parse_html( + r#" +
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + let result = rt + .evaluate_for_cdp( + r#" + new Promise(resolve => { + const records = []; + const target = document.getElementById("target"); + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + records.push([ + entry.isIntersecting, + Math.round(entry.intersectionRatio * 100) / 100, + Math.round(entry.boundingClientRect.top), + Math.round(entry.intersectionRect.height), + ]); + } + }, { threshold: [0, 0.5, 1] }); + observer.observe(target); + setTimeout(() => window.scrollTo(0, 100), 5); + setTimeout(() => window.scrollTo(0, 260), 15); + setTimeout(() => resolve(records), 30); + }) + "#, + true, + true, + ) + .await + .unwrap(); + assert_eq!( + result.value.unwrap(), + serde_json::json!([ + [false, 0, 150, 0], + [true, 0.5, 50, 50], + [false, 0, -110, 0], + ]) + ); + } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn intersection_observer_honors_root_margin_zero_area_and_no_fake_refires() { + let dom = parse_html( + r#" +
+
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + let result = rt + .evaluate_for_cdp( + r#" + new Promise(resolve => { + const marginRecords = [], zeroRecords = []; + const marginObserver = new IntersectionObserver( + entries => marginRecords.push(...entries.map(entry => [ + entry.isIntersecting, + entry.intersectionRatio, + entry.rootBounds.bottom, + ])), + { rootMargin: "0px 0px 20px", threshold: [0, 1] } + ); + const zeroObserver = new IntersectionObserver( + entries => zeroRecords.push(...entries.map(entry => [ + entry.isIntersecting, + entry.intersectionRatio, + ])) + ); + marginObserver.observe(document.getElementById("margin-target")); + zeroObserver.observe(document.getElementById("zero")); + let unsupported = ""; + try { + new IntersectionObserver(() => {}, { + root: document.getElementById("root") + }); + } catch (error) { + unsupported = error.name; + } + setTimeout(() => resolve([ + marginRecords, zeroRecords, unsupported, + marginObserver.rootMargin, marginObserver.thresholds, + ]), 200); + }) + "#, + true, + true, + ) + .await + .unwrap(); + assert_eq!( + result.value.unwrap(), + serde_json::json!([ + [[true, 1, 120]], + [[true, 1]], + "NotSupportedError", + "0px 0px 20px 0px", + [0, 1], + ]) + ); + } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn intersection_observer_recomputes_after_style_mutation_and_resize() { + let dom = parse_html( + r#" +
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + rt.execute_script( + "intersection-mutation", + r#" + globalThis.__ioRecords = []; + globalThis.__io = new IntersectionObserver(entries => { + __ioRecords.push(...entries.map(entry => [ + entry.isIntersecting, + Math.round(entry.boundingClientRect.top), + ])); + }); + __io.observe(document.getElementById("target")); + "#, + ) + .unwrap(); + rt.run_event_loop_bounded(10).await.unwrap(); + + rt.evaluate( + r#"document.getElementById("spacer").setAttribute("style", "height:120px")"#, + ) + .unwrap(); + rt.run_event_loop_bounded(10).await.unwrap(); + assert_eq!( + rt.evaluate("__ioRecords").unwrap(), + serde_json::json!([[false, 150]]) + ); + + rt.set_viewport(200.0, 160.0); + rt.run_event_loop_bounded(20).await.unwrap(); + assert_eq!( + rt.evaluate("__ioRecords").unwrap(), + serde_json::json!([[false, 150], [true, 120]]) + ); + } + + #[cfg(feature = "render")] + #[test] + fn scroll_into_view_aligns_the_root_viewport_and_clamps() { + let dom = parse_html( + r#" +
+
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const target = document.getElementById("target"); + const bottom = document.getElementById("bottom"); + const fixed = document.getElementById("fixed"); + target.scrollIntoView(); + const start = scrollY; + scrollTo(0, 0); + target.scrollIntoView({ block: "center" }); + const center = scrollY; + scrollTo(0, 0); + target.scrollIntoView({ block: "end" }); + const end = scrollY; + scrollTo(0, 0); + target.scrollIntoView({ block: "nearest" }); + const nearestOutside = scrollY; + scrollTo(0, 250); + target.scrollIntoView({ block: "nearest" }); + const nearestVisible = scrollY; + fixed.scrollIntoView(); + const afterFixed = scrollY; + bottom.scrollIntoView({ block: "start" }); + const clamped = scrollY; + const max = document.documentElement.scrollHeight - innerHeight; + return [ + start, center, end, nearestOutside, nearestVisible, + afterFixed, clamped, max, + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([300, 270, 240, 240, 250, 250, 600, 600]) + ); + } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn scroll_into_view_emits_events_only_when_the_root_moves() { + let dom = parse_html( + r#" +
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + rt.evaluate("window.scrollTo(0, 250)").unwrap(); + rt.run_event_loop_bounded(20).await.unwrap(); + let no_op = rt + .evaluate_for_cdp( + r#" + new Promise(resolve => { + let win = 0, doc = 0; + window.addEventListener("scroll", () => win++); + document.addEventListener("scroll", () => doc++); + document.getElementById("target").scrollIntoView({ block: "nearest" }); + setTimeout(() => resolve([win, doc, scrollY]), 5); + }) + "#, + true, + true, + ) + .await + .unwrap(); + assert_eq!(no_op.value.unwrap(), serde_json::json!([0, 0, 250])); + + rt.evaluate("window.scrollTo(0, 0)").unwrap(); + rt.run_event_loop_bounded(20).await.unwrap(); + let moved = rt + .evaluate_for_cdp( + r#" + new Promise(resolve => { + let win = 0, doc = 0; + window.addEventListener("scroll", () => win++); + document.addEventListener("scroll", () => doc++); + document.getElementById("target").scrollIntoView({ block: "center" }); + setTimeout(() => resolve([win, doc, scrollY]), 5); + }) + "#, + true, + true, + ) + .await + .unwrap(); + assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 270])); + } + /// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so /// firstChild()/lastChild() must descend into them. FILTER_REJECT must not. #[test] diff --git a/crates/obscura-render/Cargo.toml b/crates/obscura-render/Cargo.toml index 2e063ae..557060e 100644 --- a/crates/obscura-render/Cargo.toml +++ b/crates/obscura-render/Cargo.toml @@ -11,7 +11,7 @@ default = [] # Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text # shaping + line breaking, ab_glyph fallback) and images. # Off by default; the layout core (getBoundingClientRect etc.) works without it. -paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"] +paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:swash", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"] [dependencies] taffy = { workspace = true } @@ -22,6 +22,10 @@ ab_glyph = { workspace = true, optional = true } # breaking, the same algorithms browsers use. Built with only our embedded # fonts (no system-font scan) so layout stays deterministic across hosts. cosmic-text = { version = "=0.12.1", default-features = false, features = ["std", "swash"], optional = true } +# Keep this exactly aligned with cosmic-text 0.12's raster backend. Cosmic +# does not expose variation coordinates, so variable font instances are +# rasterized through Swash directly while Cosmic remains the shaper. +swash = { version = "=0.1.19", optional = true } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"], optional = true } ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true } base64 = { workspace = true, optional = true } diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 662617f..9b92d46 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -129,6 +129,7 @@ impl Stylesheet { matcher: &mut Matcher, nid: NodeId, props: &HashMap, + host_style: &LayoutStyle, ) -> (Option, Option) { let build = |rules: &[PseudoRule], matcher: &mut Matcher| { let mut matched: Vec<(u32, usize, &PseudoRule)> = rules @@ -140,24 +141,55 @@ impl Stylesheet { return None; } matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); - let mut style = LayoutStyle::default(); + // Generated ::before/::after boxes have an inline outer display + // by default. LayoutStyle's general default is block because it + // primarily represents ordinary DOM boxes, so set the pseudo + // initial value explicitly before applying author declarations. + let mut style = LayoutStyle { + display: crate::Display::Inline, + ..Default::default() + }; + style.color_scheme_dark = host_style.color_scheme_dark; + let inherited_color_scheme_dark = host_style.color_scheme_dark; let mut content = None; for &(_, _, rule) in &matched { - let expanded = substitute_vars(&rule.normal_decls, props, 0); - crate::style::apply_declarations(&mut style, &expanded); + let expanded = substitute_declarations(&rule.normal_decls, props); + crate::style::apply_color_scheme_declarations_from( + &mut style, + &expanded, + inherited_color_scheme_dark, + ); + } + for &(_, _, rule) in &matched { + let expanded = substitute_declarations(&rule.important_decls, props); + crate::style::apply_color_scheme_declarations_from( + &mut style, + &expanded, + inherited_color_scheme_dark, + ); + } + for &(_, _, rule) in &matched { + let expanded = substitute_declarations(&rule.normal_decls, props); + crate::style::apply_declarations_with_locked_color_scheme( + &mut style, + &expanded, + ); if let Some(value) = extract_content(&expanded, tree, nid) { content = value; } } for &(_, _, rule) in &matched { - let expanded = substitute_vars(&rule.important_decls, props, 0); - crate::style::apply_declarations(&mut style, &expanded); + let expanded = substitute_declarations(&rule.important_decls, props); + crate::style::apply_declarations_with_locked_color_scheme( + &mut style, + &expanded, + ); if let Some(value) = extract_content(&expanded, tree, nid) { content = value; } } style.before_content = content; - if style.before_content.is_some() { + if style.before_content.is_some() || style.content_image.is_some() { Some(style) } else { None @@ -254,20 +286,80 @@ impl Stylesheet { } else { let mut m = parent_props.clone(); for (k, v) in own { - m.insert(k, v); + match v.trim().to_ascii_lowercase().as_str() { + // CSS-wide keywords on a custom property are cascade + // instructions, not literal token streams. `initial` + // produces the guaranteed-invalid value so var() must use + // its fallback even when an ancestor defined the token. + "initial" => { + m.remove(&k); + } + // Custom properties inherit by default. `unset` therefore + // has the same effect as `inherit`; approximate both + // revert forms with the inherited author value as well. + "inherit" | "unset" | "revert" | "revert-layer" => { + if let Some(inherited) = parent_props.get(&k) { + m.insert(k, inherited.clone()); + } else { + m.remove(&k); + } + } + _ => { + m.insert(k, v); + } + } } Some(m) }; let props = effective.as_ref().unwrap_or(parent_props); + let inherited_color_scheme_dark = style.color_scheme_dark; + // `light-dark()` resolves against the element's final used color + // scheme, not the declaration order. Determine the scheme winner + // across the complete author cascade before applying any color-valued + // property. The style starts with its inherited scheme. + for &(_, _, i) in &matched { + let expanded = + substitute_declarations(&self.rules[i].normal_decls, props); + crate::style::apply_color_scheme_declarations_from( + style, + &expanded, + inherited_color_scheme_dark, + ); + } + let expanded = substitute_declarations(&inline_normal, props); + crate::style::apply_color_scheme_declarations_from( + style, + &expanded, + inherited_color_scheme_dark, + ); + for &(_, _, i) in &matched { + let expanded = + substitute_declarations(&self.rules[i].important_decls, props); + crate::style::apply_color_scheme_declarations_from( + style, + &expanded, + inherited_color_scheme_dark, + ); + } + let expanded = substitute_declarations(&inline_important, props); + crate::style::apply_color_scheme_declarations_from( + style, + &expanded, + inherited_color_scheme_dark, + ); + // Pass 2: apply normal declarations with `var()` substituted against // the resolved custom-property map. for &(_, _, i) in &matched { - let expanded = substitute_vars(&self.rules[i].normal_decls, props, 0); - crate::style::apply_declarations(style, &expanded); + let expanded = substitute_declarations(&self.rules[i].normal_decls, props); + crate::style::apply_declarations_with_locked_color_scheme( + style, + &expanded, + ); } - let expanded = substitute_vars(&inline_normal, props, 0); - crate::style::apply_declarations(style, &expanded); + let expanded = substitute_declarations(&inline_normal, props); + crate::style::apply_declarations_with_locked_color_scheme(style, &expanded); // CSS animations form a cascade origin above normal author rules and // below author !important. A static renderer has no compositor clock; @@ -277,18 +369,24 @@ impl Stylesheet { if style.animation_fill_forwards && !style.animation_iteration_infinite { if let Some(name) = style.animation_name.as_deref() { if let Some(decls) = self.keyframe_ends.get(name) { - let expanded = substitute_vars(decls, props, 0); - crate::style::apply_declarations(style, &expanded); + let expanded = substitute_declarations(decls, props); + crate::style::apply_declarations_with_locked_color_scheme( + style, + &expanded, + ); } } } for &(_, _, i) in &matched { - let expanded = substitute_vars(&self.rules[i].important_decls, props, 0); - crate::style::apply_declarations(style, &expanded); + let expanded = substitute_declarations(&self.rules[i].important_decls, props); + crate::style::apply_declarations_with_locked_color_scheme( + style, + &expanded, + ); } - let expanded = substitute_vars(&inline_important, props, 0); - crate::style::apply_declarations(style, &expanded); + let expanded = substitute_declarations(&inline_important, props); + crate::style::apply_declarations_with_locked_color_scheme(style, &expanded); effective } } @@ -372,14 +470,36 @@ fn extract_keyframe_end_states(css: &str) -> Vec<(String, String)> { found } -/// Substitute every `var(--name, fallback?)` in `input` with its resolved -/// value from `props` (recursively, so a token that itself references another -/// token resolves; a depth cap guards against reference cycles). A missing -/// variable with no fallback expands to empty, which makes the declaration -/// invalid and dropped, exactly as CSS specifies. -pub(crate) fn substitute_vars(input: &str, props: &HashMap, depth: u8) -> String { - if depth > 16 || !input.contains("var(") { - return input.to_string(); +/// Resolve variables one declaration at a time. An invalid variable poisons +/// its entire declaration at computed-value time, but must not erase unrelated +/// declarations in the same rule. +fn substitute_declarations(css: &str, props: &HashMap) -> String { + let mut expanded = String::new(); + for declaration in crate::style::split_declarations(css) { + let Some((name, value)) = declaration.split_once(':') else { + continue; + }; + let Some(value) = substitute_var_value(value.trim(), props, 0) else { + continue; + }; + expanded.push_str(name.trim()); + expanded.push(':'); + expanded.push_str(&value); + expanded.push(';'); + } + expanded +} + +/// Substitute every `var(--name, fallback?)` in one property value. `None` +/// represents CSS's guaranteed-invalid value. Crucially, invalidity propagates +/// through an intermediate custom property so an outer var() can use its own +/// fallback (`--toggle:var(--missing) dark; color:var(--toggle,light)`). +fn substitute_var_value(input: &str, props: &HashMap, depth: u8) -> Option { + if depth > 16 { + return None; + } + if !input.contains("var(") { + return Some(input.to_string()); } let mut out = String::new(); let mut rest = input; @@ -403,23 +523,28 @@ pub(crate) fn substitute_vars(input: &str, props: &HashMap, dept } } let Some(end) = end else { - out.push_str(&rest[pos..]); - return out; + return None; }; let inner = &after[..end]; let (name, fallback) = match inner.split_once(',') { Some((n, f)) => (n.trim(), Some(f.trim())), None => (inner.trim(), None), }; - let replacement = match props.get(name) { - Some(v) => substitute_vars(v, props, depth + 1), - None => fallback.map(|f| substitute_vars(f, props, depth + 1)).unwrap_or_default(), + let resolved = props + .get(name) + .and_then(|value| substitute_var_value(value, props, depth + 1)); + let replacement = match resolved { + Some(value) => value, + None => { + let fallback = fallback?; + substitute_var_value(fallback, props, depth + 1)? + } }; out.push_str(&replacement); rest = &after[end + 1..]; } out.push_str(rest); - out + Some(out) } /// If `selector`'s rightmost compound is the pseudo-element `which` @@ -477,6 +602,17 @@ fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option bool { + let condition = condition.trim(); + let condition = condition + .strip_prefix("@supports") + .or_else(|| condition.strip_prefix("supports")) + .unwrap_or(condition) + .trim(); + eval_supports_condition(condition) +} + +fn eval_supports_condition(condition: &str) -> bool { + let condition = condition.trim(); + if condition.is_empty() { + return false; + } + if let Some(inner) = enclosing_parenthesized(condition) { + return eval_supports_condition(inner); + } + if condition + .get(..3) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("not")) + && condition + .as_bytes() + .get(3) + .is_some_and(u8::is_ascii_whitespace) + { + return !eval_supports_condition(condition[3..].trim()); + } + if let Some(parts) = split_supports_operator(condition, "or") { + return parts.into_iter().any(eval_supports_condition); + } + if let Some(parts) = split_supports_operator(condition, "and") { + return parts.into_iter().all(eval_supports_condition); + } + let lower = condition.to_ascii_lowercase(); + if lower.starts_with("selector(") && condition.ends_with(')') { + let inner = &condition["selector(".len()..condition.len() - 1]; + return obscura_dom::selector::parse_selector(inner.trim()).is_ok(); + } + let Some((name, value)) = condition.split_once(':') else { + return false; + }; + crate::style::supports_declaration(name, value) +} + +/// Return the contents when one outer parenthesis pair encloses the complete +/// expression. A declaration leaf such as `(display:grid)` intentionally +/// becomes `display:grid` and is handled after boolean operators. +fn enclosing_parenthesized(condition: &str) -> Option<&str> { + if !condition.starts_with('(') { + return None; + } + let mut depth = 0i32; + let mut quote = None; + for (index, character) in condition.char_indices() { + if let Some(active) = quote { + if character == active { + quote = None; + } + continue; + } + match character { + '\'' | '"' => quote = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth < 0 { + return None; + } + if depth == 0 { + return (index + character.len_utf8() == condition.len()) + .then_some(&condition[1..index]); + } + } + _ => {} + } + } + None +} + +fn split_supports_operator<'a>(condition: &'a str, operator: &str) -> Option> { + let bytes = condition.as_bytes(); + let operator = operator.as_bytes(); + let mut parts = Vec::new(); + let mut start = 0usize; + let mut index = 0usize; + let mut depth = 0i32; + let mut quote = None; + while index < bytes.len() { + let byte = bytes[index]; + if let Some(active) = quote { + if byte == active { + quote = None; + } else if byte == b'\\' { + index += 1; + } + index += 1; + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth < 0 { + return None; + } + } + _ if depth == 0 + && index + operator.len() <= bytes.len() + && bytes[index..index + operator.len()].eq_ignore_ascii_case(operator) + && index > 0 + && bytes[index - 1].is_ascii_whitespace() + && bytes + .get(index + operator.len()) + .is_some_and(u8::is_ascii_whitespace) => + { + let part = condition[start..index].trim(); + if part.is_empty() { + return None; + } + parts.push(part); + index += operator.len(); + start = index; + continue; + } + _ => {} + } + index += 1; + } + if depth != 0 || quote.is_some() || parts.is_empty() { + return None; + } + let tail = condition[start..].trim(); + if tail.is_empty() { + return None; + } + parts.push(tail); + Some(parts) +} + /// Coarse `@media` evaluation against an assumed 1280px-wide desktop viewport. /// /// Real stylesheets format media features inconsistently @@ -678,16 +962,26 @@ fn single_media_query_applies_for_viewport( ) -> bool { let viewport_w = viewport.0; let viewport_h = viewport.1; - if query.contains("print") { + let query = query.trim().strip_prefix("@media").unwrap_or(query).trim(); + let compact: String = query + .chars() + .filter(|c| !c.is_whitespace()) + .flat_map(|c| c.to_lowercase()) + .collect(); + + // Tailwind expresses every `max-*` breakpoint as the negation of a + // min-width query (`not all and (min-width:40rem)`). Evaluate the inner + // condition and invert it; treating all `not all` queries as permanently + // false makes mutually exclusive responsive utilities apply together at + // desktop widths. Unknown browser-targeting features still conservatively + // evaluate true inside and therefore false after negation. + if let Some(inner) = compact.strip_prefix("notalland") { + return !single_media_query_applies_for_viewport(inner, viewport); + } + if compact == "notall" { return false; } - let compact: String = query.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect(); - - // A `not all` / `not all and (...)` query is a browser-targeting hack (most - // commonly the Safari-only flex-gap fallback `@media not all and - // (min-resolution:.001dpcm)`, which flips containers from flex to grid); it - // does not apply in a Chromium-like context. - if compact.starts_with("notall") { + if compact.contains("print") { return false; } @@ -933,7 +1227,11 @@ fn denest( if media_query_applies_for_viewport(pre, viewport) { denest(sel, &inner, rules, viewport); } - } else if at.starts_with("supports") || at.starts_with("layer") { + } else if at.starts_with("supports") { + if supports_condition_applies(pre) { + denest(sel, &inner, rules, viewport); + } + } else if at.starts_with("layer") { denest(sel, &inner, rules, viewport); } } else if !pre.is_empty() { @@ -1253,6 +1551,40 @@ mod tests { ); } + #[test] + fn supports_conditions_gate_legacy_framework_fallbacks() { + let legacy_probe = "(((-webkit-hyphens:none)) and \ + (not (margin-trim:inline))) or \ + ((-moz-orient:inline) and \ + (not (color:rgb(from red r g b))))"; + assert!(!supports_condition_applies(legacy_probe)); + assert!(supports_condition_applies( + "(display:grid) and (selector(.card > *))" + )); + assert!(supports_condition_applies( + "not (unknown-engine-prop:value)" + )); + + let css = format!( + "@supports {legacy_probe} {{ .legacy {{ line-height:1.5 }} }}\ + @supports (display:grid) {{ .modern {{ display:grid }} }}\ + .host {{ @supports {legacy_probe} {{ width:999px }} }}" + ); + let rules = parse_stylesheet_for_viewport(&css, (1280.0, 720.0)); + assert!( + !rules.iter().any(|(selector, declarations)| { + selector == ".legacy" || declarations.contains("999px") + }), + "false supports branches must be skipped: {rules:?}" + ); + assert!( + rules.iter().any(|(selector, declarations)| { + selector == ".modern" && declarations.contains("display:grid") + }), + "true supports branch should remain: {rules:?}" + ); + } + #[test] fn media_breakpoints_support_font_relative_lengths_and_ranges() { assert!(!media_query_applies_for_viewport( @@ -1273,6 +1605,35 @@ mod tests { )); } + #[test] + fn negated_min_width_queries_form_max_breakpoints() { + assert!(media_query_applies_for_viewport( + "@media not all and (min-width: 40rem)", + (639.0, 900.0) + )); + assert!(!media_query_applies_for_viewport( + "@media not all and (min-width: 40rem)", + (1280.0, 900.0) + )); + + let css = r#" + .hidden { display: none } + @media not all and (min-width: 40rem) { + .max-sm\:inline { display: inline } + } + @media (min-width: 80rem) { + .xl\:inline { display: inline } + } + "#; + let desktop = parse_stylesheet_for_viewport(css, (1280.0, 900.0)); + assert!(!desktop + .iter() + .any(|(selector, _)| selector == r".max-sm\:inline")); + assert!(desktop + .iter() + .any(|(selector, _)| selector == r".xl\:inline")); + } + #[test] fn media_query_lists_are_or_conditions() { assert!(media_query_applies_for_viewport( diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index cbef985..a276055 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -6,7 +6,7 @@ //! height. Block and flex structure, plus explicit sizes and box model, are //! correct. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use obscura_dom::tree::{DomTree, NodeId}; use taffy::prelude::*; @@ -67,6 +67,401 @@ pub struct DomLayout { /// have no DOM node of their own. #[cfg(feature = "paint")] pub run_ifc_items: HashMap>, + /// Anonymous in-flow boxes generated by `::before`/`::after`. These have + /// no DOM node of their own, but unlike the legacy text-only fast path + /// they participate in layout with their pseudo style's real box model. + pub(crate) generated_boxes: Vec, +} + +/// Root-scroll sticky-position constraints captured from normal-flow layout. +/// +/// The normal boxes stay immutable in the layout cache. A scroll offset is +/// resolved into one accumulated translation per affected node, which keeps +/// JS geometry and screenshot paint on the same path. +#[derive(Debug, Clone, Default)] +pub struct StickyLayout { + frames: Vec, + owners: HashMap, + clip_owners: HashMap, +} + +#[derive(Debug, Clone, Copy)] +struct StickyFrame { + id: NodeId, + parent_sticky: Option, + normal: Rect, + containing: Rect, + margin: crate::Edges, + inset: [Option; 4], +} + +impl StickyLayout { + fn frame_offsets( + &self, + viewport: (f32, f32), + scroll: (f32, f32), + ) -> HashMap { + let mut frame_offsets = HashMap::with_capacity(self.frames.len()); + for frame in &self.frames { + let inherited = frame + .parent_sticky + .and_then(|id| frame_offsets.get(&id).copied()) + .unwrap_or((0.0, 0.0)); + let normal = Rect { + x: frame.normal.x + inherited.0, + y: frame.normal.y + inherited.1, + ..frame.normal + }; + let containing = Rect { + x: frame.containing.x + inherited.0, + y: frame.containing.y + inherited.1, + ..frame.containing + }; + let x = sticky_axis_position( + normal.x, + normal.width, + containing.x + frame.margin.left, + containing.x + containing.width - frame.margin.right - normal.width, + scroll.0, + viewport.0, + frame.inset[3], + frame.inset[1], + ); + let y = sticky_axis_position( + normal.y, + normal.height, + containing.y + frame.margin.top, + containing.y + containing.height - frame.margin.bottom - normal.height, + scroll.1, + viewport.1, + frame.inset[0], + frame.inset[2], + ); + frame_offsets.insert( + frame.id, + (inherited.0 + x - normal.x, inherited.1 + y - normal.y), + ); + } + frame_offsets + } + + /// Resolve a single geometry query without materializing an entry for + /// every descendant in every sticky subtree. This is O(sticky frames), not + /// O(DOM), and is the hot path for repeated getBoundingClientRect reads. + pub fn translation_for( + &self, + id: NodeId, + viewport: (f32, f32), + scroll: (f32, f32), + ) -> (f32, f32) { + let Some(owner) = self.owners.get(&id) else { + return (0.0, 0.0); + }; + self.frame_offsets(viewport, scroll) + .get(owner) + .copied() + .unwrap_or((0.0, 0.0)) + } + + /// Accumulated sticky translation for every node in a sticky subtree. + /// Frames are stored in DOM preorder, so an outer sticky frame's resolved + /// movement is available before a nested sticky frame is constrained. + /// Paint calls this once for the whole document; geometry uses + /// [`StickyLayout::translation_for`] to avoid an O(DOM) map per query. + pub fn translations( + &self, + viewport: (f32, f32), + scroll: (f32, f32), + ) -> HashMap { + let frame_offsets = self.frame_offsets(viewport, scroll); + self.owners + .iter() + .filter_map(|(&id, owner)| { + frame_offsets.get(owner).copied().map(|offset| (id, offset)) + }) + .collect() + } + + /// Sticky-space movement of the ancestor that owns a node's inherited + /// overflow clip. A sticky descendant must move inside an outer clip, + /// while a clip established inside the sticky subtree moves with it. + pub fn clip_translations_from( + &self, + translations: &HashMap, + ) -> HashMap { + self.clip_owners + .iter() + .filter_map(|(&id, owner)| translations.get(owner).copied().map(|offset| (id, offset))) + .collect() + } + + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } +} + +fn sticky_axis_position( + normal: f32, + size: f32, + contain_min: f32, + contain_max: f32, + scroll: f32, + viewport: f32, + start: Option, + end: Option, +) -> f32 { + let mut stick_start = start.map(|inset| scroll + inset); + let mut stick_end = end.map(|inset| scroll + viewport - inset - size); + + // When both insets leave a sticky view rectangle smaller than the box, + // the end inset is reduced so the start edge wins in the start direction. + if let (Some(start), Some(end)) = (stick_start, stick_end) { + if end < start { + stick_end = Some(start); + } + } + + let mut position = normal; + if let Some(start) = stick_start.take() { + position = position.max(start.min(contain_max)); + } + if let Some(end) = stick_end { + position = position.min(end.max(contain_min)); + } + position +} + +impl DomLayout { + /// Nodes whose painted coordinate space is anchored to the initial + /// containing block. A viewport-fixed element and its whole subtree stay + /// stationary when the document scrolls. A fixed element captured by a + /// transformed/filter/contain ancestor remains in the document scrolling + /// coordinate space instead. + pub fn viewport_fixed_nodes(&self, tree: &DomTree) -> HashSet { + let mut fixed = HashSet::new(); + let mut has_fixed_cb: HashMap = HashMap::new(); + + for id in tree.descendants(tree.document()) { + let parent = tree.get_node(id).and_then(|node| node.parent); + let parent_is_fixed = parent.is_some_and(|parent| fixed.contains(&parent)); + let ancestor_has_fixed_cb = parent + .and_then(|parent| has_fixed_cb.get(&parent).copied()) + .unwrap_or(false); + let style = self.styles.get(&id); + let starts_viewport_fixed = + style.is_some_and(|style| style.position_fixed && !ancestor_has_fixed_cb); + + if parent_is_fixed || starts_viewport_fixed { + fixed.insert(id); + } + + let establishes_fixed_cb = + style.is_some_and(|style| style.containing_block_triggers != 0); + has_fixed_cb.insert(id, ancestor_has_fixed_cb || establishes_fixed_cb); + } + + fixed + } + + /// Root scrolling overflow in CSS pixels. This is the union of laid-out + /// document boxes after translate transforms, bounded by inherited + /// overflow clips. Viewport-fixed subtrees do not enlarge the document. + pub fn scrolling_content_size(&self, tree: &DomTree, viewport: (f32, f32)) -> (f32, f32) { + let fixed = self.viewport_fixed_nodes(tree); + let mut right = viewport.0.max(0.0); + let mut bottom = viewport.1.max(0.0); + + for (&id, rect) in &self.rects { + if fixed.contains(&id) { + continue; + } + let (tx, ty) = self.translates.get(&id).copied().unwrap_or((0.0, 0.0)); + let mut painted = Rect { + x: rect.x + tx, + y: rect.y + ty, + width: rect.width, + height: rect.height, + }; + if let Some(clip) = self.clip_rects.get(&id).copied().flatten() { + let Some(clipped) = painted.intersect(&clip) else { + continue; + }; + painted = clipped; + } + right = right.max(painted.x + painted.width); + bottom = bottom.max(painted.y + painted.height); + } + + (right.ceil(), bottom.ceil()) + } + + /// Capture root/window-scroll sticky constraints from the normal-flow + /// result. Nested overflow scrollers are intentionally outside this slice; + /// their sticky frames will need a scroll-container-specific instance. + pub fn root_sticky_layout(&self, tree: &DomTree, viewport: (f32, f32)) -> StickyLayout { + let viewport_fixed = self.viewport_fixed_nodes(tree); + let content = self.scrolling_content_size(tree, viewport); + let root_containing = Rect { + x: 0.0, + y: 0.0, + width: content.0, + height: content.1, + }; + let mut layout = StickyLayout::default(); + let mut nearest_sticky: HashMap> = HashMap::new(); + let mut inherited_clip_sticky: HashMap> = HashMap::new(); + let mut inside_nested_scroller: HashMap = HashMap::new(); + + for id in tree.descendants(tree.document()) { + let parent = tree.get_node(id).and_then(|node| node.parent); + let has_nested_scroll_container = parent.is_some_and(|parent| { + inside_nested_scroller.get(&parent).copied().unwrap_or(false) + || self + .styles + .get(&parent) + .is_some_and(|style| { + let is_root_propagated_overflow = tree + .get_node(parent) + .is_some_and(|node| { + node.as_element().is_some_and(|element| { + matches!(element.local.as_ref(), "html" | "body") + }) + }); + style.overflow_scroll_container && !is_root_propagated_overflow + }) + }); + inside_nested_scroller.insert(id, has_nested_scroll_container); + let parent_sticky = parent + .and_then(|parent| nearest_sticky.get(&parent).copied()) + .flatten(); + let inherited_clip = parent + .and_then(|parent| { + let parent_style = self.styles.get(&parent); + if parent_style.is_some_and(|style| style.overflow_hidden) { + nearest_sticky.get(&parent).copied().flatten() + } else { + inherited_clip_sticky.get(&parent).copied().flatten() + } + }); + if let Some(owner) = inherited_clip { + layout.clip_owners.insert(id, owner); + } + inherited_clip_sticky.insert(id, inherited_clip); + + if viewport_fixed.contains(&id) { + nearest_sticky.insert(id, None); + continue; + } + + let style = self.styles.get(&id); + let is_sticky = style.is_some_and(|style| { + style.position_sticky + && style.inset.iter().any(Option::is_some) + && !has_nested_scroll_container + }); + if !is_sticky { + nearest_sticky.insert(id, parent_sticky); + if let Some(owner) = parent_sticky { + layout.owners.insert(id, owner); + } + continue; + } + let (Some(style), Some(rect)) = (style, self.rects.get(&id).copied()) else { + nearest_sticky.insert(id, parent_sticky); + continue; + }; + let (tx, ty) = self.translates.get(&id).copied().unwrap_or((0.0, 0.0)); + let normal = Rect { + x: rect.x + tx, + y: rect.y + ty, + ..rect + }; + + let mut containing = None; + let mut ancestor = parent; + while let Some(candidate) = ancestor { + if let (Some(candidate_style), Some(candidate_rect)) = ( + self.styles.get(&candidate), + self.rects.get(&candidate).copied(), + ) { + if candidate_style.display != crate::Display::Inline + && !candidate_style.display_contents + { + let (ctx, cty) = + self.translates.get(&candidate).copied().unwrap_or((0.0, 0.0)); + containing = Some(Rect { + x: candidate_rect.x + + ctx + + candidate_style.border.left + + candidate_style.padding.left, + y: candidate_rect.y + + cty + + candidate_style.border.top + + candidate_style.padding.top, + width: (candidate_rect.width + - candidate_style.border.left + - candidate_style.border.right + - candidate_style.padding.left + - candidate_style.padding.right) + .max(0.0), + height: (candidate_rect.height + - candidate_style.border.top + - candidate_style.border.bottom + - candidate_style.padding.top + - candidate_style.padding.bottom) + .max(0.0), + }); + break; + } + } + ancestor = tree.get_node(candidate).and_then(|node| node.parent); + } + + let resolve_inset = |value: Option, basis: f32| match value { + Some(crate::Dimension::Px(px)) => Some(px), + Some(crate::Dimension::Percent(percent)) => Some(percent * basis), + _ => None, + }; + let inset = [ + resolve_inset(style.inset[0], viewport.1), + resolve_inset(style.inset[1], viewport.0), + resolve_inset(style.inset[2], viewport.1), + resolve_inset(style.inset[3], viewport.0), + ]; + layout.frames.push(StickyFrame { + id, + parent_sticky, + normal, + containing: containing.unwrap_or(root_containing), + margin: style.margin, + inset, + }); + layout.owners.insert(id, id); + nearest_sticky.insert(id, Some(id)); + } + + layout + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GeneratedBoxKind { + Before, + After, +} + +#[derive(Clone, Copy)] +pub(crate) struct GeneratedBox { + pub host: NodeId, + pub kind: GeneratedBoxKind, + pub rect: Rect, +} + +#[derive(Clone, Copy)] +struct GeneratedBoxBuild { + host: NodeId, + kind: GeneratedBoxKind, + node: taffy::NodeId, } /// Registry of cosmic-text inline formatting contexts created during the @@ -78,6 +473,7 @@ pub struct DomLayout { struct IfcRegistry { whole: HashMap, runs: HashMap>, + generated: Vec, /// Specified column widths per table grid node, from `` elements and /// colspan-1 cells: `(px, percent)` per column index. Consumed by the /// table column-balancing pass in `layout_dom_with_images`, which pins @@ -142,7 +538,7 @@ fn resolve_clip_rects( } let next = match (styles.get(&id), rects.get(&id)) { (Some(style), Some(rect)) if style.overflow_hidden => { - let own = Rect { x: rect.x + tx, y: rect.y + ty, width: rect.width, height: rect.height }; + let own = overflow_clip_rect(rect, style, tx, ty); Some(match inherited { Some(clip) => clip.intersect(&own).unwrap_or(Rect::default()), None => own, @@ -155,6 +551,19 @@ fn resolve_clip_rects( } } +/// The CSS overflow clip is the padding box, not the outer border box. This is +/// also the coordinate-space boundary Gecko captures for descendant display +/// items: transformed content may cover the padding area but must not repaint +/// its clip owner's border. +fn overflow_clip_rect(rect: &Rect, style: &crate::LayoutStyle, tx: f32, ty: f32) -> Rect { + Rect { + x: rect.x + tx + style.border.left, + y: rect.y + ty + style.border.top, + width: (rect.width - style.border.left - style.border.right).max(0.0), + height: (rect.height - style.border.top - style.border.bottom).max(0.0), + } +} + /// Resolve one `transform: translate()` component to px: a length passes /// through, a percentage is taken against `basis` (the element's own /// border-box extent on that axis). Font/viewport-relative leftovers fall @@ -254,6 +663,7 @@ fn cascade_walk( parent_props: &std::rc::Rc>, quirks_mode: bool, inherited_cell_padding: Option, + inherited_color_scheme_dark: bool, ) { let Some(node) = tree.get_node(id) else { return }; let is_element = node.is_element(); @@ -261,6 +671,7 @@ fn cascade_walk( // unless this element declares its own `--x` (then a richer map). let mut this_props = parent_props.clone(); let mut descendant_cell_padding = inherited_cell_padding; + let mut descendant_color_scheme_dark = inherited_color_scheme_dark; if let Some(elem) = node.as_element() { if elem.local.as_ref() == "table" { // `cellpadding` is a table-scoped presentational hint applied to @@ -272,6 +683,7 @@ fn cascade_walk( .filter(|value| value.is_finite() && *value >= 0.0); } let mut style = crate::style::ua_style(elem.local.as_ref()); + style.color_scheme_dark = inherited_color_scheme_dark; if matches!(elem.local.as_ref(), "td" | "th") { if let Some(padding) = inherited_cell_padding { style.padding = crate::Edges { @@ -353,7 +765,7 @@ fn cascade_walk( this_props = std::rc::Rc::new(m); } let (before_pseudo, after_pseudo) = - sheet.pseudo_styles(tree, matcher, id, &this_props); + sheet.pseudo_styles(tree, matcher, id, &this_props, &style); style.before_content = before_pseudo .as_ref() .filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute)) @@ -364,6 +776,7 @@ fn cascade_walk( .and_then(|pseudo| pseudo.before_content.clone()); style.before_pseudo = before_pseudo.map(Box::new); style.after_pseudo = after_pseudo.map(Box::new); + descendant_color_scheme_dark = style.color_scheme_dark; styles.insert(id, style); } @@ -380,6 +793,7 @@ fn cascade_walk( &this_props, quirks_mode, descendant_cell_padding, + descendant_color_scheme_dark, ); } if is_element { @@ -475,6 +889,7 @@ pub(crate) fn layout_dom_with_web_fonts( &root_props, quirks_mode, None, + false, ); if timing { let (r, i, c, l, u) = sheet.debug_stats(); @@ -502,7 +917,24 @@ pub(crate) fn layout_dom_with_web_fonts( // Final absolute rects of anonymous inline-run leaves, keyed by the // engine item index (they have no DOM id to key `rects` by). let mut anon_rects: HashMap = HashMap::new(); + let mut generated_rects: Vec> = Vec::new(); if let Some(root_id) = root { + // Headless Chromium's classic scrollbar gutter is 15 CSS pixels. + // `scrollbar-gutter:stable` reserves it even when the scrollbar track + // is hidden, and Gecko subtracts the platform gutter from the viewport + // containing block. Media queries and vw still see the full visual + // viewport; percentages and auto widths use this reduced ICB. + const CLASSIC_SCROLLBAR_GUTTER: f32 = 15.0; + let root_gutters = styles + .get(&root_id) + .map_or(0, |style| style.scrollbar_gutters.min(2)); + let initial_cb_width = + (viewport.0 - CLASSIC_SCROLLBAR_GUTTER * f32::from(root_gutters)).max(0.0); + let initial_cb_x = if root_gutters == 2 { + CLASSIC_SCROLLBAR_GUTTER + } else { + 0.0 + }; // Top-down inheritance of the properties CSS inherits by default. #[derive(Clone)] struct Inherited { @@ -594,7 +1026,7 @@ pub(crate) fn layout_dom_with_web_fonts( // The root element's containing block is the initial containing block, // i.e. the viewport width. let mut root_inh = Inherited::default(); - root_inh.cb_width = viewport.0; + root_inh.cb_width = initial_cb_width; root_inh.cb_height_definite = true; let mut queue = vec![(root_id, root_inh)]; while let Some((id, mut inh)) = queue.pop() { @@ -775,9 +1207,8 @@ pub(crate) fn layout_dom_with_web_fonts( Some(crate::Dimension::Px(left)), ) = (style.inset[1], style.inset[3]) { - style.width = crate::Dimension::Px( - (viewport.0 - left - right).max(0.0), - ); + style.width = + crate::Dimension::Px((initial_cb_width - left - right).max(0.0)); } } if style.height == crate::Dimension::Auto { @@ -989,6 +1420,86 @@ pub(crate) fn layout_dom_with_web_fonts( pseudo.font_size = Some(host_font_size); } let pseudo_em = pseudo.font_size.unwrap_or(host_font_size); + for index in 0..6 { + if let Some(expression) = pseudo.size_expressions[index].as_deref() { + let percent_base = if matches!(index, 1 | 3 | 5) { + viewport.1 + } else { + cb_w + }; + if let Some(px) = crate::style::resolve_contextual_length( + expression, + pseudo_em, + root_fs, + vw, + vh, + percent_base, + ) { + let resolved = crate::Dimension::Px(px); + match index { + 0 => pseudo.width = resolved, + 1 => pseudo.height = resolved, + 2 => pseudo.min_width = resolved, + 3 => pseudo.min_height = resolved, + 4 => pseudo.max_width = resolved, + _ => pseudo.max_height = resolved, + } + } + } + } + pseudo.width = pseudo.width.resolve(pseudo_em, root_fs, vw, vh); + pseudo.height = pseudo.height.resolve(pseudo_em, root_fs, vw, vh); + pseudo.min_width = pseudo.min_width.resolve(pseudo_em, root_fs, vw, vh); + pseudo.min_height = pseudo.min_height.resolve(pseudo_em, root_fs, vw, vh); + pseudo.max_width = pseudo.max_width.resolve(pseudo_em, root_fs, vw, vh); + pseudo.max_height = pseudo.max_height.resolve(pseudo_em, root_fs, vw, vh); + for index in 0..4 { + if let Some(relative) = pseudo.padding_relative[index] { + if let crate::Dimension::Px(px) = + relative.resolve(pseudo_em, root_fs, vw, vh) + { + match index { + 0 => pseudo.padding.top = px.max(0.0), + 1 => pseudo.padding.right = px.max(0.0), + 2 => pseudo.padding.bottom = px.max(0.0), + _ => pseudo.padding.left = px.max(0.0), + } + } + } + if let Some(relative) = pseudo.margin_relative[index] { + if let crate::Dimension::Px(px) = + relative.resolve(pseudo_em, root_fs, vw, vh) + { + match index { + 0 => pseudo.margin.top = px, + 1 => pseudo.margin.right = px, + 2 => pseudo.margin.bottom = px, + _ => pseudo.margin.left = px, + } + } + } + if let Some(percent) = pseudo.padding_percent[index] { + let px = (percent * cb_w).max(0.0); + match index { + 0 => pseudo.padding.top = px, + 1 => pseudo.padding.right = px, + 2 => pseudo.padding.bottom = px, + _ => pseudo.padding.left = px, + } + } + if let Some(percent) = pseudo.margin_percent[index] { + let px = percent * cb_w; + match index { + 0 => pseudo.margin.top = px, + 1 => pseudo.margin.right = px, + 2 => pseudo.margin.bottom = px, + _ => pseudo.margin.left = px, + } + } + if let Some(inset) = pseudo.inset[index] { + pseudo.inset[index] = Some(inset.resolve(pseudo_em, root_fs, vw, vh)); + } + } let weight = crate::style::computed_font_weight( pseudo.font_weight.as_deref(), host_weight, @@ -1097,8 +1608,60 @@ pub(crate) fn layout_dom_with_web_fonts( // replaced-control sizing, inputs/selects are empty auto-sized leaves // (0px tall and often stretched to their container). for (&id, style) in styles.iter_mut() { - let Some(node) = tree.get_node(id) else { continue }; - let Some(element) = node.as_element() else { continue }; + let Some(node) = tree.get_node(id) else { + continue; + }; + let Some(element) = node.as_element() else { + continue; + }; + if element.local.as_ref() == "button" && style.width == crate::Dimension::Auto { + // Buttons remain intrinsically sized form controls even when + // author CSS changes their inner display to flex/grid. Treating + // `display:flex` as an ordinary block makes an auto-width + // button stretch to its containing block (MDN's compact search + // pill became 637px wide). Approximate the native max-content + // border box from its label, generated icon, gap, and edges. + let font_size = style.font_size.unwrap_or(13.333_333).max(1.0); + let bold = crate::style::used_font_weight(style) >= 600; + let label = tree + .text_content(id) + .split_whitespace() + .collect::>() + .join(" "); + let mut content_width = + text_width(&label, font_size, bold, style.font_family.as_deref()); + let pseudo_width = |pseudo: Option<&crate::LayoutStyle>| { + let Some(pseudo) = pseudo else { return 0.0 }; + match pseudo.width { + crate::Dimension::Px(px) => px.max(0.0), + crate::Dimension::Em(value) | crate::Dimension::Rem(value) => { + (value * font_size).max(0.0) + } + crate::Dimension::Percent(percent) => (percent * font_size).max(0.0), + _ if pseudo.mask_image.is_some() || pseudo.background_image.is_some() => { + font_size + } + _ => 0.0, + } + }; + let before = pseudo_width(style.before_pseudo.as_deref()); + let after = pseudo_width(style.after_pseudo.as_deref()); + let extra_parts = usize::from(before > 0.0) + usize::from(after > 0.0); + content_width += before + after; + if extra_parts > 0 && !label.is_empty() { + content_width += style.column_gap.unwrap_or(0.0) * extra_parts as f32; + } + let horizontal_edges = style.padding.left + + style.padding.right + + style.border.left + + style.border.right; + style.width = + crate::Dimension::Px(if style.box_sizing == crate::BoxSizing::ContentBox { + content_width + } else { + content_width + horizontal_edges + }); + } if element.local.as_ref() == "select" { let option_labels: Vec = tree .descendants(id) @@ -1356,7 +1919,7 @@ pub(crate) fn layout_dom_with_web_fonts( let static_position_candidates = reparent_inset_positioned_nodes(tree, &mut taffy_tree, taffy_root, &id_map, &styles); let available = taffy::Size { - width: taffy::AvailableSpace::Definite(viewport.0), + width: taffy::AvailableSpace::Definite(initial_cb_width), height: taffy::AvailableSpace::Definite(viewport.1), }; #[cfg(feature = "paint")] @@ -1441,15 +2004,13 @@ pub(crate) fn layout_dom_with_web_fonts( Some(crate::Dimension::Px(w)) => w, _ => max_c, }; - let used_outer = preferred_outer - .max(min_c) - .min(viewport.0.max(min_c)); - let used_declaration = - if table_style.box_sizing == crate::BoxSizing::ContentBox { - (used_outer - inline_outer_edges).max(0.0) - } else { - used_outer - }; + let used_outer = preferred_outer.max(min_c).min(initial_cb_width.max(min_c)); + let used_declaration = if table_style.box_sizing == crate::BoxSizing::ContentBox + { + (used_outer - inline_outer_edges).max(0.0) + } else { + used_outer + }; // Distribute the used track space proportionally between // each column's own min-content and max-content width, the // way CSS tables do, instead of letting the grid hand every @@ -1738,7 +2299,26 @@ pub(crate) fn layout_dom_with_web_fonts( let _ = taffy_tree.compute_layout(taffy_root, available); } } - compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &words, &mut rects, &mut text_runs, &mut anon_rects); + let generated_nodes: HashMap = ifc_items + .generated + .iter() + .enumerate() + .map(|(index, generated)| (generated.node, index)) + .collect(); + generated_rects.resize(ifc_items.generated.len(), None); + compute_absolute_rects( + &taffy_tree, + taffy_root, + initial_cb_x, + 0.0, + &id_map, + &words, + &mut rects, + &mut text_runs, + &mut anon_rects, + &generated_nodes, + &mut generated_rects, + ); synthesize_row_rects(tree, &mut rects); } } @@ -1779,7 +2359,7 @@ pub(crate) fn layout_dom_with_web_fonts( let inherited = clip_rects.get(nid).copied().flatten(); let clip = if style.overflow_hidden { let (tx, ty) = translates.get(nid).copied().unwrap_or((0.0, 0.0)); - let own = crate::Rect { x: rect.x + tx, y: rect.y + ty, width: rect.width, height: rect.height }; + let own = overflow_clip_rect(rect, style, tx, ty); Some(match inherited { Some(c) => c.intersect(&own).unwrap_or(crate::Rect::default()), None => own, @@ -1799,7 +2379,7 @@ pub(crate) fn layout_dom_with_web_fonts( let clip = match (styles.get(parent), rects.get(parent)) { (Some(style), Some(prect)) if style.overflow_hidden => { let (tx, ty) = translates.get(parent).copied().unwrap_or((0.0, 0.0)); - let own = crate::Rect { x: prect.x + tx, y: prect.y + ty, width: prect.width, height: prect.height }; + let own = overflow_clip_rect(prect, style, tx, ty); Some(match inherited { Some(c) => c.intersect(&own).unwrap_or(crate::Rect::default()), None => own, @@ -1814,6 +2394,19 @@ pub(crate) fn layout_dom_with_web_fonts( } } + let generated_boxes = ifc_items + .generated + .iter() + .zip(generated_rects) + .filter_map(|(generated, rect)| { + rect.map(|rect| GeneratedBox { + host: generated.host, + kind: generated.kind, + rect, + }) + }) + .collect(); + DomLayout { rects, styles, @@ -1826,6 +2419,7 @@ pub(crate) fn layout_dom_with_web_fonts( ifc_items: ifc_items.whole, #[cfg(feature = "paint")] run_ifc_items: ifc_items.runs, + generated_boxes, } } @@ -2000,7 +2594,7 @@ fn resolve_named_placement( map: &HashMap, ) -> Option> { use taffy::style_helpers::{line, span}; - let side = |tok: &str| -> Option { + let side = |tok: &str, is_start: bool| -> Option { let t = tok.trim(); if t.is_empty() || t.eq_ignore_ascii_case("auto") { return Some(taffy::GridPlacement::Auto); @@ -2013,10 +2607,15 @@ fn resolve_named_placement( if let Ok(i) = t.parse::() { return Some(line(i)); } - map.get(t).map(|&l| line(l)) + map.get(t) + .or_else(|| map.get(&format!("{t}-{}", if is_start { "start" } else { "end" }))) + .map(|&l| line(l)) }; if let Some((a, b)) = raw.split_once('/') { - Some(taffy::Line { start: side(a)?, end: side(b)? }) + Some(taffy::Line { + start: side(a, true)?, + end: side(b, false)?, + }) } else { let name = raw.trim(); if let Some(&s) = map.get(&format!("{name}-start")) { @@ -2040,6 +2639,8 @@ fn compute_absolute_rects( rects: &mut HashMap, text_runs: &mut HashMap>, anon_rects: &mut HashMap, + generated_nodes: &HashMap, + generated_rects: &mut [Option], ) { if let Ok(layout) = taffy_tree.layout(taffy_id) { let x = abs_x + layout.location.x; @@ -2054,6 +2655,9 @@ fn compute_absolute_rects( // final rect by item index so the finalize pass can pin it. anon_rects.insert(item, rect); } + if let Some(index) = generated_nodes.get(&taffy_id) { + generated_rects[*index] = Some(rect); + } // A word leaf's dom_id is its owning text node, shared by every other // word from the same node, so this appends rather than overwrites. if let Some((text_dom_id, word)) = words.get(&taffy_id) { @@ -2062,7 +2666,19 @@ fn compute_absolute_rects( if let Ok(children) = taffy_tree.children(taffy_id) { for child_id in children { - compute_absolute_rects(taffy_tree, child_id, x, y, id_map, words, rects, text_runs, anon_rects); + compute_absolute_rects( + taffy_tree, + child_id, + x, + y, + id_map, + words, + rects, + text_runs, + anon_rects, + generated_nodes, + generated_rects, + ); } } } @@ -2885,8 +3501,8 @@ fn is_flattenable_inline(tree: &DomTree, id: NodeId, styles: &HashMap) -> bool { + content == Some("") + || style.content_image.is_some() + || style.display != crate::Display::Inline + || style.is_inline_block + || style.position.is_some() + || style.width != crate::Dimension::Auto + || style.height != crate::Dimension::Auto + || style.min_width != crate::Dimension::Auto + || style.min_height != crate::Dimension::Auto + || style.max_width != crate::Dimension::Auto + || style.max_height != crate::Dimension::Auto + || style.margin != crate::Edges::default() + || style.margin_auto.iter().any(|value| *value) + || style.padding != crate::Edges::default() + || style.border != crate::Edges::default() + || style.background_color.is_some() + || style.background_gradient.is_some() + || style.background_radial_gradient.is_some() + || style.background_conic_gradient.is_some() + || style.background_image.is_some() + || style.mask_image.is_some() + || style.box_shadow.is_some() + || style.border_radius > 0.0 + || style.overflow_hidden + || style.transform_translate.is_some() + || style.transform_scale.is_some() +} + +fn has_in_flow_generated_pseudo(pseudo: Option<&crate::LayoutStyle>) -> bool { + pseudo.map_or(false, |pseudo| { + pseudo.display != crate::Display::None + && !matches!(pseudo.position, Some(taffy::Position::Absolute)) + }) +} + +/// Build one in-flow pseudo as either the existing zero-overhead generated +/// text leaves or a real anonymous box. The latter is used only when the +/// pseudo's own box model can affect geometry/paint; ordinary `content:"x"` +/// remains part of the surrounding inline run and does not allocate an extra +/// container. +fn build_in_flow_pseudo( + host: NodeId, + kind: GeneratedBoxKind, + pseudo: Option<&crate::LayoutStyle>, + taffy_tree: &mut TaffyTree, + words: &mut HashMap, + ifc_items: &mut IfcRegistry, +) -> Option<(Vec, bool)> { + let pseudo = pseudo?; + if matches!(pseudo.position, Some(taffy::Position::Absolute)) + || pseudo.display == crate::Display::None + { + return None; + } + let content = pseudo.before_content.as_deref(); + if !pseudo_requires_generated_box(pseudo, content) { + let leaves = build_pseudo_content(host, content?, pseudo, taffy_tree, words); + return (!leaves.is_empty()).then_some((leaves, false)); + } + + let children = content + .filter(|text| !text.is_empty()) + .map(|text| build_pseudo_content(host, text, pseudo, taffy_tree, words)) + .unwrap_or_default(); + let mut taffy_style = to_taffy_style(pseudo); + // A block pseudo's outer participation is block-level, but its generated + // text still forms an inline formatting context inside it. Taffy has no + // split outer/inner display representation, so use a wrapping row for the + // inner text while the parent block still treats this node as one child. + if pseudo.display == crate::Display::Block && !children.is_empty() { + taffy_style.display = taffy::style::Display::Flex; + taffy_style.flex_direction = taffy::FlexDirection::Row; + taffy_style.flex_wrap = taffy::FlexWrap::Wrap; + } + let node = if children.is_empty() { + taffy_tree.new_leaf(taffy_style).ok()? + } else { + taffy_tree.new_with_children(taffy_style, &children).ok()? + }; + ifc_items + .generated + .push(GeneratedBoxBuild { host, kind, node }); + let block_level = pseudo.display != crate::Display::Inline && !pseudo.is_inline_block; + Some((vec![node], block_level)) +} + /// Split already whitespace-collapsed text into tokens, each carrying at most /// one trailing space (`"Hello World "` -> `["Hello ", "World "]`), so a /// word's own width naturally includes the gap to the next one. @@ -3552,6 +4255,35 @@ fn build( let mut taffy_style = to_taffy_style(style); + // A grid item's automatic minimum size is clamped by a definite + // max-width. Taffy's intrinsic track pass cannot resolve a percentage max + // until the track exists, so `max-width:100%` otherwise participates with + // its full max-content minimum and circularly expands an implicit auto + // track (a row of fixed gallery columns widened Tailwind's 1200px content + // track to 1528px). Let the authored percentage remain relative to the + // final grid area, but remove that circular automatic minimum. Do not + // eagerly turn the percentage into viewport pixels: a grid area may be + // narrower than its container because of gutters. + if matches!(style.max_width, crate::Dimension::Percent(_)) + && matches!(style.min_width, crate::Dimension::Auto) + { + let mut parent = node.parent; + let grid_item = loop { + let Some(parent_id) = parent else { break false }; + let Some(parent_style) = styles.get(&parent_id) else { + break false; + }; + if parent_style.display_contents { + parent = tree.get_node(parent_id).and_then(|node| node.parent); + continue; + } + break parent_style.display == crate::Display::Grid; + }; + if grid_item { + taffy_style.min_size.width = taffy::Dimension::length(0.0); + } + } + // A forced break that reaches the general box builder sits between block // or atomic siblings, so it cannot be folded into a shaped inline run. // Give that sentinel the inherited used line-height instead of the UA @@ -3691,11 +4423,26 @@ fn build( // word lands on its own line, one word per row, instead of wrapping // several words per line the way inline text actually flows. let stacks_children_vertically = style.display == crate::Display::Block - || (style.display == crate::Display::Flex && style.flex_direction == Some(taffy::FlexDirection::Column)); - let has_inline_ish_content = - has_inline_content(tree, id, styles) || style.before_content.is_some() || style.after_content.is_some(); + || (style.display == crate::Display::Flex + && style.flex_direction == Some(taffy::FlexDirection::Column)); + let has_inline_ish_content = has_inline_content(tree, id, styles) + || has_in_flow_generated_pseudo(style.before_pseudo.as_deref()) + || has_in_flow_generated_pseudo(style.after_pseudo.as_deref()); let mut dom_children = tree.children(id); + // A boxless inline wrapper is transparent to our approximated box tree. + // Flatten it before deciding whether this block has mixed inline/block + // content, not only later while building children. Otherwise + // `
…` looks like one inline child
+    // during classification, is promoted to a flex row, and only then exposes
+    // the block spans—too late, so every line lands side by side. Real engines
+    // split the inline around those blocks and make the blocks siblings in the
+    // containing block formatting context.
+    if style.display == crate::Display::Block || style.internal_flex_container {
+        let mut flattened = Vec::new();
+        flatten_boxless_inline_children(tree, &dom_children, styles, &mut flattened);
+        dom_children = flattened;
+    }
     let internal_mixed_block_flow = style.internal_flex_container
         && dom_children.iter().any(|cid| {
             styles.get(cid).map_or(false, |child| {
@@ -3755,6 +4502,31 @@ fn build(
             }
         }
     }
+    // Main-axis auto margins absorb positive free space before
+    // `justify-content` is applied. Taffy 0.12 currently applies both: it
+    // end-justifies the items and then adds the auto-margin space again,
+    // placing later controls far outside the container (MDN's theme/language
+    // controls landed beyond x=2100). Neutralize justify-content whenever an
+    // in-flow flex item owns a main-axis auto margin; taffy then performs the
+    // auto-margin distribution correctly.
+    if style.display == crate::Display::Flex {
+        let column = matches!(
+            style.flex_direction,
+            Some(taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse)
+        );
+        let has_main_auto_margin = dom_children.iter().any(|cid| {
+            styles.get(cid).map_or(false, |child| {
+                if column {
+                    child.margin_auto[0] || child.margin_auto[2]
+                } else {
+                    child.margin_auto[1] || child.margin_auto[3]
+                }
+            })
+        });
+        if has_main_auto_margin {
+            taffy_style.justify_content = Some(taffy::JustifyContent::FLEX_START);
+        }
+    }
     // `float` has no effect on a flex or grid item. Legacy stylesheets often
     // leave floats on children after a newer rule turns their parent into a
     // flex container; routing those children through the block float-zone
@@ -3834,13 +4606,26 @@ fn build(
     } else {
         dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect()
     };
-    if let Some(content) = &style.before_content {
-        let mut leaves = build_pseudo_content(id, content, style, taffy_tree, words);
-        leaves.append(&mut child_ids);
-        child_ids = leaves;
+    if let Some((mut before, _)) = build_in_flow_pseudo(
+        id,
+        GeneratedBoxKind::Before,
+        style.before_pseudo.as_deref(),
+        taffy_tree,
+        words,
+        ifc_items,
+    ) {
+        before.append(&mut child_ids);
+        child_ids = before;
     }
-    if let Some(content) = &style.after_content {
-        child_ids.append(&mut build_pseudo_content(id, content, style, taffy_tree, words));
+    if let Some((mut after, _)) = build_in_flow_pseudo(
+        id,
+        GeneratedBoxKind::After,
+        style.after_pseudo.as_deref(),
+        taffy_tree,
+        words,
+        ifc_items,
+    ) {
+        child_ids.append(&mut after);
     }
 
     if style.internal_flex_container
@@ -3896,8 +4681,8 @@ fn inline_wrapper_float(
         || wrapper_style.margin != crate::Edges::default()
         || wrapper_style.padding != crate::Edges::default()
         || wrapper_style.border != crate::Edges::default()
-        || wrapper_style.before_content.is_some()
-        || wrapper_style.after_content.is_some()
+        || wrapper_style.before_pseudo.is_some()
+        || wrapper_style.after_pseudo.is_some()
     {
         return None;
     }
@@ -4005,16 +4790,35 @@ fn build_mixed_block(
         }
     }
 
-    // ::before joins the first inline run and ::after the last (a list
-    // marker must share its item text's lines); when the adjacent segment
-    // is a block, the pseudo content gets its own anonymous run instead.
-    let before_leaves = style.before_content.as_ref().map(|c| build_pseudo_content(id, c, style, taffy_tree, words)).unwrap_or_default();
-    let after_leaves = style.after_content.as_ref().map(|c| build_pseudo_content(id, c, style, taffy_tree, words)).unwrap_or_default();
-    let mut before_pending = !before_leaves.is_empty();
-    let mut after_pending = !after_leaves.is_empty();
+    // Inline pseudos join the first/last inline run. A block-level pseudo is
+    // a real direct child of this block and therefore sits outside the
+    // anonymous run wrappers, exactly like an authored block child.
+    let before = build_in_flow_pseudo(
+        id,
+        GeneratedBoxKind::Before,
+        style.before_pseudo.as_deref(),
+        taffy_tree,
+        words,
+        ifc_items,
+    );
+    let after = build_in_flow_pseudo(
+        id,
+        GeneratedBoxKind::After,
+        style.after_pseudo.as_deref(),
+        taffy_tree,
+        words,
+        ifc_items,
+    );
+    let (before_leaves, before_block) = before.unwrap_or_else(|| (Vec::new(), false));
+    let (after_leaves, after_block) = after.unwrap_or_else(|| (Vec::new(), false));
+    let mut before_pending = !before_block && !before_leaves.is_empty();
+    let mut after_pending = !after_block && !after_leaves.is_empty();
 
     let n_segs = segs.len();
     let mut child_ids: Vec = Vec::new();
+    if before_block {
+        child_ids.extend(before_leaves.iter().copied());
+    }
     for (i, seg) in segs.into_iter().enumerate() {
         match seg {
             Seg::Block(cid) => {
@@ -4119,6 +4923,9 @@ fn build_mixed_block(
             .ok()?;
         child_ids.push(wrapper);
     }
+    if after_block {
+        child_ids.extend(after_leaves.iter().copied());
+    }
     for cid in out_of_flow {
         child_ids.extend(build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles));
     }
@@ -4151,6 +4958,102 @@ fn flatten_contents_children(
     }
 }
 
+/// Does an inline contribute only in-flow block children?
+///
+/// Such an inline has empty before/after inline fragments once CSS performs
+/// the block-in-inline split, so its block children can participate directly
+/// in the containing block's segment construction. This is deliberately
+/// narrower than a complete ib-split: mixed inline/block children still need
+/// fragment boxes to preserve decorations. A relative inline with no offsets
+/// is safe here provided it does not establish the containing block for an
+/// absolutely positioned descendant.
+fn inline_wraps_only_in_flow_blocks(
+    tree: &DomTree,
+    id: NodeId,
+    styles: &HashMap,
+) -> bool {
+    let Some(style) = styles.get(&id) else {
+        return false;
+    };
+    if style.display != crate::Display::Inline
+        || style.is_inline_block
+        || style.before_pseudo.is_some()
+        || style.after_pseudo.is_some()
+        || style.float.is_some()
+        || matches!(style.position, Some(taffy::Position::Absolute))
+        || (style.position.is_some()
+            && (style.inset.iter().any(Option::is_some)
+                || style.inset_expressions.iter().any(Option::is_some)))
+    {
+        return false;
+    }
+
+    let mut saw_block = false;
+    for cid in tree.children(id) {
+        let Some(node) = tree.get_node(cid) else {
+            continue;
+        };
+        if let obscura_dom::tree::NodeData::Text { contents } = &node.data {
+            if !contents.trim().is_empty() {
+                return false;
+            }
+            continue;
+        }
+        if !node.is_element() {
+            continue;
+        }
+        let Some(child) = styles.get(&cid) else {
+            return false;
+        };
+        if child.display == crate::Display::None {
+            continue;
+        }
+        if matches!(child.position, Some(taffy::Position::Absolute)) || child.float.is_some() {
+            return false;
+        }
+        if child.display_contents {
+            if !inline_wraps_only_in_flow_blocks(tree, cid, styles) {
+                return false;
+            }
+            saw_block = true;
+        } else if child.display == crate::Display::Block {
+            saw_block = true;
+        } else {
+            return false;
+        }
+    }
+    saw_block
+}
+
+/// Recursively splice `display:contents`, decoration-free inline wrappers, and
+/// inline wrappers whose only generated flow content is block-level into a
+/// block container's effective child list. Descendants already carry inherited
+/// computed text styles, so removing these wrappers preserves shaping while
+/// exposing block-in-inline descendants early enough for anonymous block
+/// construction.
+fn flatten_boxless_inline_children(
+    tree: &DomTree,
+    children: &[NodeId],
+    styles: &HashMap,
+    out: &mut Vec,
+) {
+    for &cid in children {
+        let display_contents = styles
+            .get(&cid)
+            .map(|style| style.display_contents && style.display != crate::Display::None)
+            .unwrap_or(false);
+        if display_contents
+            || is_flattenable_inline(tree, cid, styles)
+            || inline_wraps_only_in_flow_blocks(tree, cid, styles)
+        {
+            let kids = tree.children(cid);
+            flatten_boxless_inline_children(tree, &kids, styles, out);
+        } else {
+            out.push(cid);
+        }
+    }
+}
+
 /// Style for a single-leaf inline run: a block-level box that fills the
 /// containing block's width, with height from the shaped text (measure fn).
 fn run_leaf_style() -> taffy::Style {
@@ -4545,8 +5448,8 @@ fn build_children_with_float_zone(
                     && style.margin == crate::Edges::default()
                     && style.padding == crate::Edges::default()
                     && style.border == crate::Edges::default()
-                    && style.before_content.is_none()
-                    && style.after_content.is_none()
+                    && style.before_pseudo.is_none()
+                    && style.after_pseudo.is_none()
             })
             .unwrap_or(true);
         no_size && tree.text_content(cid).trim().is_empty()
diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs
index 3dda517..a55ceca 100644
--- a/crates/obscura-render/src/inline.rs
+++ b/crates/obscura-render/src/inline.rs
@@ -14,7 +14,12 @@
 
 use std::{collections::HashMap, sync::Arc};
 
-use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
+use cosmic_text::{
+    Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FontSystem, Metrics, Shaping,
+    Style, SwashCache, SwashImage, Weight, Wrap,
+};
+use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
+use swash::zeno::{Angle, Format, Transform, Vector};
 
 use obscura_dom::tree::{DomTree, NodeId};
 
@@ -90,7 +95,15 @@ struct LoadedFace {
     max_weight: u16,
     shape_weight: u16,
     italic: bool,
-    variable: bool,
+}
+
+#[derive(Clone)]
+struct ResolvedFont {
+    family: Arc,
+    shape_weight: u16,
+    /// Authored `wght` coordinate for a ranged @font-face. Cosmic Text 0.12
+    /// cannot carry this into shaping, so it is kept separately for raster.
+    variation_weight: Option,
 }
 
 #[derive(Clone)]
@@ -106,7 +119,7 @@ fn resolve_loaded_font(
     requested_weight: u16,
     requested_italic: bool,
     loaded: &HashMap,
-) -> (Arc, u16) {
+) -> ResolvedFont {
     if let Some(stack) = fam {
         for token in stack.split(',') {
             let name = token
@@ -124,15 +137,25 @@ fn resolve_loaded_font(
                 } else {
                     exact_style
                 };
-                if let Some(face) = candidates.iter().copied().find(|face| {
-                    (face.min_weight..=face.max_weight).contains(&requested_weight)
-                }) {
-                    let weight = if face.variable {
-                        requested_weight
-                    } else {
-                        face.shape_weight
+                if let Some(face) = candidates
+                    .iter()
+                    .copied()
+                    .find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
+                {
+                    // cosmic-text 0.12 only accepts the named family when the
+                    // shaping weight exactly matches fontdb's weight for that
+                    // file. A variable face commonly advertises `100 900` in
+                    // CSS while fontdb records its default instance as 400.
+                    // Passing the authored weight makes cosmic-text reject the
+                    // right family and fall through to an unrelated exact-
+                    // weight face. Select the file at its database weight;
+                    // variable-axis shaping is not exposed by this version.
+                    return ResolvedFont {
+                        family: Arc::clone(&face.name),
+                        shape_weight: face.shape_weight,
+                        variation_weight: (face.min_weight != face.max_weight)
+                            .then_some(requested_weight),
                     };
-                    return (Arc::clone(&face.name), weight);
                 }
                 let available: Vec<_> =
                     candidates.iter().map(|face| face.min_weight).collect();
@@ -141,14 +164,22 @@ fn resolve_loaded_font(
                     .into_iter()
                     .find(|face| face.min_weight == matched)
                 {
-                    return (Arc::clone(&face.name), face.shape_weight);
+                    return ResolvedFont {
+                        family: Arc::clone(&face.name),
+                        shape_weight: face.shape_weight,
+                        variation_weight: None,
+                    };
                 }
             }
         }
     }
     let fallback = resolve_font_family(fam);
     let weights: &[u16] = &[400, 700];
-    (Arc::from(fallback), match_font_weight(requested_weight, weights))
+    ResolvedFont {
+        family: Arc::from(fallback),
+        shape_weight: match_font_weight(requested_weight, weights),
+        variation_weight: None,
+    }
 }
 
 /// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects
@@ -191,28 +222,6 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 {
     }
 }
 
-fn face_is_variable(data: &[u8], face_index: u32) -> bool {
-    let base = if data.get(..4) == Some(b"ttcf") {
-        let offset = 12usize.saturating_add(face_index as usize * 4);
-        data.get(offset..offset + 4)
-            .and_then(|bytes| bytes.try_into().ok())
-            .map(u32::from_be_bytes)
-            .unwrap_or(0) as usize
-    } else {
-        0
-    };
-    let Some(count) = data
-        .get(base + 4..base + 6)
-        .and_then(|bytes| bytes.try_into().ok())
-        .map(u16::from_be_bytes)
-    else {
-        return false;
-    };
-    (0..count as usize).any(|index| {
-        let start = base + 12 + index * 16;
-        data.get(start..start + 4) == Some(b"fvar")
-    })
-}
 /// Resolve `line-height: normal` from the selected face's horizontal header.
 ///
 /// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and
@@ -258,6 +267,20 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
 /// into an [`InlineItem`]'s clip-text fills.
 const META_UNDERLINE: usize = 1;
 const META_FILL_SHIFT: usize = 1;
+const META_VARIATION_BITS: usize = 10;
+const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS;
+const META_VARIATION_MASK: usize =
+    ((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT;
+const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE;
+
+fn metadata_fill(metadata: usize) -> Option {
+    ((metadata & META_FILL_MASK) >> META_FILL_SHIFT).checked_sub(1)
+}
+
+fn metadata_variation_weight(metadata: usize) -> Option {
+    let weight = ((metadata & META_VARIATION_MASK) >> META_VARIATION_SHIFT) as u16;
+    (weight != 0).then_some(weight)
+}
 
 type ClipTextFill = (f32, Vec<([u8; 4], Option)>);
 
@@ -286,10 +309,106 @@ pub struct TextEngine {
     font_system: FontSystem,
     loaded_families: HashMap,
     swash: SwashCache,
+    variable_swash: VariableSwashCache,
     items: Vec,
     replaced: Vec,
 }
 
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+struct VariableCacheKey {
+    glyph: CacheKey,
+    weight: u16,
+}
+
+/// Swash's ordinary cache key intentionally has no variation coordinates.
+/// Keep variable instances in a separate cache so a 400 glyph can never be
+/// reused for 700 and repeated paint remains O(1) after the first raster.
+struct VariableSwashCache {
+    context: ScaleContext,
+    images: HashMap>,
+}
+
+impl VariableSwashCache {
+    fn new() -> Self {
+        Self {
+            context: ScaleContext::new(),
+            images: HashMap::new(),
+        }
+    }
+
+    fn with_pixels(
+        &mut self,
+        font_system: &mut FontSystem,
+        cache_key: CacheKey,
+        weight: u16,
+        base: Color,
+        mut f: F,
+    ) {
+        let key = VariableCacheKey {
+            glyph: cache_key,
+            weight,
+        };
+        let image = self.images.entry(key).or_insert_with(|| {
+            let font = font_system.get_font(cache_key.font_id)?;
+            let mut scaler = self
+                .context
+                .builder(font.as_swash())
+                .size(f32::from_bits(cache_key.font_size_bits))
+                .hint(true)
+                .variations([("wght", weight as f32)])
+                .build();
+            let offset = Vector::new(cache_key.x_bin.as_float(), cache_key.y_bin.as_float());
+            Render::new(&[
+                Source::ColorOutline(0),
+                Source::ColorBitmap(StrikeWith::BestFit),
+                Source::Outline,
+            ])
+            .format(Format::Alpha)
+            .offset(offset)
+            .transform(
+                cache_key
+                    .flags
+                    .contains(CacheKeyFlags::FAKE_ITALIC)
+                    .then(|| {
+                        Transform::skew(
+                            Angle::from_degrees(14.0),
+                            Angle::from_degrees(0.0),
+                        )
+                    }),
+            )
+            .render(&mut scaler, cache_key.glyph_id)
+        });
+        let Some(image) = image else { return };
+        let left = image.placement.left;
+        let top = -image.placement.top;
+        match image.content {
+            SwashContent::Mask => {
+                for (index, alpha) in image.data.iter().copied().enumerate() {
+                    let x = index as i32 % image.placement.width as i32;
+                    let y = index as i32 / image.placement.width as i32;
+                    f(
+                        left + x,
+                        top + y,
+                        Color(((alpha as u32) << 24) | base.0 & 0x00FF_FFFF),
+                    );
+                }
+            }
+            SwashContent::Color => {
+                for (index, rgba) in image.data.chunks_exact(4).enumerate() {
+                    let x = index as i32 % image.placement.width as i32;
+                    let y = index as i32 / image.placement.width as i32;
+                    f(
+                        left + x,
+                        top + y,
+                        Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]),
+                    );
+                }
+            }
+            SwashContent::SubpixelMask => {}
+        }
+    }
+}
+
 const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1);
 
 #[derive(Clone, Copy)]
@@ -407,9 +526,6 @@ impl TextEngine {
             let shape_weight = face.weight.0;
             let italic = declared_italic
                 .unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal));
-            let variable = db
-                .with_face_data(id, face_is_variable)
-                .unwrap_or(false);
             let weight = declared_weight.unwrap_or((shape_weight, shape_weight));
             let declared_names: Vec = declared_family
                 .map(|name| vec![name])
@@ -424,7 +540,6 @@ impl TextEngine {
                     max_weight: weight.1,
                     shape_weight,
                     italic,
-                    variable,
                 });
             }
         }
@@ -434,6 +549,7 @@ impl TextEngine {
             font_system,
             loaded_families,
             swash: SwashCache::new(),
+            variable_swash: VariableSwashCache::new(),
             items: Vec::new(),
             replaced: Vec::new(),
         }
@@ -458,18 +574,13 @@ impl TextEngine {
         }
         let base = styles.get(&id)?;
         let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
-        let (family, weight) = resolve_loaded_font(
+        let font = resolve_loaded_font(
             base.font_family.as_deref(),
             crate::style::used_font_weight(base),
             base.font_style_italic.unwrap_or(false),
             &self.loaded_families,
         );
-        let ctx = base_span_ctx(
-            base,
-            family,
-            weight,
-            &mut collector,
-        );
+        let ctx = base_span_ctx(base, font, &mut collector);
         let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
         collect_spans(
             tree,
@@ -508,18 +619,13 @@ impl TextEngine {
         }
         let base = styles.get(&parent)?;
         let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
-        let (family, weight) = resolve_loaded_font(
+        let font = resolve_loaded_font(
             base.font_family.as_deref(),
             crate::style::used_font_weight(base),
             base.font_style_italic.unwrap_or(false),
             &self.loaded_families,
         );
-        let ctx = base_span_ctx(
-            base,
-            family,
-            weight,
-            &mut collector,
-        );
+        let ctx = base_span_ctx(base, font, &mut collector);
         let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
         for &cid in run {
             collect_node_spans(
@@ -551,17 +657,18 @@ impl TextEngine {
             last_was_space: true,
             clip_fills: Vec::new(),
         };
-        let (family, weight) = resolve_loaded_font(
+        let font = resolve_loaded_font(
             style.font_family.as_deref(),
             crate::style::used_font_weight(style),
             style.font_style_italic.unwrap_or(false),
             &self.loaded_families,
         );
-        let context = base_span_ctx(style, family, weight, &mut collector);
+        let context = base_span_ctx(style, font, &mut collector);
         let attrs = SpanAttrs {
             font_size: context.font_size,
             line_height: context.line_height,
             weight: context.weight,
+            variation_weight: context.variation_weight,
             italic: context.italic,
             underline: context.underline,
             color: context.color,
@@ -735,6 +842,7 @@ struct SpanAttrs {
     font_size: f32,
     line_height: f32,
     weight: u16,
+    variation_weight: Option,
     italic: bool,
     underline: bool,
     color: [u8; 4],
@@ -759,9 +867,14 @@ impl SpanAttrs {
         // reaches paint; the real gradient is selected through metadata.
         let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
         a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
-        // Underline and the optional fill index share the per-glyph metadata.
+        // Underline, the optional fill index, and a variable `wght` coordinate
+        // share the per-glyph metadata.
         let fill = self.clip_fill.map_or(0, |index| (index + 1) << META_FILL_SHIFT);
-        a = a.metadata(fill | usize::from(self.underline));
+        debug_assert_eq!(fill & !META_FILL_MASK, 0);
+        let variation = self
+            .variation_weight
+            .map_or(0, |weight| usize::from(weight.clamp(1, 1000)) << META_VARIATION_SHIFT);
+        a = a.metadata(fill | variation | usize::from(self.underline));
         a
     }
 }
@@ -773,6 +886,7 @@ struct SpanCtx {
     line_height: f32,
     color: [u8; 4],
     weight: u16,
+    variation_weight: Option,
     italic: bool,
     underline: bool,
     transform: TextTransform,
@@ -800,8 +914,7 @@ struct Collector {
 /// transparent (and invisible), unchanged.
 fn base_span_ctx(
     base: &LayoutStyle,
-    family: Arc,
-    weight: u16,
+    font: ResolvedFont,
     collector: &mut Collector,
 ) -> SpanCtx {
     let clip_fill = clip_text_fill(base).map(|fill| {
@@ -813,11 +926,12 @@ fn base_span_ctx(
         font_size: base.font_size.unwrap_or(16.0),
         line_height: used_line_height(base),
         color: base.color.unwrap_or([0, 0, 0, 255]),
-        weight,
+        weight: font.shape_weight,
+        variation_weight: font.variation_weight,
         italic: base.font_style_italic.unwrap_or(false),
         underline: base.underline.unwrap_or(false),
         transform: base.text_transform.unwrap_or(TextTransform::None),
-        family,
+        family: font.family,
         clip_fill,
     }
 }
@@ -864,6 +978,7 @@ fn collect_node_spans(
                 font_size: ctx.font_size,
                 line_height: ctx.line_height,
                 weight: ctx.weight,
+                variation_weight: ctx.variation_weight,
                 italic: ctx.italic,
                 underline: ctx.underline,
                 color: ctx.color,
@@ -883,6 +998,7 @@ fn collect_node_spans(
                     font_size: ctx.font_size,
                     line_height: ctx.line_height,
                     weight: ctx.weight,
+                    variation_weight: ctx.variation_weight,
                     italic: ctx.italic,
                     underline: ctx.underline,
                     color: ctx.color,
@@ -907,7 +1023,7 @@ fn collect_node_spans(
             let requested_weight = style
                 .map(crate::style::used_font_weight)
                 .unwrap_or(ctx.weight);
-            let (family, weight) = style
+            let font = style
                 .and_then(|style| style.font_family.as_deref())
                 .map(|family| {
                     resolve_loaded_font(
@@ -919,7 +1035,17 @@ fn collect_node_spans(
                         loaded_families,
                     )
                 })
-                .unwrap_or_else(|| (Arc::clone(&ctx.family), requested_weight));
+                .unwrap_or_else(|| ResolvedFont {
+                    family: Arc::clone(&ctx.family),
+                    shape_weight: if ctx.variation_weight.is_some() {
+                        ctx.weight
+                    } else {
+                        requested_weight
+                    },
+                    variation_weight: ctx
+                        .variation_weight
+                        .map(|_| requested_weight.clamp(1, 1000)),
+                });
             let child = SpanCtx {
                 font_size: style
                     .and_then(|style| style.font_size)
@@ -928,13 +1054,14 @@ fn collect_node_spans(
                     .map(used_line_height)
                     .unwrap_or(ctx.line_height),
                 color,
-                weight,
+                weight: font.shape_weight,
+                variation_weight: font.variation_weight,
                 italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
                 // Underline propagates in: an ancestor's underline covers
                 // descendant text; an element only sets its own via CSS.
                 underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
                 transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
-                family,
+                family: font.family,
                 clip_fill,
             };
             collect_spans(
@@ -998,8 +1125,17 @@ fn buffer_size(buffer: &Buffer) -> (f32, f32) {
 /// plain inline formatting (no atomic inline boxes, no block children, no
 /// inline element carrying its own box)? Such a container collapses cleanly
 /// to one shaped buffer; anything else keeps the general build path.
-fn is_pure_text_ifc(tree: &DomTree, id: NodeId, styles: &std::collections::HashMap) -> bool {
-    let Some(style) = styles.get(&id) else { return false };
+fn is_pure_text_ifc(
+    tree: &DomTree,
+    id: NodeId,
+    styles: &std::collections::HashMap,
+) -> bool {
+    let Some(style) = styles.get(&id) else {
+        return false;
+    };
+    if style.before_pseudo.is_some() || style.after_pseudo.is_some() {
+        return false;
+    }
     // Only containers that lay their children out in normal flow (block, or
     // the flex-column stand-ins our UA sheet uses for td/th/center). A real
     // flex/grid row with inline children is rare and better left to taffy.
@@ -1080,8 +1216,8 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM
                 && style.position.is_none()
                 && style.float.is_none()
                 && !style.overflow_hidden
-                && style.before_content.is_none()
-                && style.after_content.is_none();
+                && style.before_pseudo.is_none()
+                && style.after_pseudo.is_none();
             if !foldable_inline {
                 return false;
             }
@@ -1177,6 +1313,7 @@ impl TextEngine {
         let TextEngine {
             font_system,
             swash,
+            variable_swash,
             items,
             ..
         } = self;
@@ -1203,7 +1340,7 @@ impl TextEngine {
             let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
             for g in run.glyphs {
                 let underlined = g.metadata & META_UNDERLINE != 0;
-                if let Some(fill_index) = (g.metadata >> META_FILL_SHIFT).checked_sub(1) {
+                if let Some(fill_index) = metadata_fill(g.metadata) {
                     if let Some(bounds) = fill_bounds.get_mut(fill_index) {
                         let glyph_bounds = (
                             g.x,
@@ -1259,12 +1396,8 @@ impl TextEngine {
             for glyph in run.glyphs {
                 let physical = glyph.physical((0.0, 0.0), 1.0);
                 let glyph_color = glyph.color_opt.unwrap_or(default);
-                let fill_index = (glyph.metadata >> META_FILL_SHIFT).checked_sub(1);
-                swash.with_pixels(
-                    font_system,
-                    physical.cache_key,
-                    glyph_color,
-                    |x, y, color| {
+                let fill_index = metadata_fill(glyph.metadata);
+                let mut draw_pixel = |x, y, color: Color| {
                     let a = color.a() as u32;
                     if a == 0 {
                         return;
@@ -1311,8 +1444,23 @@ impl TextEngine {
                     let out_b = sb + (dst.blue() as u32 * inv / 255);
                     pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8)
                         .unwrap_or(dst);
-                    },
-                );
+                };
+                if let Some(weight) = metadata_variation_weight(glyph.metadata) {
+                    variable_swash.with_pixels(
+                        font_system,
+                        physical.cache_key,
+                        weight,
+                        glyph_color,
+                        &mut draw_pixel,
+                    );
+                } else {
+                    swash.with_pixels(
+                        font_system,
+                        physical.cache_key,
+                        glyph_color,
+                        &mut draw_pixel,
+                    );
+                }
             }
         }
 
@@ -1349,6 +1497,7 @@ impl TextEngine {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use base64::Engine as _;
 
     const RED: [u8; 4] = [255, 0, 0, 255];
     const BLUE: [u8; 4] = [0, 0, 255, 255];
@@ -1383,7 +1532,6 @@ mod tests {
                     max_weight: 400,
                     shape_weight: 400,
                     italic: false,
-                    variable: false,
                 },
                 LoadedFace {
                     name: Arc::from("Poppins Medium"),
@@ -1391,7 +1539,6 @@ mod tests {
                     max_weight: 500,
                     shape_weight: 500,
                     italic: false,
-                    variable: false,
                 },
                 LoadedFace {
                     name: Arc::from("Poppins"),
@@ -1399,17 +1546,203 @@ mod tests {
                     max_weight: 700,
                     shape_weight: 700,
                     italic: false,
-                    variable: false,
                 },
             ],
         };
         let loaded = HashMap::from([("poppins".to_string(), family)]);
         let medium = resolve_loaded_font(Some("Poppins, sans-serif"), 500, false, &loaded);
-        assert_eq!(medium.0.as_ref(), "Poppins Medium");
-        assert_eq!(medium.1, 500);
+        assert_eq!(medium.family.as_ref(), "Poppins Medium");
+        assert_eq!(medium.shape_weight, 500);
+        assert_eq!(medium.variation_weight, None);
         let semibold = resolve_loaded_font(Some("Poppins"), 600, false, &loaded);
-        assert_eq!(semibold.0.as_ref(), "Poppins");
-        assert_eq!(semibold.1, 700);
+        assert_eq!(semibold.family.as_ref(), "Poppins");
+        assert_eq!(semibold.shape_weight, 700);
+        assert_eq!(semibold.variation_weight, None);
+    }
+
+    #[test]
+    fn ranged_face_shapes_at_its_font_database_weight() {
+        let tree = obscura_dom::parse_html("

Variable family

"); + let copy = tree.get_element_by_id("copy").unwrap(); + let mut style = LayoutStyle::default(); + style.display = Display::Block; + style.font_family = Some("test variable".to_string()); + style.font_weight = Some("700".to_string()); + style.font_size = Some(32.0); + let styles = HashMap::from([(copy, style)]); + + let mut engine = TextEngine::new(); + engine.loaded_families.insert( + "test variable".to_string(), + LoadedFamily { + faces: vec![LoadedFace { + name: Arc::from(FAMILY), + min_weight: 100, + max_weight: 900, + shape_weight: 400, + italic: false, + }], + }, + ); + + let item = engine.try_build(&tree, copy, &styles).unwrap(); + engine.measure(item, None); + let font_id = engine.items[item] + .buffer + .layout_runs() + .next() + .and_then(|run| run.glyphs.first()) + .map(|glyph| glyph.font_id) + .unwrap(); + let face = engine.font_system.db().face(font_id).unwrap(); + assert!( + face.families.iter().any(|(name, _)| name == FAMILY), + "authored family must not fall through to an unrelated exact-weight face" + ); + assert_eq!(face.weight.0, 400); + } + + #[test] + fn ranged_face_preserves_requested_weight_for_variable_raster() { + let loaded = HashMap::from([( + "inter".to_string(), + LoadedFamily { + faces: vec![LoadedFace { + name: Arc::from("Inter"), + min_weight: 100, + max_weight: 900, + shape_weight: 400, + italic: false, + }], + }, + )]); + let resolved = resolve_loaded_font(Some("Inter, sans-serif"), 725, false, &loaded); + assert_eq!(resolved.family.as_ref(), "Inter"); + assert_eq!(resolved.shape_weight, 400); + assert_eq!(resolved.variation_weight, Some(725)); + } + + #[test] + fn glyph_metadata_keeps_fill_and_variable_weight_independent() { + let attrs = SpanAttrs { + font_size: 16.0, + line_height: 18.0, + weight: 400, + variation_weight: Some(725), + italic: false, + underline: true, + color: [1, 2, 3, 255], + family: Arc::from(FAMILY), + clip_fill: Some(37), + }; + let metadata = attrs.to_attrs().metadata; + assert_ne!(metadata & META_UNDERLINE, 0); + assert_eq!(metadata_fill(metadata), Some(37)); + assert_eq!(metadata_variation_weight(metadata), Some(725)); + } + + fn variable_font_fixture() -> Vec { + let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64") + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect(); + let compressed = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("decode variable-font fixture"); + wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture") + } + + fn render_variable_weight(weight: u16) -> ((f32, f32), u64, Vec) { + let mut engine = TextEngine::new_with_web_fonts(&[WebFont { + data: variable_font_fixture(), + family: Some("Obscura VF Test".to_string()), + weight: Some((100, 900)), + italic: Some(false), + }]); + let tree = obscura_dom::parse_html("

MMMMMMMM

"); + let copy = tree.get_element_by_id("copy").unwrap(); + let mut style = LayoutStyle::default(); + style.display = Display::Block; + style.font_family = Some("Obscura VF Test".to_string()); + style.font_weight = Some(weight.to_string()); + style.font_size = Some(64.0); + style.line_height = Some(crate::LineHeight::Px(80.0)); + let styles = HashMap::from([(copy, style)]); + let item = engine.try_build(&tree, copy, &styles).unwrap(); + let geometry = engine.measure(item, Some(600.0)); + engine.finalize(item, (0.0, 0.0), 600.0, None); + let mut pixmap = tiny_skia::Pixmap::new(600, 100).unwrap(); + engine.paint_item(item, &mut pixmap, (0.0, 0.0)); + let ink = pixmap + .pixels() + .iter() + .map(|pixel| u64::from(pixel.alpha())) + .sum(); + let mut cached_weights: Vec<_> = engine + .variable_swash + .images + .keys() + .map(|key| key.weight) + .collect(); + cached_weights.sort_unstable(); + cached_weights.dedup(); + (geometry, ink, cached_weights) + } + + #[test] + fn variable_wght_changes_true_outline_without_changing_layout_geometry() { + let (regular_geometry, regular_ink, regular_cache) = render_variable_weight(400); + let (black_geometry, black_ink, black_cache) = render_variable_weight(900); + assert_eq!(regular_geometry, black_geometry); + assert!( + black_ink > regular_ink * 13 / 10, + "wght=900 should carry substantially more raster ink: {regular_ink} vs {black_ink}" + ); + assert_eq!(regular_cache, vec![400]); + assert_eq!(black_cache, vec![900]); + } + + #[test] + fn variable_glyph_cache_keys_include_weight_axis() { + let mut engine = TextEngine::new_with_web_fonts(&[WebFont { + data: variable_font_fixture(), + family: Some("Obscura VF Test".to_string()), + weight: Some((100, 900)), + italic: Some(false), + }]); + let tree = obscura_dom::parse_html( + "

MM

", + ); + let copy = tree.get_element_by_id("copy").unwrap(); + let regular = tree.get_element_by_id("regular").unwrap(); + let black = tree.get_element_by_id("black").unwrap(); + let mut base = LayoutStyle::default(); + base.display = Display::Block; + base.font_family = Some("Obscura VF Test".to_string()); + base.font_size = Some(64.0); + base.line_height = Some(crate::LineHeight::Px(80.0)); + let mut regular_style = base.clone(); + regular_style.display = Display::Inline; + regular_style.font_weight = Some("400".to_string()); + let mut black_style = regular_style.clone(); + black_style.font_weight = Some("900".to_string()); + let styles = HashMap::from([ + (copy, base), + (regular, regular_style), + (black, black_style), + ]); + let item = engine.try_build(&tree, copy, &styles).unwrap(); + engine.measure(item, Some(200.0)); + engine.finalize(item, (0.0, 0.0), 200.0, None); + let mut pixmap = tiny_skia::Pixmap::new(200, 100).unwrap(); + engine.paint_item(item, &mut pixmap, (0.0, 0.0)); + let weights: std::collections::HashSet<_> = engine + .variable_swash + .images + .keys() + .map(|key| key.weight) + .collect(); + assert_eq!(weights, std::collections::HashSet::from([400, 900])); } #[test] diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index d6782a1..cdb06fe 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -20,12 +20,14 @@ pub mod style; pub use style::compute_style; pub mod dom; -pub use dom::{layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout}; +pub use dom::{ + layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout, StickyLayout, +}; #[cfg(feature = "paint")] mod paint; #[cfg(feature = "paint")] -pub use paint::{paint_dom, screenshot_png}; +pub use paint::{paint_dom, paint_dom_scrolled, screenshot_png, screenshot_png_scrolled}; // Real inline text layout (cosmic-text) lives behind the paint feature; the // layout-only build keeps the lighter word-split geometry. The stub lets @@ -301,7 +303,19 @@ pub struct LayoutStyle { pub mask_repeat: Option<(bool, bool)>, /// Foreground (text) color for the paint step. pub color: Option<[u8; 4]>, + /// Computed SVG presentation properties supplied by author CSS. Inline + /// SVG is serialized into a standalone document for resvg; without + /// carrying these values across that boundary, stylesheet-driven icons + /// and text lose their fill/stroke and can become completely invisible. + pub svg_fill: Option, + pub svg_stroke: Option, + pub svg_stroke_width: Option, pub border_color: Option<[u8; 4]>, + /// Used color scheme for CSS Color 5 `light-dark()`. The renderer's + /// current user preference is light; an inherited `color-scheme: dark` + /// subtree switches this to true, while `normal`, `light`, or a list that + /// permits light keeps the light scheme. + pub color_scheme_dark: bool, pub font_size: Option, /// `font-size` given in a font/viewport-relative unit, resolved to /// `font_size` (px) during the inheritance pass against the parent and @@ -391,6 +405,10 @@ pub struct LayoutStyle { /// Distinguishes `fixed` from `absolute`; both map to taffy's absolute /// layout mode, but fixed boxes use the initial containing block. pub position_fixed: bool, + /// Distinguishes `sticky` from ordinary relative positioning. Sticky boxes + /// remain in normal flow; their insets constrain a scroll-time translation + /// and therefore must not be applied as taffy relative offsets. + pub position_sticky: bool, pub inset: [Option; 4], // top, right, bottom, left /// Deferred functional inset expressions in top/right/bottom/left order. pub inset_expressions: [Option; 4], @@ -401,6 +419,15 @@ pub struct LayoutStyle { /// clipped box used for skip-links and screen-reader-only labels) actually /// invisible instead of painting its text wherever it lands. pub overflow_hidden: bool, + /// Whether computed overflow establishes a scroll container. `clip` + /// clips paint but deliberately does not establish one, which matters for + /// selecting the scrollport that controls a sticky descendant. + pub overflow_scroll_container: bool, + /// Number of classic scrollbar gutters reserved by + /// `scrollbar-gutter:stable` (one) or `stable both-edges` (two). Root + /// gutters reduce the initial containing block even when the scrollbar + /// itself is visually hidden in a headless screenshot. + pub scrollbar_gutters: u8, /// `float: left|right`. True CSS float needs per-line reflow around the /// float's shape, which taffy's block/flex/grid modes do not do; see @@ -445,6 +472,11 @@ pub struct LayoutStyle { /// compositing. pub effectively_invisible: bool, + /// A CSS image supplied by `content: url(...)` on a replaced element. + /// This is distinct from generated pseudo text: on an `` it becomes + /// the element's image source and contributes intrinsic dimensions just + /// like an HTML `src`. + pub content_image: Option, /// Literal text injected by a `::before`/`::after` rule with a plain /// string-literal `content` (see `css::Stylesheet::pseudo_content`). /// Rendered as an extra word-run at the start/end of this element's @@ -863,12 +895,14 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { // Positioning. Absolute/fixed take the box out of flow. if let Some(pos) = style.position { s.position = pos; - s.inset = taffy::Rect { - top: inset_lpa(style.inset[0]), - right: inset_lpa(style.inset[1]), - bottom: inset_lpa(style.inset[2]), - left: inset_lpa(style.inset[3]), - }; + if !style.position_sticky { + s.inset = taffy::Rect { + top: inset_lpa(style.inset[0]), + right: inset_lpa(style.inset[1]), + bottom: inset_lpa(style.inset[2]), + left: inset_lpa(style.inset[3]), + }; + } } s.margin = rect_auto(style.margin, style.margin_auto); diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index ff4030a..29861e5 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -12,6 +12,9 @@ use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf"); static MONO_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); +static FONT_BOLD_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf"); +static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); +static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); use crate::dom::layout_dom_with_web_fonts; @@ -21,6 +24,18 @@ use crate::dom::layout_dom_with_web_fonts; /// majority of real-world markup; without it only absolute and `data:` URLs /// can be fetched. pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option { + paint_dom_scrolled(tree, viewport, base_url, (0.0, 0.0)) +} + +/// Render the visible viewport after root scrolling. Normal document content +/// is translated by the clamped scroll offset while viewport-fixed subtrees +/// remain anchored to the initial containing block. +pub fn paint_dom_scrolled( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + scroll: (f32, f32), +) -> Option { let (w, h) = (viewport.0 as u32, viewport.1 as u32); let mut pixmap = Pixmap::new(w, h)?; pixmap.fill(Color::WHITE); @@ -34,9 +49,31 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // CSS-sized image with no width/height attribute would otherwise be 0x0 // and never paint). This seeds the same cache the paint pass reads, so // each URL is still fetched at most once. - let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); + let mut intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); let fonts = collect_web_fonts(tree, base_url); + // Most framework pages use web fonts and many decorative SVG icons, but + // only SVG text needs the page font faces. Avoid cloning/loading the page + // font database for ordinary icons and HTML-only text. + let svg_fonts = if has_inline_svg_text(tree) { + svg_font_database_with_web_fonts(&fonts) + } else { + svg_font_database() + }; let mut laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts); + // `content:url(...)` is computed by the author cascade, whereas ordinary + // HTML image sources are available before layout. Pay for a second layout + // only on the uncommon pages that actually use a CSS image as replaced + // content: its metadata then enters the same intrinsic-size map as `src`. + if collect_content_image_intrinsics( + tree, + &laid.styles, + base_url, + &mut image_cache, + &mut intrinsic, + ) { + laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts); + } + apply_document_scroll(tree, &mut laid, viewport, scroll); let root_font_size = tree .query_selector("html") .ok() @@ -106,7 +143,57 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - .chain(pos_layers.into_iter().flat_map(|(_, sub)| sub)) .collect(); - for nid in paint_order { + // Generated boxes are anonymous layout children. ::before paints directly + // after its host's own box; ::after paints after the host's last DOM + // descendant in this paint order. Build the latter schedule only on pages + // that actually materialized a generated box. A reverse DOM-preorder pass + // propagates each node's paint index to its parent once, deriving subtree + // endpoints in O(nodes + generated boxes), including reordered z layers. + let mut generated_before: std::collections::HashMap< + obscura_dom::tree::NodeId, + Vec, + > = std::collections::HashMap::new(); + let mut generated_after_at: Vec> = + vec![Vec::new(); paint_order.len()]; + if !laid.generated_boxes.is_empty() { + let paint_indices: std::collections::HashMap = + paint_order + .iter() + .enumerate() + .map(|(index, &nid)| (nid, index)) + .collect(); + let mut last_index: std::collections::HashMap = + paint_indices.clone(); + let dom_preorder = tree.descendants(tree.document()); + for nid in dom_preorder.into_iter().rev() { + let Some(index) = last_index.get(&nid).copied() else { + continue; + }; + if let Some(parent) = tree.get_node(nid).and_then(|node| node.parent) { + last_index + .entry(parent) + .and_modify(|last| *last = (*last).max(index)) + .or_insert(index); + } + } + for generated in laid.generated_boxes.iter().copied() { + match generated.kind { + crate::dom::GeneratedBoxKind::Before => { + generated_before + .entry(generated.host) + .or_default() + .push(generated); + } + crate::dom::GeneratedBoxKind::After => { + if let Some(index) = last_index.get(&generated.host) { + generated_after_at[*index].push(generated); + } + } + } + } + } + + for (paint_index, nid) in paint_order.into_iter().enumerate() { if svg_subtree_skip.contains(&nid) { continue; } @@ -117,6 +204,17 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - if node.is_text() { paint_text_node(tree, nid, &laid, &mut pixmap); + for generated in &generated_after_at[paint_index] { + paint_in_flow_generated_box( + &mut pixmap, + generated, + &laid, + viewport, + root_font_size, + base_url, + &mut image_cache, + ); + } continue; } @@ -283,6 +381,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - viewport, root_font_size, clip, + base_url, + &mut image_cache, ); } @@ -336,7 +436,12 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - } if name.local.as_ref() == "img" { - if let Some((src, _density)) = resolve_img_url(tree, nid) { + let source = style + .content_image + .as_ref() + .map(|url| (url.clone(), 1.0)) + .or_else(|| resolve_img_url(tree, nid)); + if let Some((src, _density)) = source { // `visible_rect` is the border box already intersected with the // ancestor overflow clip: the raster must not paint past it (a // half-scrolled carousel slide's image otherwise bleeds over @@ -387,7 +492,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // is drawn at its full border-box size (undistorted) and clipped to the // overflow-visible region. if name.local.as_ref() == "svg" { - let mut markup = serialize_svg(tree, nid); + let mut markup = serialize_svg_styled(tree, nid, &laid.styles); // Resolve referenced symbols before carrying the host color into // the standalone document. A document-level/external symbol may // itself contain `currentColor`, and therefore has to be present @@ -406,8 +511,14 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // the sprite, splice the referenced `` into a local ``, // and rewrite the href to a same-document `#id`. Same-document // `` (empty url) is untouched. - if let Some(content) = render_svg(markup.as_bytes(), rect.width as u32, rect.height as u32) { - let mask = clip.and_then(|_| rect_clip_mask(pixmap.width(), pixmap.height(), &visible_rect)); + if let Some(content) = render_svg_with_font_database( + markup.as_bytes(), + rect.width as u32, + rect.height as u32, + &svg_fonts, + ) { + let mask = clip + .and_then(|_| rect_clip_mask(pixmap.width(), pixmap.height(), &visible_rect)); pixmap.draw_pixmap( rect.x as i32, rect.y as i32, @@ -422,6 +533,20 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - } } + if let Some(generated) = generated_before.get(&nid) { + for generated in generated { + paint_in_flow_generated_box( + &mut pixmap, + generated, + &laid, + viewport, + root_font_size, + base_url, + &mut image_cache, + ); + } + } + // List-item marker (bullet or number), drawn in the indent to the left // of the item's content box. `list_style` is inherited and resolved, // so `None` (e.g. a nav `
    `) suppresses it. @@ -542,6 +667,17 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - } } } + for generated in &generated_after_at[paint_index] { + paint_in_flow_generated_box( + &mut pixmap, + generated, + &laid, + viewport, + root_font_size, + base_url, + &mut image_cache, + ); + } } // Inline formatting contexts shaped by cosmic-text (paragraphs, headings, @@ -579,6 +715,59 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - Some(pixmap) } +fn apply_document_scroll( + tree: &DomTree, + laid: &mut crate::DomLayout, + viewport: (f32, f32), + requested: (f32, f32), +) { + let content = laid.scrolling_content_size(tree, viewport); + let scroll_x = if requested.0.is_finite() { + requested + .0 + .clamp(0.0, (content.0 - viewport.0).max(0.0)) + } else { + 0.0 + }; + let scroll_y = if requested.1.is_finite() { + requested + .1 + .clamp(0.0, (content.1 - viewport.1).max(0.0)) + } else { + 0.0 + }; + let sticky_layout = laid.root_sticky_layout(tree, viewport); + if scroll_x == 0.0 && scroll_y == 0.0 && sticky_layout.is_empty() { + return; + } + let sticky = + sticky_layout.translations(viewport, (scroll_x, scroll_y)); + let sticky_clips = + sticky_layout.clip_translations_from(&sticky); + + let viewport_fixed = laid.viewport_fixed_nodes(tree); + for id in tree.descendants(tree.document()) { + if viewport_fixed.contains(&id) { + continue; + } + let offset = laid.translates.entry(id).or_insert((0.0, 0.0)); + if let Some((x, y)) = sticky.get(&id) { + offset.0 += x; + offset.1 += y; + } + offset.0 -= scroll_x; + offset.1 -= scroll_y; + if let Some(Some(clip)) = laid.clip_rects.get_mut(&id) { + if let Some((x, y)) = sticky_clips.get(&id) { + clip.x += x; + clip.y += y; + } + clip.x -= scroll_x; + clip.y -= scroll_y; + } + } +} + /// A closed rounded-rectangle path, corners approximated by quadratic curves /// (visually indistinguishable from true arcs at typical UI radii). `r` is /// clamped so it never exceeds half the shorter side. @@ -756,6 +945,17 @@ pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&st paint_dom(tree, viewport, base_url)?.encode_png().ok() } +/// PNG convenience wrapper for a scrolled root viewport. +pub fn screenshot_png_scrolled( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + scroll: (f32, f32), +) -> Option> { + paint_dom_scrolled(tree, viewport, base_url, scroll) + .and_then(|pixmap| pixmap.encode_png().ok()) +} + /// A representative visible color for `background-clip: text` text whose own /// color is transparent, used on the word-split paint path (the cosmic-text IFC /// path samples the gradient per glyph in `inline`). Returns the gradient's mid @@ -1818,6 +2018,210 @@ fn split_background_size_components(value: &str) -> Vec<&str> { components } +fn paint_in_flow_generated_box( + pixmap: &mut Pixmap, + generated: &crate::dom::GeneratedBox, + laid: &crate::dom::DomLayout, + viewport: (f32, f32), + root_font_size: f32, + base_url: Option<&str>, + image_cache: &mut std::collections::HashMap>>, +) { + let Some(host_style) = laid.styles.get(&generated.host) else { + return; + }; + let style = match generated.kind { + crate::dom::GeneratedBoxKind::Before => host_style.before_pseudo.as_deref(), + crate::dom::GeneratedBoxKind::After => host_style.after_pseudo.as_deref(), + }; + let Some(style) = style else { return }; + if style.effectively_invisible { + return; + } + + let (ox, oy) = laid + .translates + .get(&generated.host) + .copied() + .unwrap_or((0.0, 0.0)); + let rect = crate::Rect { + x: generated.rect.x + ox, + y: generated.rect.y + oy, + width: generated.rect.width, + height: generated.rect.height, + }; + let mut clip = laid.clip_rects.get(&generated.host).copied().flatten(); + if host_style.overflow_hidden { + if let Some(host_rect) = laid.rects.get(&generated.host) { + let own = crate::Rect { + x: host_rect.x + ox, + y: host_rect.y + oy, + width: host_rect.width, + height: host_rect.height, + }; + clip = Some(match clip { + Some(inherited) => inherited.intersect(&own).unwrap_or(crate::Rect::default()), + None => own, + }); + } + } + let visible = match clip { + Some(clip) => rect.intersect(&clip), + None => Some(rect), + }; + let Some(visible) = visible else { return }; + if visible.width <= 0.0 || visible.height <= 0.0 { + return; + } + + if let Some(shadow) = style.box_shadow { + paint_box_shadow(pixmap, &shadow, &rect, style.border_radius, clip); + } + let path = if style.border_radius > 0.5 { + rounded_rect_path( + visible.x, + visible.y, + visible.width, + visible.height, + style.border_radius, + ) + } else { + Rect::from_xywh(visible.x, visible.y, visible.width, visible.height).and_then(|rect| { + let mut builder = PathBuilder::new(); + builder.push_rect(rect); + builder.finish() + }) + }; + let Some(path) = path else { return }; + if style.mask_image.is_none() && !style.background_clip_text { + if let Some(color) = style.background_color { + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); + paint.anti_alias = style.border_radius > 0.5; + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } + if let Some((center, stops)) = &style.background_radial_gradient { + paint_radial_gradient(pixmap, &path, &rect, *center, stops); + } + if let Some((angle, center, stops)) = &style.background_conic_gradient { + paint_conic_gradient(pixmap, &rect, *angle, *center, stops); + } + if let Some((angle, stops)) = &style.background_gradient { + paint_linear_gradient(pixmap, &path, &rect, *angle, stops); + } + } + if let Some(mask_url) = &style.mask_image { + let fill = style + .background_color + .or(style.color) + .unwrap_or([0, 0, 0, 255]); + paint_mask( + mask_url, + base_url, + &visible, + fill, + style.background_gradient.as_ref(), + style.background_conic_gradient.as_ref(), + style.mask_size, + style.mask_repeat, + pixmap, + image_cache, + ); + } else if let Some(background_url) = &style.background_image { + if let Some(image_rect) = background_image_rect( + background_url, + base_url, + &rect, + style.background_size, + style.background_size_expression.as_deref(), + style.background_size_fit, + style.background_position, + style.font_size.unwrap_or(16.0), + root_font_size, + viewport, + image_cache, + ) { + paint_image( + background_url, + base_url, + &image_rect, + &visible, + crate::ObjectFit::Fill, + pixmap, + image_cache, + ); + } + } + + let border_color = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]); + let mut border_paint = Paint::default(); + border_paint.set_color(Color::from_rgba8( + border_color[0], + border_color[1], + border_color[2], + border_color[3], + )); + let mut borders = PathBuilder::new(); + let mut push_edge = |edge: crate::Rect| { + let edge = match clip { + Some(clip) => edge.intersect(&clip), + None => Some(edge), + }; + if let Some(edge) = edge { + if let Some(rect) = Rect::from_xywh(edge.x, edge.y, edge.width, edge.height) { + borders.push_rect(rect); + } + } + }; + if style.border.top > 0.0 { + push_edge(crate::Rect { + x: rect.x, + y: rect.y, + width: rect.width, + height: style.border.top, + }); + } + if style.border.right > 0.0 { + push_edge(crate::Rect { + x: rect.x + rect.width - style.border.right, + y: rect.y, + width: style.border.right, + height: rect.height, + }); + } + if style.border.bottom > 0.0 { + push_edge(crate::Rect { + x: rect.x, + y: rect.y + rect.height - style.border.bottom, + width: rect.width, + height: style.border.bottom, + }); + } + if style.border.left > 0.0 { + push_edge(crate::Rect { + x: rect.x, + y: rect.y, + width: style.border.left, + height: rect.height, + }); + } + if let Some(path) = borders.finish() { + pixmap.fill_path( + &path, + &border_paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } +} + fn paint_positioned_pseudo( text_engine: &mut crate::inline::TextEngine, pixmap: &mut Pixmap, @@ -1826,6 +2230,8 @@ fn paint_positioned_pseudo( viewport: (f32, f32), root_font_size: f32, ancestor_clip: Option, + base_url: Option<&str>, + image_cache: &mut std::collections::HashMap>>, ) { if style.position != Some(taffy::Position::Absolute) { return; @@ -1876,19 +2282,69 @@ fn paint_positioned_pseudo( }) }; let Some(path) = path else { return }; - if let Some(color) = style.background_color { - let mut paint = Paint::default(); - paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); - pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + if style.mask_image.is_none() { + if let Some(color) = style.background_color { + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } + if let Some((center, stops)) = &style.background_radial_gradient { + paint_radial_gradient(pixmap, &path, &rect, *center, stops); + } + if let Some((angle, center, stops)) = &style.background_conic_gradient { + paint_conic_gradient(pixmap, &rect, *angle, *center, stops); + } + if let Some((angle, stops)) = &style.background_gradient { + paint_linear_gradient(pixmap, &path, &rect, *angle, stops); + } } - if let Some((center, stops)) = &style.background_radial_gradient { - paint_radial_gradient(pixmap, &path, &rect, *center, stops); - } - if let Some((angle, center, stops)) = &style.background_conic_gradient { - paint_conic_gradient(pixmap, &rect, *angle, *center, stops); - } - if let Some((angle, stops)) = &style.background_gradient { - paint_linear_gradient(pixmap, &path, &rect, *angle, stops); + if let Some(mask_url) = &style.mask_image { + let fill = style + .background_color + .or(style.color) + .unwrap_or([0, 0, 0, 255]); + paint_mask( + mask_url, + base_url, + &visible, + fill, + style.background_gradient.as_ref(), + style.background_conic_gradient.as_ref(), + style.mask_size, + style.mask_repeat, + pixmap, + image_cache, + ); + } else if let Some(bg_url) = &style.background_image { + if let Some(image_rect) = background_image_rect( + bg_url, + base_url, + &rect, + style.background_size, + style.background_size_expression.as_deref(), + style.background_size_fit, + style.background_position, + em, + root_font_size, + viewport, + image_cache, + ) { + paint_image( + bg_url, + base_url, + &image_rect, + &visible, + crate::ObjectFit::Fill, + pixmap, + image_cache, + ); + } } if let Some(content) = style.before_content.as_deref().filter(|content| !content.is_empty()) { let Some(item) = text_engine.push_generated_text(content, style) else { return }; @@ -1942,6 +2398,47 @@ fn collect_image_intrinsics( out } +/// Add intrinsic metadata for CSS `content:url(...)` images after the first +/// cascade has exposed their computed URL. Returns true when a new intrinsic +/// entry was added and layout therefore needs one resource-aware retry. +fn collect_content_image_intrinsics( + tree: &DomTree, + styles: &std::collections::HashMap, + base_url: Option<&str>, + cache: &mut std::collections::HashMap>>, + out: &mut std::collections::HashMap, +) -> bool { + let mut changed = false; + for (&nid, style) in styles { + let Some(node) = tree.get_node(nid) else { + continue; + }; + if node + .as_element() + .map_or(true, |name| name.local.as_ref() != "img") + { + continue; + } + let Some(url) = style.content_image.as_deref() else { + continue; + }; + let Some(bytes) = fetch_bytes(url, base_url, cache) else { + continue; + }; + let dimensions = image_dimensions(&bytes) + .map(|(width, height)| (width as f32, height as f32)) + .or_else(|| svg_intrinsic(&bytes)); + let Some((width, height)) = dimensions else { + continue; + }; + if width <= 0.0 || height <= 0.0 { + continue; + } + changed |= out.insert(nid, (width, height)) != Some((width, height)); + } + changed +} + /// Choose the URL to paint for an ``. Browsers do not use `src` alone: /// a wrapping ``'s ``s, `srcset`, and `sizes` select by /// type/media/viewport/density, and lazy-loaded images keep the real URL in @@ -2317,6 +2814,16 @@ fn is_svg(bytes: &[u8]) -> bool { /// Rasterize SVG bytes to a `width` x `height` pixmap, scaled to fit (matching /// how a replaced element like `` sizes its intrinsic content). fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option { + let fonts = svg_font_database(); + render_svg_with_font_database(bytes, width, height, &fonts) +} + +fn render_svg_with_font_database( + bytes: &[u8], + width: u32, + height: u32, + fonts: &std::sync::Arc, +) -> Option { if width == 0 || height == 0 { return None; } @@ -2332,6 +2839,8 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option { // override them, so provide the used viewport as actual root attributes. let viewport_svg = svg_with_root_viewport(bytes, width, height)?; opts.default_size = usvg::Size::from_wh(width as f32, height as f32)?; + opts.font_family = "Liberation Serif".to_string(); + opts.fontdb = std::sync::Arc::clone(fonts); let tree = usvg::Tree::from_data(&viewport_svg, &opts).ok()?; let size = tree.size(); if size.width() <= 0.0 || size.height() <= 0.0 { @@ -2343,6 +2852,60 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option { Some(svg_pixmap) } +/// A deterministic font database shared by every SVG raster in the process. +/// Constructing/scanning a database per icon is far too expensive for pages +/// with SVG-heavy navigation and would be prohibitive for future repeated +/// frame capture. The embedded faces are the same stable browser-generic +/// families used by the HTML text engine. +fn svg_font_database() -> std::sync::Arc { + static DATABASE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + std::sync::Arc::clone(DATABASE.get_or_init(|| { + let mut database = usvg::fontdb::Database::new(); + for bytes in [ + FONT_BYTES, + FONT_BOLD_BYTES, + FONT_OBLIQUE_BYTES, + FONT_BOLD_OBLIQUE_BYTES, + SERIF_FONT_BYTES, + MONO_FONT_BYTES, + ] { + database.load_font_data(bytes.to_vec()); + } + database.set_sans_serif_family("Liberation Sans"); + database.set_serif_family("Liberation Serif"); + database.set_monospace_family("Liberation Mono"); + std::sync::Arc::new(database) + })) +} + +fn svg_font_database_with_web_fonts( + web_fonts: &[crate::inline::WebFont], +) -> std::sync::Arc { + let base = svg_font_database(); + if web_fonts.is_empty() { + return base; + } + // `fontdb::Database::clone` shares each binary source; only the page's + // already-decoded user fonts allocate here, once per page rather than once + // per SVG. HTML shaping needs the same bytes, so retaining both databases + // is the cost of keeping the rasterizer and layout engine deterministic. + let mut database = (*base).clone(); + for font in web_fonts { + database.load_font_data(font.data.clone()); + } + std::sync::Arc::new(database) +} + +fn has_inline_svg_text(tree: &DomTree) -> bool { + tree.descendants(tree.document()).into_iter().any(|nid| { + tree.get_node(nid).is_some_and(|node| { + node.as_element() + .is_some_and(|name| matches!(name.local.as_ref(), "text" | "tspan" | "textPath")) + }) + }) +} + /// Return SVG XML whose root `width`/`height` are the resolved CSS viewport. /// /// This is deliberately a narrow XML start-tag rewrite rather than a DOM @@ -2454,9 +3017,24 @@ fn svg_root_attr_value_range(tag: &str, wanted: &str) -> Option<(usize, usize)> /// explicit closing tag. The root gains an `xmlns` declaration when it lacks /// one (common for inline svg, whose namespace is implied by the HTML parser /// but required for usvg to parse the string on its own). +#[cfg(test)] fn serialize_svg(tree: &DomTree, root: obscura_dom::tree::NodeId) -> String { let mut buf = String::new(); - serialize_svg_node(tree, root, true, &mut buf); + serialize_svg_node(tree, root, true, None, &mut buf); + buf +} + +/// Serialize an inline SVG while carrying the page's computed author styling +/// into the standalone document consumed by resvg. External page stylesheets +/// are otherwise outside that document, so class-driven SVG text and icons +/// silently fall back to presentation attributes or SVG defaults. +fn serialize_svg_styled( + tree: &DomTree, + root: obscura_dom::tree::NodeId, + styles: &std::collections::HashMap, +) -> String { + let mut buf = String::new(); + serialize_svg_node(tree, root, true, Some(styles), &mut buf); buf } @@ -2476,7 +3054,13 @@ fn inject_svg_current_color(markup: &mut String, color: [u8; 4]) { markup.insert_str(start + ">, + buf: &mut String, +) { let node = match tree.get_node(nid) { Some(n) => n, None => return, @@ -2490,7 +3074,7 @@ fn serialize_svg_node(tree: &DomTree, nid: obscura_dom::tree::NodeId, is_root: b // Document/comment/PI: no tag of its own, emit only element children. None => { for child in tree.children(nid) { - serialize_svg_node(tree, child, false, buf); + serialize_svg_node(tree, child, false, styles, buf); } return; } @@ -2499,6 +3083,7 @@ fn serialize_svg_node(tree: &DomTree, nid: obscura_dom::tree::NodeId, is_root: b buf.push('<'); buf.push_str(tag); let mut has_xmlns = false; + let mut source_style: Option = None; if let Some(attrs) = node.attrs() { for attr in attrs { // Emit the local name only, dropping any prefix (`xlink:href` -> @@ -2517,6 +3102,10 @@ fn serialize_svg_node(tree: &DomTree, nid: obscura_dom::tree::NodeId, is_root: b if aname == "xmlns" { has_xmlns = true; } + if styles.is_some() && aname == "style" { + source_style = Some(attr.value.to_string()); + continue; + } buf.push(' '); buf.push_str(aname); buf.push_str("=\""); @@ -2524,12 +3113,83 @@ fn serialize_svg_node(tree: &DomTree, nid: obscura_dom::tree::NodeId, is_root: b buf.push('"'); } } + if styles.is_some() { + let mut declarations = String::new(); + if let Some(source) = source_style.as_deref() { + declarations.push_str(source.trim()); + if !declarations.is_empty() && !declarations.ends_with(';') { + declarations.push(';'); + } + } + let mut append = |name: &str, value: &str| { + if value.trim().is_empty() { + return; + } + declarations.push_str(name); + declarations.push(':'); + declarations.push_str(value); + declarations.push_str("!important;"); + }; + if let Some(computed) = styles.and_then(|all| all.get(&nid)) { + if let Some(value) = computed.svg_fill.as_deref() { + append( + "fill", + if value.eq_ignore_ascii_case("currentcolor") { + "currentColor" + } else { + value + }, + ); + } + if let Some(value) = computed.svg_stroke.as_deref() { + append( + "stroke", + if value.eq_ignore_ascii_case("currentcolor") { + "currentColor" + } else { + value + }, + ); + } + if let Some(value) = computed.svg_stroke_width.as_deref() { + append("stroke-width", value); + } + if matches!(tag, "svg" | "text" | "textPath" | "textpath" | "tspan") { + if let Some(value) = computed.font_size { + append("font-size", &format!("{value}px")); + } + if let Some(value) = computed.font_weight.as_deref() { + append("font-weight", value); + } + if let Some(value) = computed.font_family.as_deref() { + append("font-family", value); + } + if computed.font_style_italic == Some(true) { + append("font-style", "italic"); + } + } + if let Some(color) = computed.color { + append( + "color", + &format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2]), + ); + } + if let Some(opacity) = computed.opacity { + append("opacity", &opacity.to_string()); + } + } + if !declarations.is_empty() { + buf.push_str(" style=\""); + svg_escape_attr(&declarations, buf); + buf.push('"'); + } + } if is_root && !has_xmlns { buf.push_str(" xmlns=\"http://www.w3.org/2000/svg\""); } buf.push('>'); for child in tree.children(nid) { - serialize_svg_node(tree, child, false, buf); + serialize_svg_node(tree, child, false, styles, buf); } buf.push_str(" +
    +
    +
    + x +
    + "#, + ); + let top = paint_dom_scrolled(&tree, (100.0, 80.0), None, (0.0, 0.0)) + .expect("top viewport"); + let scrolled = paint_dom_scrolled(&tree, (100.0, 80.0), None, (0.0, 80.0)) + .expect("scrolled viewport"); + + let top_content = top.pixel(50, 10).expect("top content"); + assert!( + top_content.red() > 240 && top_content.blue() < 15, + "top viewport should show first red block: {top_content:?}" + ); + let scrolled_content = scrolled.pixel(50, 10).expect("scrolled content"); + assert!( + scrolled_content.blue() > 240 && scrolled_content.red() < 15, + "scrolled viewport should show second blue block: {scrolled_content:?}" + ); + for (name, pixmap) in [("top", &top), ("scrolled", &scrolled)] { + let fixed = pixmap.pixel(5, 5).expect("fixed content"); + assert!( + fixed.green() > 240 && fixed.red() < 15 && fixed.blue() < 15, + "{name} viewport should keep fixed subtree at the viewport origin: {fixed:?}" + ); + } + } + + /// Portable pixel counterpart to the Chromium root-sticky geometry probe + /// in obscura-js. It checks that sticky backgrounds and descendants move + /// as one painted subtree, bottom sticking is visible, fixed remains + /// viewport-anchored, and the top sticky eventually exits at its + /// containing-block boundary. + #[test] + fn root_scroll_sticky_paints_subtrees_and_respects_bottom_boundary() { + let tree = parse_html( + r#" +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + "#, + ); + let viewport = (800.0, 513.0); + let top = paint_dom_scrolled(&tree, viewport, None, (0.0, 0.0)).unwrap(); + let stuck = paint_dom_scrolled(&tree, viewport, None, (0.0, 100.0)).unwrap(); + let bottom_normal = + paint_dom_scrolled(&tree, viewport, None, (0.0, 400.0)).unwrap(); + let boundary = + paint_dom_scrolled(&tree, viewport, None, (0.0, 9999.0)).unwrap(); + + let is_color = |pixel: tiny_skia::PremultipliedColorU8, rgb: [u8; 3]| { + (pixel.red() as i16 - rgb[0] as i16).abs() < 8 + && (pixel.green() as i16 - rgb[1] as i16).abs() < 8 + && (pixel.blue() as i16 - rgb[2] as i16).abs() < 8 + }; + assert!(is_color(top.pixel(100, 80).unwrap(), [255, 0, 0])); + assert!(is_color(top.pixel(100, 460).unwrap(), [255, 136, 0])); + assert!(is_color(stuck.pixel(100, 25).unwrap(), [0, 0, 255])); + assert!(is_color(stuck.pixel(100, 50).unwrap(), [255, 0, 0])); + assert!(is_color(stuck.pixel(100, 460).unwrap(), [255, 136, 0])); + assert!(is_color( + bottom_normal.pixel(100, 240).unwrap(), + [255, 136, 0] + )); + assert!(is_color(boundary.pixel(100, 20).unwrap(), [34, 0, 34])); + for pixmap in [&top, &stuck, &bottom_normal, &boundary] { + assert!(is_color(pixmap.pixel(620, 30).unwrap(), [0, 255, 0])); + } + } + #[test] fn object_fit_contain_and_cover_center_and_preserve_aspect() { // A 200x100 box (2:1) with a square 100x100 image, offset so centering @@ -3089,6 +3833,146 @@ mod tests { ); } + #[test] + fn paints_generated_style_images_and_sizes_content_url_as_replaced_content() { + let tree = parse_html( + r#" +
    + "#, + ); + + // The first cascade exposes the style image. Feeding its decoded + // dimensions back through the ordinary intrinsic map must give the + // source-less replaced element its 30x20 CSS box. + let mut intrinsic = std::collections::HashMap::new(); + let first = layout_dom_with_web_fonts(&tree, (120.0, 80.0), &intrinsic, &[]); + let host_id = tree + .query_selector("#host") + .expect("selector") + .expect("host"); + let host_style = &first.styles[&host_id]; + assert!( + host_style + .before_pseudo + .as_deref() + .and_then(|style| style.background_image.as_deref()) + .is_some(), + "the positioned pseudo must retain its parsed URL background" + ); + assert!( + host_style + .after_pseudo + .as_deref() + .and_then(|style| style.mask_image.as_deref()) + .is_some(), + "the positioned pseudo must retain its parsed URL mask" + ); + let host_rect = first.rects[&host_id]; + assert_eq!( + (host_rect.x, host_rect.y, host_rect.width, host_rect.height), + (0.0, 0.0, 100.0, 40.0) + ); + let mut cache = std::collections::HashMap::new(); + assert!(collect_content_image_intrinsics( + &tree, + &first.styles, + None, + &mut cache, + &mut intrinsic, + )); + let laid = layout_dom_with_web_fonts(&tree, (120.0, 80.0), &intrinsic, &[]); + let image_id = tree + .query_selector("#content-image") + .expect("selector") + .expect("content image"); + let image_rect = laid.rects[&image_id]; + assert_eq!( + ( + image_rect.x, + image_rect.y, + image_rect.width, + image_rect.height + ), + (0.0, 40.0, 30.0, 20.0), + "content:url must use ordinary replaced-element geometry" + ); + + let pixmap = paint_dom(&tree, (120.0, 80.0), None).expect("pixmap"); + let blue = pixmap.pixel(20, 20).expect("blue pseudo"); + assert!( + blue.blue() > 220 && blue.red() < 40 && blue.green() < 80, + "positioned pseudo background-image must paint: {blue:?}" + ); + let red = pixmap.pixel(70, 20).expect("masked pseudo center"); + assert!( + red.red() > 220 && red.green() < 40 && red.blue() < 40, + "positioned pseudo mask center must use the authored fill: {red:?}" + ); + let transparent_corner = pixmap.pixel(51, 1).expect("mask corner"); + assert_eq!( + ( + transparent_corner.red(), + transparent_corner.green(), + transparent_corner.blue(), + ), + (255, 255, 255), + "transparent mask corners must not paint the pseudo's solid box" + ); + let green = pixmap.pixel(15, 50).expect("content image"); + assert!( + green.green() > 220 && green.red() < 40 && green.blue() < 40, + "content:url image must paint through the replaced-image path: {green:?}" + ); + } + + #[test] + fn paints_empty_in_flow_generated_block_at_its_layout_rect() { + let tree = parse_html( + r#" +
    TEXT
    + "#, + ); + let pixmap = paint_dom(&tree, (240.0, 100.0), None).expect("pixmap"); + let generated = pixmap.pixel(40, 20).expect("generated block pixel"); + assert!( + generated.blue() > 180 && generated.green() > 70 && generated.red() < 30, + "the anonymous generated box must paint its own background: {generated:?}" + ); + let following = pixmap.pixel(10, 75).expect("following block pixel"); + assert!( + following.green() > 120 && following.red() < 30 && following.blue() < 30, + "the following block must paint below the generated geometry: {following:?}" + ); + } + #[test] fn paints_positioned_attr_content_over_the_host_background() { let tree = parse_html( @@ -3247,6 +4131,40 @@ mod tests { assert_eq!((origin.red(), origin.green(), origin.blue()), (255, 255, 255)); } + #[test] + fn transformed_image_is_clipped_inside_overflow_border() { + // CSS overflow clipping belongs to the owner's padding box. The + // translated images paint after the viewport's border in tree order, + // so a border-box clip would let them overwrite the border. + let tree = parse_html( + r#" +
    +
    + + +
    +
    + "#, + ); + let pixmap = paint_dom(&tree, (140.0, 90.0), None).expect("pixmap"); + + for &(x, y) in &[(0, 30), (3, 30), (104, 30), (107, 30), (50, 0), (50, 67)] { + let pixel = pixmap.pixel(x, y).expect("border pixel"); + assert!( + pixel.red() > 220 && pixel.green() < 40 && pixel.blue() < 40, + "translated image must not overwrite border pixel ({x},{y}): {pixel:?}" + ); + } + let content = pixmap.pixel(50, 30).expect("content pixel"); + assert!( + content.blue() > 220 && content.red() < 40, + "translated cover image must remain visible inside padding box: {content:?}" + ); + } + #[test] fn translate_offscreen_box_is_not_painted() { // translate(-10000px,0) shoves the box far off the left edge (the old @@ -3380,6 +4298,50 @@ mod tests { ); } + #[test] + fn inline_svg_keeps_author_css_and_embedded_text_fonts() { + // Inline SVG is parsed in the HTML document and styled by the page's + // author sheet, then serialized into a standalone document for resvg. + // The standalone boundary must not erase a CSS fill/font and inherit + // the root's `fill:none`, which made text-only illustrations blank. + let tree = parse_html( + r##" + + SVG text + + "##, + ); + let svg = tree.query_selector("svg").unwrap().unwrap(); + let layout = crate::dom::layout_dom(&tree, (160.0, 80.0)); + let markup = serialize_svg_styled(&tree, svg, &layout.styles); + assert!( + markup.contains("fill:#00cc55!important"), + "computed author fill must cross the standalone boundary: {markup}" + ); + assert!( + markup.contains("font-size:24px!important"), + "computed SVG font size must cross the standalone boundary: {markup}" + ); + + let pixmap = paint_dom(&tree, (160.0, 80.0), None).expect("pixmap"); + let painted_green = pixmap + .pixels() + .iter() + .any(|pixel| pixel.green() > 150 && pixel.red() < 80 && pixel.blue() < 120); + assert!( + painted_green, + "author-styled SVG text should rasterize with embedded fonts" + ); + } + #[test] fn injects_xmlns_only_when_absent() { let tree = parse_html( @@ -3586,6 +4548,48 @@ mod tests { ); } + #[test] + fn svg_light_dark_presentation_is_resolved_before_usvg() { + let tree = parse_html( + r#" +
    + + + +
    "#, + ); + let laid = crate::dom::layout_dom(&tree, (100.0, 100.0)); + let svg = tree.query_selector("#icon").unwrap().unwrap(); + let markup = serialize_svg_styled(&tree, svg, &laid.styles); + assert!( + !markup.to_ascii_lowercase().contains("light-dark("), + "unsupported CSS Color 5 syntax must not reach usvg: {markup}" + ); + assert!( + markup.contains("fill:#51565dff!important") + && markup.contains("stroke:#000000ff!important"), + "serialized presentation colors must use the dark subtree scheme: {markup}" + ); + let pixmap = + render_svg(markup.as_bytes(), 10, 10).expect("resolved SVG renders"); + let center = pixmap.pixel(5, 5).expect("center pixel"); + assert!( + center.red() > 60 + && center.red() < 110 + && center.green() > 60 + && center.green() < 120 + && center.blue() > 70 + && center.blue() < 130, + "resolved dark fill must survive usvg rasterization: {center:?}" + ); + } + #[test] fn font_face_parser_selects_ascii_subset_and_preserves_functional_src() { let css = r#" diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index b9b4886..688631a 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -237,13 +237,16 @@ pub fn ua_style(tag: &str) -> LayoutStyle { pub fn apply_inline(style: &mut LayoutStyle, css: &str) { let (normal, important) = partition_declarations(css); - apply_declarations(style, &normal); - apply_declarations(style, &important); + let inherited_scheme = style.color_scheme_dark; + apply_color_scheme_declarations_from(style, &normal, inherited_scheme); + apply_color_scheme_declarations_from(style, &important, inherited_scheme); + apply_declarations_with_locked_color_scheme(style, &normal); + apply_declarations_with_locked_color_scheme(style, &important); } /// Split a declaration block into normal and `!important` declarations while /// preserving source order inside each priority. The returned declarations -/// have the priority marker removed, ready for [`apply_declarations`]. +/// have the priority marker removed, ready for the computed-style pass. pub(crate) fn partition_declarations(css: &str) -> (String, String) { let mut normal = String::new(); let mut important = String::new(); @@ -270,16 +273,72 @@ pub(crate) fn partition_declarations(css: &str) -> (String, String) { (normal, important) } -/// Apply declarations in the order provided. Priority ordering must already -/// have been resolved by the caller. -pub(crate) fn apply_declarations(style: &mut LayoutStyle, css: &str) { +/// Apply only `color-scheme` from a declaration list. The stylesheet cascade +/// runs this over every matching block before resolving color-valued +/// declarations, because `light-dark()` selects from the element's final used +/// scheme regardless of whether `color-scheme` appears before or after the +/// color in source order. +pub(crate) fn apply_color_scheme_declarations_from( + style: &mut LayoutStyle, + css: &str, + inherited_scheme: bool, +) { + for raw in split_declarations(css) { + let Some((name, value)) = raw.trim().split_once(':') else { continue }; + if name.trim().eq_ignore_ascii_case("color-scheme") { + apply_color_scheme(style, value.trim(), inherited_scheme); + } + } +} + +pub(crate) fn apply_declarations_with_locked_color_scheme( + style: &mut LayoutStyle, + css: &str, +) { for raw in split_declarations(css) { let decl = raw.trim(); if decl.is_empty() { continue; } - let Some((name, value)) = decl.split_once(':') else { continue }; - apply_value(style, &name.trim().to_ascii_lowercase(), value.trim()); + let Some((name, value)) = decl.split_once(':') else { + continue; + }; + let name = name.trim().to_ascii_lowercase(); + if name != "color-scheme" { + apply_value(style, &name, value.trim()); + } + } +} + +fn apply_color_scheme( + style: &mut LayoutStyle, + value: &str, + inherited_scheme: bool, +) { + let tokens: Vec = value + .split_whitespace() + .map(|token| token.to_ascii_lowercase()) + .collect(); + if tokens.iter().any(|token| token == "inherit" || token == "unset") { + style.color_scheme_dark = inherited_scheme; + return; + } + if tokens.iter().any(|token| { + matches!(token.as_str(), "initial" | "revert" | "revert-layer") + }) { + style.color_scheme_dark = false; + return; + } + // The current browser/user preference is light. A scheme list that admits + // light therefore uses light; a dark-only list uses dark. `normal`, + // initial/revert, and malformed values retain the default/inherited light + // behavior used by this compact computed-style model. + if tokens.iter().any(|token| token == "light") + || tokens.iter().any(|token| token == "normal") + { + style.color_scheme_dark = false; + } else if tokens.iter().any(|token| token == "dark") { + style.color_scheme_dark = true; } } @@ -448,8 +507,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.border_color = None; return; } - for p in value.split_whitespace() { - if let Some(c) = parse_color(p) { + for p in split_ws_paren(value) { + if let Some(c) = + parse_color_for_scheme(p, style.color_scheme_dark) + { style.border_color = Some(c); } else if p.ends_with("px") || p.chars().all(|c| c.is_ascii_digit()) { if let Some(e) = edges(p) { style.border = e; } @@ -469,7 +530,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "border-left-width" | "border-left" => { set_edge(&mut style.border, Side::Left, border_side_width(value)) } - "background-color" => style.background_color = parse_color(value), + "background-color" => { + style.background_color = + parse_color_for_scheme(value, style.color_scheme_dark) + } "background" => { // A shorthand resets every omitted background longhand to its // initial value before applying the layers it does name. @@ -480,14 +544,18 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { // so leave the prior cascade winner untouched in that case. if !value.trim().is_empty() { style.background_color = None; - style.background_gradient = parse_linear_gradient(value); - style.background_radial_gradient = parse_radial_gradient(value); - style.background_conic_gradient = parse_conic_gradient(value); + style.background_gradient = + parse_linear_gradient(value, style.color_scheme_dark); + style.background_radial_gradient = + parse_radial_gradient(value, style.color_scheme_dark); + style.background_conic_gradient = + parse_conic_gradient(value, style.color_scheme_dark); if style.background_gradient.is_none() && style.background_radial_gradient.is_none() && style.background_conic_gradient.is_none() { - style.background_color = parse_color(value); + style.background_color = + parse_color_for_scheme(value, style.color_scheme_dark); } style.background_image = parse_url(value); style.background_size = None; @@ -498,9 +566,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "background-image" => { - style.background_gradient = parse_linear_gradient(value); - style.background_radial_gradient = parse_radial_gradient(value); - style.background_conic_gradient = parse_conic_gradient(value); + style.background_gradient = + parse_linear_gradient(value, style.color_scheme_dark); + style.background_radial_gradient = + parse_radial_gradient(value, style.color_scheme_dark); + style.background_conic_gradient = + parse_conic_gradient(value, style.color_scheme_dark); style.background_image = parse_url(value); } "background-size" => { @@ -510,6 +581,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.background_size_fit = parse_background_size_fit(value); } "background-position" => style.background_position = parse_background_position(value), + // On replaced elements, an image-valued `content` replaces the + // ordinary source and participates in intrinsic sizing. String-valued + // generated content is handled separately for pseudo-elements. + "content" => style.content_image = parse_url(value), "mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(value), "mask-size" | "-webkit-mask-size" => { style.mask_size = parse_background_size(value) @@ -533,8 +608,30 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { // transparent through this inherited property while clipping a // background gradient to the text. We model one effective text color, // so the vendor fill color is the paint-time color winner. - "color" | "-webkit-text-fill-color" => style.color = parse_color(value), - "border-color" => style.border_color = parse_color(value), + "color" | "-webkit-text-fill-color" => { + style.color = parse_color_for_scheme(value, style.color_scheme_dark) + } + // Inline SVG is rasterized as a standalone SVG document. Preserve + // author-CSS presentation values verbatim so paint can carry the + // computed cascade into that document (including currentColor, + // `none`, and paint-server URLs). + "fill" => { + style.svg_fill = Some(resolve_svg_presentation_color( + value, + style.color_scheme_dark, + )) + } + "stroke" => { + style.svg_stroke = Some(resolve_svg_presentation_color( + value, + style.color_scheme_dark, + )) + } + "stroke-width" => style.svg_stroke_width = Some(value.trim().to_string()), + "border-color" => { + style.border_color = + parse_color_for_scheme(value, style.color_scheme_dark) + } "font-size" => { // Absolute lengths resolve now; font/viewport-relative ones defer // to the inheritance pass (they need parent/root font-size). @@ -654,18 +751,27 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "absolute" => { style.position = Some(taffy::Position::Absolute); style.position_fixed = false; + style.position_sticky = false; } "fixed" => { style.position = Some(taffy::Position::Absolute); style.position_fixed = true; + style.position_sticky = false; } - "relative" | "sticky" => { + "relative" => { style.position = Some(taffy::Position::Relative); style.position_fixed = false; + style.position_sticky = false; + } + "sticky" => { + style.position = Some(taffy::Position::Relative); + style.position_fixed = false; + style.position_sticky = true; } "static" => { style.position = None; style.position_fixed = false; + style.position_sticky = false; } _ => {} } @@ -709,6 +815,26 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "overflow" | "overflow-x" | "overflow-y" => { style.overflow_hidden = value != "visible"; + style.overflow_scroll_container = + !matches!(value, "visible" | "clip"); + } + "scrollbar-gutter" => { + let lower = value.to_ascii_lowercase(); + style.scrollbar_gutters = if lower + .split_ascii_whitespace() + .any(|token| token == "stable") + { + if lower + .split_ascii_whitespace() + .any(|token| token == "both-edges") + { + 2 + } else { + 1 + } + } else { + 0 + }; } "visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")), "opacity" => style.opacity = value.trim().parse::().ok(), @@ -905,12 +1031,247 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { value.trim().eq_ignore_ascii_case("auto"), ), "box-shadow" | "-webkit-box-shadow" => { - style.box_shadow = parse_box_shadow(value, style.color); + style.box_shadow = parse_box_shadow( + value, + style.color, + style.color_scheme_dark, + ); } _ => {} } } +/// Whether a declaration is syntactically supported by the renderer's CSS +/// property/value implementation. `@supports` and the JavaScript-facing +/// capability model must not treat every non-empty declaration as valid: +/// modern framework sheets use negative feature probes to isolate legacy +/// browser fallbacks, and activating those fallbacks corrupts the modern +/// cascade. +pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { + let name = name.trim().to_ascii_lowercase(); + let value = value.trim(); + if value.is_empty() { + return false; + } + if name.starts_with("--") { + return name.len() > 2; + } + let css_wide = matches!( + value.to_ascii_lowercase().as_str(), + "initial" | "inherit" | "unset" | "revert" | "revert-layer" + ); + let known = matches!( + name.as_str(), + "display" + | "width" + | "height" + | "min-width" + | "min-height" + | "max-width" + | "max-height" + | "box-sizing" + | "aspect-ratio" + | "margin" + | "margin-top" + | "margin-right" + | "margin-bottom" + | "margin-left" + | "margin-inline" + | "margin-inline-start" + | "margin-inline-end" + | "margin-block" + | "margin-block-start" + | "margin-block-end" + | "padding" + | "padding-top" + | "padding-right" + | "padding-bottom" + | "padding-left" + | "padding-inline" + | "padding-inline-start" + | "padding-inline-end" + | "padding-block" + | "padding-block-start" + | "padding-block-end" + | "border-radius" + | "border" + | "border-width" + | "border-top-width" + | "border-right-width" + | "border-bottom-width" + | "border-left-width" + | "border-top" + | "border-right" + | "border-bottom" + | "border-left" + | "background" + | "background-color" + | "background-image" + | "background-size" + | "background-position" + | "background-clip" + | "-webkit-background-clip" + | "mask-image" + | "-webkit-mask-image" + | "mask-size" + | "-webkit-mask-size" + | "mask-repeat" + | "-webkit-mask-repeat" + | "color" + | "-webkit-text-fill-color" + | "fill" + | "stroke" + | "stroke-width" + | "border-color" + | "color-scheme" + | "font-size" + | "font" + | "font-weight" + | "font-family" + | "font-style" + | "text-align" + | "text-transform" + | "text-decoration" + | "text-decoration-line" + | "line-height" + | "align-items" + | "justify-items" + | "place-items" + | "align-self" + | "justify-self" + | "place-self" + | "align-content" + | "justify-content" + | "place-content" + | "flex-direction" + | "flex-wrap" + | "flex-grow" + | "flex-shrink" + | "flex-basis" + | "flex" + | "order" + | "position" + | "float" + | "object-fit" + | "top" + | "right" + | "bottom" + | "left" + | "inset" + | "overflow" + | "overflow-x" + | "overflow-y" + | "scrollbar-gutter" + | "visibility" + | "opacity" + | "animation" + | "animation-name" + | "animation-fill-mode" + | "animation-iteration-count" + | "z-index" + | "clear" + | "vertical-align" + | "list-style" + | "list-style-type" + | "gap" + | "grid-gap" + | "row-gap" + | "grid-row-gap" + | "column-gap" + | "grid-column-gap" + | "border-spacing" + | "border-collapse" + | "grid-template-columns" + | "grid-template-rows" + | "grid-template-areas" + | "grid-template" + | "grid" + | "grid-auto-flow" + | "grid-area" + | "grid-column" + | "grid-row" + | "grid-column-start" + | "grid-column-end" + | "grid-row-start" + | "grid-row-end" + | "transform" + | "filter" + | "backdrop-filter" + | "-webkit-backdrop-filter" + | "perspective" + | "contain" + | "will-change" + | "content-visibility" + | "box-shadow" + | "-webkit-box-shadow" + ); + if !known { + return false; + } + if css_wide { + return true; + } + match name.as_str() { + "display" => matches!( + value.to_ascii_lowercase().as_str(), + "none" + | "flex" + | "inline-flex" + | "inline" + | "inline-block" + | "grid" + | "inline-grid" + | "block" + | "flow-root" + | "contents" + ), + "position" => matches!( + value.to_ascii_lowercase().as_str(), + "static" | "relative" | "absolute" | "fixed" | "sticky" + ), + "box-sizing" => matches!( + value.to_ascii_lowercase().as_str(), + "content-box" | "border-box" + ), + "float" => matches!( + value.to_ascii_lowercase().as_str(), + "none" | "left" | "right" + ), + "object-fit" => matches!( + value.to_ascii_lowercase().as_str(), + "fill" | "contain" | "cover" | "none" | "scale-down" + ), + "visibility" => matches!( + value.to_ascii_lowercase().as_str(), + "visible" | "hidden" | "collapse" + ), + "scrollbar-gutter" => matches!( + value.to_ascii_lowercase().as_str(), + "auto" | "stable" | "stable both-edges" + ), + "color" | "-webkit-text-fill-color" | "background-color" | "border-color" => { + parse_color(value).is_some() + } + "color-scheme" => { + let tokens: Vec<&str> = value.split_whitespace().collect(); + !tokens.is_empty() + && tokens.iter().all(|token| { + matches!( + token.to_ascii_lowercase().as_str(), + "normal" | "light" | "dark" | "only" + ) + }) + && tokens.iter().any(|token| { + matches!( + token.to_ascii_lowercase().as_str(), + "normal" | "light" | "dark" + ) + }) + } + _ => true, + } +} + fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) { style.animation_name = None; style.animation_fill_forwards = false; @@ -1509,13 +1870,20 @@ fn set_grid_placement_side( } else { &mut style.grid_row_raw }; - let (mut start, mut end) = raw_slot - .as_deref() - .and_then(|raw| raw.split_once('/')) - .map(|(start, end)| { + let (mut start, mut end) = match raw_slot.as_deref() { + Some(raw) if raw.contains('/') => { + let (start, end) = raw.split_once('/').unwrap(); (start.trim().to_string(), end.trim().to_string()) - }) - .unwrap_or_else(|| ("auto".to_string(), "auto".to_string())); + } + // A single named grid-area shorthand contributes its `-start` + // and `-end` lines. Preserve both logical sides when a later + // longhand overrides only one of them (`grid-column:content` + // followed by `grid-column-end:extended-full-end`, as on MDN). + // Resolution can still fall back to an exact named line when no + // area-generated name exists. + Some(raw) => (raw.trim().to_string(), raw.trim().to_string()), + None => ("auto".to_string(), "auto".to_string()), + }; if is_start { start = value.trim().to_string(); } else { @@ -1591,19 +1959,51 @@ fn parse_grid_placement(value: &str) -> taffy::GridPlacement { /// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named /// colors. Returns None for anything else (transparent). pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> { + parse_color_for_scheme(value, false) +} + +fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { let raw = value.trim(); + // CSS Color 5 `light-dark(light, dark)` selects by the used color scheme. + // Obscura currently exposes the default/light scheme, so return the first + // branch. Both branches still have to be complete valid colors: accepting + // a valid light arm beside malformed dark syntax would keep a declaration + // that Chromium rejects at parse time. + let lower_full = raw.to_ascii_lowercase(); + if lower_full.starts_with("light-dark(") { + let inner_and_close = &raw["light-dark(".len()..]; + let close = find_matching_paren(inner_and_close)?; + if !inner_and_close[close + 1..].trim().is_empty() { + return None; + } + let arguments = split_top_level(&inner_and_close[..close], ','); + if arguments.len() != 2 + || arguments + .iter() + .any(|argument| argument.trim().is_empty() || !is_complete_color_token(argument)) + { + return None; + } + let light = + parse_color_for_scheme(arguments[0].trim(), dark_scheme)?; + let dark = + parse_color_for_scheme(arguments[1].trim(), dark_scheme)?; + return Some(if dark_scheme { dark } else { light }); + } // CSS custom property with a fallback: var(--name, ). We cannot // resolve the variable, but the fallback after the comma is a real color. if let Some(rest) = raw.strip_prefix("var(") { let inner = rest.strip_suffix(')').unwrap_or(rest); if let Some((_, fallback)) = inner.split_once(',') { - return parse_color(fallback.trim()); + return parse_color_for_scheme(fallback.trim(), dark_scheme); } return None; } // rgb()/rgba() functional notation. - let lower_full = raw.to_ascii_lowercase(); - if let Some(rest) = lower_full.strip_prefix("rgb(").or_else(|| lower_full.strip_prefix("rgba(")) { + if let Some(rest) = lower_full + .strip_prefix("rgb(") + .or_else(|| lower_full.strip_prefix("rgba(")) + { let inner = rest.strip_suffix(')').unwrap_or(rest); let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect(); if parts.len() >= 3 { @@ -1690,10 +2090,14 @@ pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> { if let Some(idx) = s.rfind(char::is_whitespace) { let tail = s[idx + 1..].trim(); if let Some(p) = tail.strip_suffix('%').and_then(|x| x.parse::().ok()) { - return parse_color(s[..idx].trim()).map(|c| (c, Some(p / 100.0))); + return parse_color_for_scheme( + s[..idx].trim(), + dark_scheme, + ) + .map(|c| (c, Some(p / 100.0))); } } - parse_color(s).map(|c| (c, None)) + parse_color_for_scheme(s, dark_scheme).map(|c| (c, None)) }; if let (Some((c1, p1)), Some((c2, p2))) = (parse_arg(args[1]), parse_arg(args[2])) { let (w1, w2) = match (p1, p2) { @@ -1777,6 +2181,18 @@ pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> { } } +fn resolve_svg_presentation_color(value: &str, dark_scheme: bool) -> String { + let raw = value.trim(); + if raw.to_ascii_lowercase().contains("light-dark(") { + if let Some([red, green, blue, alpha]) = + parse_color_for_scheme(raw, dark_scheme) + { + return format!("#{red:02x}{green:02x}{blue:02x}{alpha:02x}"); + } + } + raw.to_string() +} + /// The remaining common CSS named colors (the hot ones from real sites) beyond /// the handful spelled out above. fn named_color(v: &str) -> Option<[u8; 4]> { @@ -1890,6 +2306,52 @@ fn named_color(v: &str) -> Option<[u8; 4]> { Some([rgb[0], rgb[1], rgb[2], 255]) } +/// Reject trailing tokens and unbalanced functions before a `light-dark()` +/// branch reaches the intentionally permissive legacy color parser. Nested +/// color functions and their internal whitespace remain valid. +fn is_complete_color_token(value: &str) -> bool { + let value = value.trim(); + if value.is_empty() { + return false; + } + let mut depth = 0i32; + let mut first_open = None; + let mut outer_close = None; + for (index, character) in value.char_indices() { + match character { + '(' => { + if depth == 0 && first_open.is_none() { + first_open = Some(index); + } + depth += 1; + } + ')' => { + depth -= 1; + if depth < 0 { + return false; + } + if depth == 0 { + outer_close = Some(index); + } + } + _ => {} + } + } + if depth != 0 { + return false; + } + match first_open { + Some(open) => { + open > 0 + && value[..open] + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + && outer_close == Some(value.len() - 1) + } + None => !value.chars().any(char::is_whitespace), + } +} + /// Convert `hsl()`/`hsla()` to RGBA. `h` in degrees, `s`/`l` as 0-1. /// Convert an OKLab color (L in 0..1, a/b unbounded) to sRGB rgba bytes. /// (oklch is converted to oklab by the caller.) Standard Björn Ottosson matrix. @@ -2166,10 +2628,19 @@ fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option { current.push(character); } '+' | '-' if depth == 0 => { - if !current.trim().is_empty() { - terms.push((sign, std::mem::take(&mut current))); + let follows_product_operator = current + .chars() + .rev() + .find(|character| !character.is_whitespace()) + .is_some_and(|character| matches!(character, '*' | '/')); + if follows_product_operator { + current.push(character); + } else { + if !current.trim().is_empty() { + terms.push((sign, std::mem::take(&mut current))); + } + sign = if character == '-' { -1.0 } else { 1.0 }; } - sign = if character == '-' { -1.0 } else { 1.0 }; } _ => current.push(character), } @@ -2352,10 +2823,19 @@ fn eval_calc(expr: &str) -> Option { '(' => { depth += 1; cur.push(c); } ')' => { depth -= 1; cur.push(c); } '+' | '-' if depth == 0 => { - if !cur.trim().is_empty() { - terms.push((sign, std::mem::take(&mut cur))); + let follows_product_operator = cur + .chars() + .rev() + .find(|character| !character.is_whitespace()) + .is_some_and(|character| matches!(character, '*' | '/')); + if follows_product_operator { + cur.push(c); + } else { + if !cur.trim().is_empty() { + terms.push((sign, std::mem::take(&mut cur))); + } + sign = if c == '-' { -1.0 } else { 1.0 }; } - sign = if c == '-' { -1.0 } else { 1.0 }; } _ => cur.push(c), } @@ -2912,7 +3392,10 @@ fn token(value: &str) -> Option<&str> { /// clockwise); `to ` keywords map to their angle. Color stops keep their /// optional 0..1 position. Returns None if it is not a linear-gradient or has /// no parseable colors. Radial/conic gradients are not handled (None). -fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option)>)> { +fn parse_linear_gradient( + value: &str, + dark_scheme: bool, +) -> Option<(f32, Vec<([u8; 4], Option)>)> { let v = value.trim(); let lower = v.to_ascii_lowercase(); let start = lower.find("linear-gradient(")?; @@ -2997,7 +3480,7 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option) } else { (t, None) }; - if let Some(c) = parse_color(color_str) { + if let Some(c) = parse_color_for_scheme(color_str, dark_scheme) { stops.push((c, pos)); } } @@ -3011,6 +3494,7 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option) fn parse_radial_gradient( value: &str, + dark_scheme: bool, ) -> Option<((f32, f32), Vec<([u8; 4], Option)>)> { let lower = value.to_ascii_lowercase(); let start = lower.find("radial-gradient(")?; @@ -3037,13 +3521,13 @@ fn parse_radial_gradient( center.1 = y; } stop_start = 1; - } else if parse_color(parts[0].trim()).is_none() { + } else if parse_color_for_scheme(parts[0].trim(), dark_scheme).is_none() { stop_start = 1; } let mut stops = Vec::new(); for part in &parts[stop_start..] { let (color, position) = split_color_stop(part.trim()); - if let Some(color) = parse_color(color) { + if let Some(color) = parse_color_for_scheme(color, dark_scheme) { stops.push((color, position)); } } @@ -3055,6 +3539,7 @@ fn parse_radial_gradient( /// is retained as box-relative fractions for paint-time resolution. fn parse_conic_gradient( value: &str, + dark_scheme: bool, ) -> Option<(f32, (f32, f32), Vec<([u8; 4], Option)>)> { let v = value.trim(); let lower = v.to_ascii_lowercase(); @@ -3106,7 +3591,7 @@ fn parse_conic_gradient( continue; } let (color, position) = split_color_stop(part); - if let Some(color) = parse_color(color) { + if let Some(color) = parse_color_for_scheme(color, dark_scheme) { stops.push((color, position)); } } @@ -3382,7 +3867,11 @@ fn parse_background_position(value: &str) -> (f32, f32) { /// keyword and the color may each lead or trail the lengths; comma-separated /// multiples are accepted but only the first layer is stored. `current_color` /// supplies the default when the color is omitted (CSS `currentColor`). -fn parse_box_shadow(value: &str, current_color: Option<[u8; 4]>) -> Option { +fn parse_box_shadow( + value: &str, + current_color: Option<[u8; 4]>, + dark_scheme: bool, +) -> Option { let v = value.trim(); if v.is_empty() || v.eq_ignore_ascii_case("none") { return None; @@ -3411,7 +3900,7 @@ fn parse_box_shadow(value: &str, current_color: Option<[u8; 4]>) -> Option + body { margin: 0 } + #grid { display: grid; width: 1200px } + #item { max-width: 100% } + #gallery { display: flex; gap: 8px } + .column { flex: none; height: 20px } + +
    +
    + +
    +
    + "#, + ); + let layout = layout_dom(&tree, (1280.0, 200.0)); + let grid = layout.rects[&tree.get_element_by_id("grid").unwrap()]; + let item = layout.rects[&tree.get_element_by_id("item").unwrap()]; + let gallery = layout.rects[&tree.get_element_by_id("gallery").unwrap()]; + assert!( + (grid.width - 1200.0).abs() < 0.01 + && (item.width - 1200.0).abs() < 0.01 + && (gallery.width - 1200.0).abs() < 0.01, + "max-width:100% must clamp the grid item while its flex contents overflow: \ + grid={grid:?} item={item:?} gallery={gallery:?}" + ); +} + +#[test] +fn named_area_shorthand_survives_end_longhand_override() { + let tree = parse_html( + r#" + +
    + "#, + ); + let layout = layout_dom(&tree, (1280.0, 200.0)); + let hero = layout.rects[&tree.get_element_by_id("hero").unwrap()]; + assert!( + (hero.x - 216.0).abs() < 0.01 && (hero.width - 984.0).abs() < 0.01, + "the end longhand must retain the shorthand's named content start: {hero:?}" + ); +} + +#[test] +fn auto_width_flex_button_keeps_native_intrinsic_sizing() { + let tree = parse_html( + r#" + +
    + "#, + ); + let layout = layout_dom(&tree, (800.0, 200.0)); + let button = layout.rects[&tree.get_element_by_id("search").unwrap()]; + assert!( + button.width > 120.0 + && button.width < 220.0 + && (button.x - (600.0 - button.width) / 2.0).abs() < 0.01, + "the auto-width flex button should shrink-wrap and center: {button:?}" + ); +} + +#[test] +fn flex_auto_margin_absorbs_space_before_justify_content() { + let tree = parse_html( + r#" + +
    +
    +
    +
    +
    + "#, + ); + let layout = layout_dom(&tree, (1280.0, 200.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let crumb = rect("crumb"); + let theme = rect("theme"); + let language = rect("language"); + assert!( + (crumb.x - 0.0).abs() < 0.01 + && (theme.x - 1042.0).abs() < 0.01 + && (language.x - 1149.0).abs() < 0.01, + "main-axis auto margin must consume free space before justify-content: \ + crumb={crumb:?} theme={theme:?} language={language:?}" + ); +} + +#[test] +fn nested_is_descendant_utility_blockifies_matching_lines() { + let tree = parse_html( + r#" + +
    +
    onetwo
    +
    + "#, + ); + let layout = layout_dom(&tree, (500.0, 200.0)); + let one = tree.get_element_by_id("one").unwrap(); + let two = tree.get_element_by_id("two").unwrap(); + assert_eq!(layout.styles[&one].display, obscura_render::Display::Block); + assert_eq!(layout.styles[&two].display, obscura_render::Display::Block); + assert!(layout.rects[&two].y > layout.rects[&one].y); + assert_eq!(layout.rects[&one].width, layout.rects[&two].width); +} + #[test] fn opposing_floats_share_header_band_through_inline_wrapper() { let tree = parse_html(include_str!("../../../render-repros/opposing-header-floats.html")); @@ -1168,3 +1326,304 @@ fn contextual_right_inset_uses_root_font_tokens() { "right inset should place 100px box at 900 - 56 - 100: {rect:?}" ); } + +#[test] +fn false_legacy_supports_probe_does_not_override_modern_line_height() { + // Tailwind v4 uses this old-browser probe around a leading-variable reset. + // Chromium rejects the condition. Flattening every @supports body made + // the reset win, invalidated the var(), and inflated three 96px lines from + // 288px to an inherited 432px. + let tree = parse_html( + r#" + +

    one
    two
    three

    + "#, + ); + let layout = layout_dom(&tree, (1280.0, 720.0)); + let hero = layout.rects[&tree.get_element_by_id("hero").unwrap()]; + assert!( + (hero.height - 288.0).abs() < 0.01, + "three 96px modern lines should be 288px tall: {hero:?}" + ); +} + +#[test] +fn custom_property_css_wide_keywords_preserve_var_fallback_semantics() { + let tree = parse_html( + r#" + +
    fallback
    +
    inherit
    +
    nested fallback
    + "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let invalid = tree.get_element_by_id("invalid").unwrap(); + let inherited = tree.get_element_by_id("inherited").unwrap(); + let nested = tree.get_element_by_id("nested").unwrap(); + assert_eq!( + layout.styles[&invalid].color, + Some([0x44, 0x55, 0x66, 0xff]), + "`initial` is guaranteed-invalid and var() must use its fallback" + ); + assert_eq!( + layout.styles[&inherited].color, + Some([0x11, 0x22, 0x33, 0xff]), + "`unset` inherits a custom property's parent value" + ); + assert_eq!( + layout.styles[&nested].background_color, + Some([0xff, 0xff, 0xff, 0xff]), + "an invalid nested custom property must activate the outer fallback" + ); +} + +#[test] +fn stable_root_scrollbar_gutter_reduces_the_initial_containing_block() { + let tree = parse_html( + r#" + +
    +
    + "#, + ); + let layout = layout_dom(&tree, (900.0, 600.0)); + let fill = layout.rects[&tree.get_element_by_id("fill").unwrap()]; + let center = layout.rects[&tree.get_element_by_id("center").unwrap()]; + assert!( + (fill.width - 885.0).abs() < 0.01, + "stable classic gutter should reserve 15px: {fill:?}" + ); + assert!( + (center.x - 242.0).abs() < 0.01, + "401px box should center in the 885px ICB: {center:?}" + ); +} + +#[test] +fn auto_grid_track_freezes_at_growth_limit_during_distribution() { + // The first auto track reaches its max-content growth limit before the + // second. The remaining free space belongs only to the second track. + // Giving the frozen track that remainder again makes the grid overflow + // even though the two automatic minimums fit the definite container. + let tree = parse_html( + r#" + +
    +

    Resources for Developers,
    by Developers

    +
    +
    + "#, + ); + let layout = layout_dom(&tree, (1280.0, 720.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let grid = rect("grid"); + let copy = rect("copy"); + let art = rect("art"); + assert!( + ((copy.x + copy.width + 16.0) - art.x).abs() < 0.01, + "the authored column gap must separate the final tracks: {copy:?} {art:?}" + ); + assert!( + ((art.x + art.width) - (grid.x + grid.width)).abs() < 0.01, + "free-space redistribution must not grow a track after it freezes: {grid:?} {copy:?} {art:?}" + ); +} + +#[test] +fn empty_block_before_participates_in_normal_flow_geometry() { + let tree = parse_html( + r#" + +
    TEXT
    + "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let host = layout.rects[&tree.get_element_by_id("host").unwrap()]; + let next = layout.rects[&tree.get_element_by_id("next").unwrap()]; + assert!( + (host.height - 70.0).abs() < 0.01, + "40px generated block + 10px margin + 20px line must size host: {host:?}" + ); + assert!( + (next.y - 70.0).abs() < 0.01, + "following flow content must start after generated geometry: {next:?}" + ); +} + +#[test] +fn empty_inline_block_pseudo_is_an_atomic_flex_item() { + let tree = parse_html( + r#" + +
    + "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let label = layout.rects[&tree.get_element_by_id("label").unwrap()]; + assert!( + (label.x - 17.0).abs() < 0.01, + "8px pseudo content width + 2px borders + 5px margin must precede label: {label:?}" + ); +} + +#[test] +fn light_dark_uses_the_final_inherited_color_scheme_across_the_cascade() { + let tree = parse_html( + r#" + +
    +
    + +
    +
    + "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let dark = tree.get_element_by_id("dark").unwrap(); + let child = tree.get_element_by_id("child").unwrap(); + let mixed = tree.get_element_by_id("mixed").unwrap(); + let rect = tree.query_selector("#icon rect").unwrap().unwrap(); + + let dark_style = &layout.styles[&dark]; + assert!(dark_style.color_scheme_dark); + assert_eq!(dark_style.background_color, Some([0x18, 0x19, 0x1b, 0xff])); + assert_eq!(dark_style.color, Some([0xee, 0xee, 0xee, 0xff])); + assert_eq!(dark_style.border_color, Some([0x33, 0x33, 0x33, 0xff])); + assert_eq!( + dark_style + .before_pseudo + .as_deref() + .and_then(|pseudo| pseudo.background_color), + Some([0x00, 0x00, 0xff, 0xff]), + "pseudo must use its host's inherited dark scheme" + ); + + let child_style = &layout.styles[&child]; + assert!(child_style.color_scheme_dark); + let (_, stops) = child_style + .background_gradient + .as_ref() + .expect("dark gradient"); + assert_eq!(stops[0].0, [0x10, 0x10, 0x10, 0xff]); + assert_eq!(stops[1].0, [0x20, 0x20, 0x20, 0xff]); + + let rect_style = &layout.styles[&rect]; + assert!(rect_style.color_scheme_dark); + assert_eq!(rect_style.svg_fill.as_deref(), Some("#51565dff")); + assert_eq!(rect_style.svg_stroke.as_deref(), Some("#000000ff")); + + let mixed_style = &layout.styles[&mixed]; + assert!( + !mixed_style.color_scheme_dark, + "a scheme list admitting light uses the current light preference" + ); + assert_eq!(mixed_style.background_color, Some([0xab, 0xcd, 0xef, 0xff])); +} diff --git a/vendor/taffy/src/compute/grid/track_sizing.rs b/vendor/taffy/src/compute/grid/track_sizing.rs index 10e3ea2..a2b8c18 100644 --- a/vendor/taffy/src/compute/grid/track_sizing.rs +++ b/vendor/taffy/src/compute/grid/track_sizing.rs @@ -1399,15 +1399,25 @@ fn distribute_space_up_to_limits( .iter() .filter(|track| track_affected_property(track) + track.item_incurred_increase < track_limit(track)) .filter(|track| track_is_affected(track)) - .map(|track| (track_limit(track) - track_affected_property(track)) / track_distribution_proportion(track)) + .map(|track| { + (track_limit(track) + - track_affected_property(track) + - track.item_incurred_increase) + / track_distribution_proportion(track) + }) .min_by(|a, b| a.total_cmp(b)) .unwrap(); // We will never pass an empty track list to this function let iteration_item_incurred_increase = f32_min(min_increase_limit, space_to_distribute / track_distribution_proportion_sum); for track in tracks.iter_mut().filter(|track| track_is_affected(track)) { + let current_size = track_affected_property(track) + track.item_incurred_increase; + let limit = track_limit(track); + if current_size >= limit { + continue; + } let increase = iteration_item_incurred_increase * track_distribution_proportion(track); - if increase > 0.0 && track_affected_property(track) + increase <= track_limit(track) + THRESHOLD { + if increase > 0.0 && current_size + increase <= limit + THRESHOLD { track.item_incurred_increase += increase; space_to_distribute -= increase; }