Improve modern layout and viewport rendering

This commit is contained in:
SGavrl committed 2026-07-30 20:24:57 +00:00
1 parent aff0172987
commit 10b8492116
22 files changed
+5946 -418

No files matched your search

Generated
+1
View File
@@ -2070,6 +2070,7 @@ dependencies = [
"image",
"obscura-dom",
"resvg",
"swash",
"taffy",
"tiny-skia",
"ureq",
+48 -1
View File
@@ -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#"<html style="margin:0"><body style="margin:0">
<div style="height:80px;background:#ff0000"></div>
<div id="second" style="height:80px;background:#0000ff"></div>
<div style="position:fixed;left:0;top:0;width:20px;height:20px;background:#00ff00"></div>
</body></html>"#,
);
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
+82 -7
View File
@@ -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)
.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,<html style='margin:0'><body style='margin:0'><div style='height:80px;background:red'></div><div style='height:80px;background:blue'></div><div style='position:fixed;left:0;top:0;width:20px;height:20px;background:green'></div></body></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();
+80 -39
View File
@@ -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),
@@ -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,<html style=\"margin:0\"><body style=\"margin:0\">",
"<div style=\"height:80px;background:red\"></div>",
"<div style=\"height:80px;background:blue\"></div>",
"</body></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");
}
+16
View File
@@ -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#"<div class="**:[.line]:block"><code><span id="target" class="line">x</span></code></div>"#,
);
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(
+657 -120
View File
@@ -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;
if (changed && !this._scrollSuppress) this._fireScroll();
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 wireUp = () => {
if (!globalThis.document?.body) return;
const mo = new MutationObserver(() => {
const recompute = () => {
if (pending) return;
pending = true;
Promise.resolve().then(() => { pending = false; reFire(); });
Promise.resolve().then(() => {
pending = false;
for (const observer of globalThis.__intersectionObservers) {
if (observer._connected && observer._targets.size) {
observer._check([...observer._targets], false);
}
}
});
try { mo.observe(globalThis.document.body, {childList: true, subtree: true}); } catch {}
};
globalThis.__obscura_recompute_intersections = recompute;
globalThis.addEventListener("resize", recompute);
const wireUp = () => {
if (!globalThis.document?.body) return;
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
+1 -1
View File
@@ -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};
+154 -26
View File
@@ -64,6 +64,14 @@ pub struct JsNetworkEvent {
pub timestamp: f64,
}
#[cfg(feature = "render")]
pub struct LayoutCache {
rects: std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>,
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
sticky: obscura_render::StickyLayout,
content_size: (f32, f32),
}
pub struct ObscuraState {
pub dom: Option<DomTree>,
pub url: String,
@@ -104,14 +112,17 @@ pub struct ObscuraState {
// Network.requestWillBeSent / responseReceived for them (issue #406).
pub js_network_events: Vec<JsNetworkEvent>,
/// 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<std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>>,
pub layout_cache: Option<LayoutCache>,
#[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::<SharedState>().clone();
let gs = gs.borrow();
let shared = state.borrow::<SharedState>().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_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::<SharedState>().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::<SharedState>().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::<SharedState>().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::<SharedState>().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)
}
+776
View File
@@ -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(
"<set-viewport>",
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("<html><body></body></html>");
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("<html><body></body></html>");
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("<html><body></body></html>");
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("<html><body></body></html>");
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("<html><body></body></html>");
@@ -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("<html><body></body></html>");
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#"<div id="test">Content</div>"#);
@@ -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#"<html><body><div id="d"></div></body></html>"#);
@@ -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#"<html><body><div id="d"></div></body></html>"#);
@@ -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#"<html><body><div id="d"></div></body></html>"#);
@@ -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#"<html style="margin:0"><body style="margin:0">
<div id="wide" style="width:600px;height:700px"></div>
<div id="target" style="width:20px;height:300px"></div>
<div id="fixed" style="position:fixed;left:12px;top:14px;width:30px;height:25px">
<span id="fixed-child">fixed</span>
</div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div style="height:40px"></div>
<div id="cb" style="box-sizing:border-box;height:900px;padding:10px 12px;border:4px solid #333">
<div id="top" style="box-sizing:border-box;position:sticky;top:20px;height:60px;margin:6px">
<div id="top-child" style="height:12px"></div>
</div>
<div style="height:500px"></div>
<div id="bottom" style="box-sizing:border-box;position:sticky;bottom:15px;height:50px;margin:5px"></div>
</div>
<div style="height:700px"></div>
<div id="fixed" style="position:fixed;left:600px;top:20px;width:60px;height:60px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div style="box-sizing:border-box;margin-left:40px;width:500px;height:100px;padding:10px;border:4px solid">
<div id="sticky" style="box-sizing:border-box;position:sticky;left:20px;width:60px;height:30px;margin:6px">
<div id="child" style="width:10px;height:10px"></div>
</div>
</div>
<div style="width:1600px;height:600px"></div>
<div id="fixed" style="position:fixed;left:20px;top:100px;width:60px;height:30px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div id="box" style="height:300px;width:40px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div style="height:1000px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div style="height:150px"></div>
<div id="target" style="height:100px"></div>
<div style="height:300px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0;position:relative">
<div style="height:110px"></div>
<div id="margin-target" style="height:10px"></div>
<div id="zero" style="position:absolute;left:20px;top:50px;width:0;height:0"></div>
<div id="root" style="height:20px;overflow:auto"></div>
<div style="height:300px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div id="spacer" style="height:150px"></div>
<div id="target" style="height:20px"></div>
<div style="height:300px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div style="height:300px"></div>
<div id="target" style="height:40px"></div>
<div style="height:340px"></div>
<div id="bottom" style="height:20px"></div>
<div id="fixed" style="position:fixed;top:10px;height:20px"></div>
</body></html>"#,
);
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#"<html style="margin:0"><body style="margin:0">
<div style="height:300px"></div>
<div id="target" style="height:40px"></div>
<div style="height:300px"></div>
</body></html>"#,
);
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]
+5 -1
View File
@@ -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 }
+400 -39
View File
@@ -129,6 +129,7 @@ impl Stylesheet {
matcher: &mut Matcher,
nid: NodeId,
props: &HashMap<String, String>,
host_style: &LayoutStyle,
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
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 {
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<String, String>, 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, String>) -> 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<String, String>, depth: u8) -> Option<String> {
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<String, String>, 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<Option<St
};
if let Some(parsed) = parsed {
result = Some(Some(parsed));
} else if value
.trim_start()
.get(..4)
.map_or(false, |prefix| prefix.eq_ignore_ascii_case("url("))
{
// An image-valued content declaration supersedes any earlier
// string declaration. The image itself is retained on
// LayoutStyle::content_image by apply_declarations; clearing the
// text here keeps the two views of the winning declaration in
// sync and, importantly, keeps the pseudo alive.
result = Some(None);
}
}
result
@@ -615,7 +751,9 @@ fn flush_at_rule(
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
}
} else if at.starts_with("supports") {
if supports_condition_applies(sel) {
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
}
} else if at.starts_with("property") {
// `@property --x { syntax:..; initial-value: V }` declares a custom
// property and its default. Register the initial-value as a `:root`
@@ -648,6 +786,152 @@ fn flush_at_rule(
// layout-relevant rules for us, so drop them.
}
/// Evaluate the boolean subset of CSS Conditional Rules used by modern
/// framework stylesheets. This follows the same shape as Gecko's
/// SupportsCondition tree: declaration/selector leaves, arbitrary grouping,
/// unary `not`, and top-level `and`/`or` chains. Unknown or malformed future
/// syntax is false instead of optimistically enabling a fallback branch.
pub(crate) fn supports_condition_applies(condition: &str) -> 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<Vec<&'a str>> {
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(
File diff suppressed because it is too large. Load diff
+417 -84
View File
@@ -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<str>,
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<u16>,
}
#[derive(Clone)]
@@ -106,7 +119,7 @@ fn resolve_loaded_font(
requested_weight: u16,
requested_italic: bool,
loaded: &HashMap<String, LoadedFamily>,
) -> (Arc<str>, 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<usize> {
((metadata & META_FILL_MASK) >> META_FILL_SHIFT).checked_sub(1)
}
fn metadata_variation_weight(metadata: usize) -> Option<u16> {
let weight = ((metadata & META_VARIATION_MASK) >> META_VARIATION_SHIFT) as u16;
(weight != 0).then_some(weight)
}
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
@@ -286,10 +309,106 @@ pub struct TextEngine {
font_system: FontSystem,
loaded_families: HashMap<String, LoadedFamily>,
swash: SwashCache,
variable_swash: VariableSwashCache,
items: Vec<InlineItem>,
replaced: Vec<ReplacedItem>,
}
#[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<VariableCacheKey, Option<SwashImage>>,
}
impl VariableSwashCache {
fn new() -> Self {
Self {
context: ScaleContext::new(),
images: HashMap::new(),
}
}
fn with_pixels<F: FnMut(i32, i32, Color)>(
&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<String> = 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<u16>,
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<u16>,
italic: bool,
underline: bool,
transform: TextTransform,
@@ -800,8 +914,7 @@ struct Collector {
/// transparent (and invisible), unchanged.
fn base_span_ctx(
base: &LayoutStyle,
family: Arc<str>,
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<NodeId, LayoutStyle>) -> bool {
let Some(style) = styles.get(&id) else { return false };
fn is_pure_text_ifc(
tree: &DomTree,
id: NodeId,
styles: &std::collections::HashMap<NodeId, LayoutStyle>,
) -> 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("<p id='copy'>Variable family</p>");
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<u8> {
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<u16>) {
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("<p id='copy'>MMMMMMMM</p>");
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(
"<p id='copy'><span id='regular'>M</span><span id='black'>M</span></p>",
);
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]
+36 -2
View File
@@ -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<String>,
pub svg_stroke: Option<String>,
pub svg_stroke_width: Option<String>,
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<f32>,
/// `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<Dimension>; 4], // top, right, bottom, left
/// Deferred functional inset expressions in top/right/bottom/left order.
pub inset_expressions: [Option<String>; 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 `<img>` it becomes
/// the element's image source and contributes intrinsic dimensions just
/// like an HTML `src`.
pub content_image: Option<String>,
/// 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,6 +895,7 @@ 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;
if !style.position_sticky {
s.inset = taffy::Rect {
top: inset_lpa(style.inset[0]),
right: inset_lpa(style.inset[1]),
@@ -870,6 +903,7 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
left: inset_lpa(style.inset[3]),
};
}
}
s.margin = rect_auto(style.margin, style.margin_auto);
s.padding = rect_lp(style.padding);
File diff suppressed because it is too large. Load diff
+595 -40
View File
@@ -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<String> = 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::<f32>().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, <fallback>). 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::<f32>().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,11 +2628,20 @@ fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option<f32> {
current.push(character);
}
'+' | '-' if depth == 0 => {
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 };
}
}
_ => current.push(character),
}
}
@@ -2352,11 +2823,20 @@ fn eval_calc(expr: &str) -> Option<f32> {
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
'+' | '-' if depth == 0 => {
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 };
}
}
_ => cur.push(c),
}
}
@@ -2912,7 +3392,10 @@ fn token(value: &str) -> Option<&str> {
/// clockwise); `to <side>` 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<f32>)>)> {
fn parse_linear_gradient(
value: &str,
dark_scheme: bool,
) -> Option<(f32, Vec<([u8; 4], Option<f32>)>)> {
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<f32>)
} 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<f32>)
fn parse_radial_gradient(
value: &str,
dark_scheme: bool,
) -> Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)> {
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<f32>)>)> {
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<crate::BoxShadow> {
fn parse_box_shadow(
value: &str,
current_color: Option<[u8; 4]>,
dark_scheme: bool,
) -> Option<crate::BoxShadow> {
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<crate
continue;
}
}
if let Some(c) = parse_color(t) {
if let Some(c) = parse_color_for_scheme(t, dark_scheme) {
color = Some(c);
}
}
@@ -3835,6 +4324,17 @@ mod tests {
),
Some(122.0)
);
assert_eq!(
resolve_contextual_length(
"calc(35rem*-1/4)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(-140.0)
);
assert_eq!(
resolve_contextual_length(
"calc(round(247px * 1, 10px))",
@@ -3896,6 +4396,58 @@ mod tests {
assert!(l.background_clip_text);
}
#[test]
fn light_dark_uses_light_scheme_and_validates_both_nested_branches() {
assert_eq!(
parse_color("light-dark(rgb(16, 32, 48), hsl(0 100% 50%))"),
Some([16, 32, 48, 255])
);
assert_eq!(
parse_color(
"LIGHT-DARK(color-mix(in srgb, #204060 75%, transparent), \
light-dark(white, black))"
),
Some([32, 64, 96, 191]),
"nested commas must stay inside their color function"
);
let style = compute_style(
"div",
Some(
"color:light-dark(#123456,#ffffff);\
background-color:light-dark(rgb(1, 2, 3),rgb(4, 5, 6));\
border-color:light-dark(red,blue)",
),
);
assert_eq!(style.color, Some([0x12, 0x34, 0x56, 255]));
assert_eq!(style.background_color, Some([1, 2, 3, 255]));
assert_eq!(style.border_color, Some([255, 0, 0, 255]));
assert!(supports_declaration(
"color",
"light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"
));
for malformed in [
"light-dark(red)",
"light-dark(red, blue, green)",
"light-dark(red,)",
"light-dark(,blue)",
"light-dark(red, rgb(1, 2, 3)",
"light-dark(red garbage, blue)",
"light-dark(red, definitely-not-a-color)",
"light-dark(red, blue) trailing",
] {
assert_eq!(
parse_color(malformed),
None,
"malformed light-dark() must invalidate the declaration: {malformed}"
);
assert!(
!supports_declaration("color", malformed),
"@supports must reject malformed light-dark(): {malformed}"
);
}
}
#[test]
fn background_shorthand_resets_omitted_layers() {
let s = compute_style(
@@ -3998,6 +4550,9 @@ mod tests {
// The exact shape MediaWiki uses to offset a TOC toggle button into
// the left margin: a negative product divided by a constant.
assert_eq!(resolve_length("calc(-1 * 22px / 2)"), Some(-11.0));
// Minifiers commonly remove every optional space. A sign immediately
// following `*` or `/` is unary and belongs to that factor.
assert_eq!(resolve_length("calc(35px*-1/4)"), Some(-8.75));
}
#[test]
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,14 @@
obscura-vf-test.woff2.b64
Source: Inter variable Roman Latin webfont served by https://vuejs.org/
Upstream: https://github.com/rsms/inter
Upstream version recorded in the font: 4.001;git-9221beed3
License: SIL Open Font License 1.1 (see LICENSE-OFL-1.1.txt)
The test fixture was subset with fontTools 4.29.1 to U+0020, U+0041,
U+004D, U+0061, and U+006D while retaining the opsz and wght variation axes.
All family, full, PostScript, unique, and named-instance records were renamed
from the OFL reserved name to "Obscura VF Test" / "ObscuraVFTest" variants.
The original copyright attribution remains in the font's copyright record.
The WOFF2 is stored as base64 text so repository edits remain reviewable.
@@ -0,0 +1,65 @@
d09GMgABAAAAAA5sABMAAAAAGLwAAA38AAQAQgAAAAAAAAAAAAAAAAAAAAAAAAAAGnobgWgcbj9I
VkFSgU4/TVZBUoEoBmA/U1RBVIFcJzQAVC+BNgqEXIRCMJJCATYCJAMoCxYABCAFijoHIBs/F1HU
5slKQfHlgG0she4Eg7JUDHccA5KHzbNe7O5I26HCfFJCOjjQTgPiOZ/YEZLMwvM8bvrnvggQgpRS
mTPrrFNnak7aTsW7b6oTbZbWNCVUVUDgOiB2j5S7AupZOSvGYHDZwNvoC2wymI9PzZWbJ4ZqclVA
wncq2T2Or7AEcE+HnOMwbf8DoOOsTNIju6qWmJPYO9dHKpX0dARmHwGwavk/e/1Jd4SQp+odIUuH
Mce+uH//vR9zcDhsipZa8pGXnBzGYEySSIRxSNTJMEEcTClmuhr2Ityffct9J1SMKp0ljGRCCTX5
u7/+ZQgaEQCgIFAahaAwEQQBvAgggI0AcW50iDvCPHeAFu8k+G6XCaC/5+x53ypsIYBaPXryNm1+
9wbJIUvUoToCBQ0rxzLUZDbfQUoDZdMc1JhhaEJ8Y04AAlACvfDNDu8PugK8KNo6A3Qn8mDAqwoz
nT8ksL3CQKFsEZM1tElqKoAEbrcjgzi3iDucFfe0aKImjp8aEJ96u4MvWwYivw1sE4M8BW9QwgtB
m2m5yQAWpaOjJ/oXzQRsm7mHaQbJsRfJeqIPGvleFT3c99fFr/oOXfKJNAhjmhEq5cdg2YV3ewN8
fHHrxxq3Zy7IjVGSBpqMJyf0M+MeghAsbJ9PN7xYjCnzYlDtf8BmAiAJJEtNG8+LXKMbZ505UqIA
L9fxbevzMB1x+AXdnwE3HIB9faVZ8W6Fm615jSKyhgFGVf1NsO2eSb3Jqt0lGBq8eVYgfDXo3Pev
8KcYF7l5EXhyhQICoPc33gMx7ISFRo9rA5moCRPJylUWZoxMXYIc7PXVXOVRpFOCStSjpTc828Yv
G9iDAEg5giAsySQGWaEYukLnlUWmZEJrr8Qra6BuKNItuYsVtuo5MKhcEdZiyREtpyFUcgVAh5fS
YC2FoEe6p/2mfzu3pVkZw4urq1atdCBTKE8oyRpy73foxAE2XhQ6MRq5xzBett/n7rgsCcxwpTAK
RRQfwZCtZzQQYtFI4M2AB7IGjkoREaVkxVRNdAmvLDXENCRzmUe8WLPMJxbuLvWLbaieywISxI2l
IQkbWmxZkkSQsygZwq03PrzMGJZJadiwUB8TtqIXUUG9QulIWdt+5v45/lnW7I985/wfeM7MjKeE
8BmBWTs/1MpBa/fHvN8lWh4n8+GapYiemV+idn10xPcWM4A5aJkJRVYFzvcMOyKOi6haVo2c6e6j
qpdvC3RLrBPUzy3PQGTDB+IjPQIk1iXVPn83a7XvHrNEG06bTvcj5yz7E1MdCRIf1Aj/nStFH7ki
Q83e+9/8/KWCndp+O3SzRPWL7R92tkmuWtFs+tD/Q+ACxnt1qf8//j4wuMSUopNzp74cnFhiatFh
d9r2nxBhvPepSwWmKUctDIajuMKqzJxS495vef/0wZTi4e+Njs7MqrgmvWUkjepIjsyu3IWP8o0+
0WHY6PiB/tVNeLRoO98Qe17gzqTZvOrAEdbtO5ZaNm8td6JW2cy1k09X7F5rVf6cDt0nsl+82MLP
6NK5s2bDqaFL27VowOMnfKP9Qz2tdXHPxIwmrZFT3cAR+uzqf5pHwdTeg6s66racPbRRdlZ6cvio
2qFGvVKnu/Rfzfpygby+eERv/wK3OoUtnr92+80pTZaUuTSbEXsJVDVSKns2s3xoTUPwVM0ei28K
KgMFePAxTKaGAaqB0ckzHYEPyzWUxZrW4HIR1nJOPi4urKeNtEw9C4gz4iTVhEYYEzBRmAiJ+JMT
Tnabr5/5N0LCIJ0MoAo6cTQyUGQgJH9TCHqjiVgshGVySRdB2GzgotPBD6YYIp4CQdRToyjbDhZ2
lmudOnnx2b+EwBx2p8anwe/fwVang5Sg2FyWVq1IuCoRCSa0jp8ETmTL4+9HxOZL4/MLHZyazfOA
pWQxnLvSsaMEFrM4gfjYyZHkHcBaxZ+gaOiIe2Aqr8XdnZgmP0Z28n8F1RQ5QVDyJM6C/C/lHv52
cvj/4Tp01QwH1P1QtaLWlJ/tD/lvV7EfGsX3xI50sLHK6/e7c71/kt4IdCsGD3zwwPPQSkSJ/6I0
IKE+AFudzhZKJnE+nrOoHv6mhY9BjaeN3g8sxAb0Q78nqvhN4p/L5fX53B6H3eFyhn/+3OMIr/NV
7vPZQg6H0+U4iMWip6UXBvucNt5vOGO1lTVSpN0lsE1/IOR5VEDRJGHWBBy3MiD/ZRaiFHyRHKlW
Dbmi/7GhMDgJ0e/S7GGWFrl2zZuqXliC3Bp3iEtOOFd1y8aR+d3Tw/2d7U0owF9Olocbhj5YCnbV
nFVdHUz8dlv+lazNplJI0vifP6yV2bMxbORd0aWoFHLK2xcTLx+z1XUZ1/hdoi+WIydOICnor1qT
bOnnrVdM29DKjnSfyCXX/l4vmMMInBMlniaPsdL9Tg5Z2pzp1Y1esIRF9KQVrCaHBIx1srB8htyW
vvxppTJzJoZ1/CcDJXeluNkkj7Q4WBKO086/tvO8iYvu2IKNr8ceEDg4U7ugPd7IuhmLfrKtit8k
VIrCMYwkGJah6IxlCZyB0wx1yOQsw9AUw6Fo6rT0wuArJ4uXmkIw/4nFSCQQTW33/8/KSCg1VZGg
3uXX3Ureqk5ZKMd3Hbbmw2JFYTAQSVP9V2zNESHjCNO5QXKUEFpWmvmb1Qr8ViQiPSxFQ+EeBxii
thj7OpfclZOgWRYOmStt0zTlseflhHmYjaWYjdkFdX/FWElmtpDN5WMuR/uBKxiKEcTfm2ee6MiP
f9HEbvf7kxiaXVXZnde0HE7TIY+CQ468ZakHHJlLYY90Gk1mgk6HxPn/o4PcjWfPK1B8cfCKvN6W
Z0GjStN48jRToGiiABnJaoiWXfdPvSYK8zubp/xfo8Xxj+fnrxdb+2X0r//+S4dfr0JjIkme/Qd6
4JHYgFugqr3mvRsrqTx9AS45GkrlSYztzZ1QPJxm+fn4gDK4VuaEGPqeFH2I35OljZzU1q31fGrt
2iNW6x6RYSFB/qSmPIo/W9su8/LfgwDMO58uXTzdmLv98cu6WmMbwTI2Gp98OXy/s+dzMmkViTNZ
5OrV81vzu0Z3P93VFWZvmtPl3Ssbw06oawvaQEsnn9fr3pM4oyoliZeUKvB/92AZP+TS9Z03Jx9u
aN/bc71Oqmn+JVSMGOHue/jhu83ucroCQnuVyBHtdd08GoWZL/NLUkzPUVVa/DuRwPJ8UyHe92hl
FTQ2NNQ1VGGaerTSQEf8l6Q5skBluYSIE8uzVFEqNt27ogKN8i/TJAdX7+szTwTkcYXGQvC6k5qk
pmWVGv9EI/ABTVKOCbrMqqXZdZOlRRbModR0WWlVA0cTCzM9I3MdDffkbtYRZnoFNJWXl5YSBPIn
K6+qKMfSk1MIAZWj+aGBE7snajAPQLquaqrMq7oGV7U4Wsanst6wWBXRm+upDxW5GpCvDJ4ytW7V
MnWAuOH9eEQ39MBivUMMPRYWyA+duMhQoumPqGtKy/+N32f3B32+AEWimajXE4uFvXFKIwHf64Ti
c+m07/9PFOxms+7f0wuDl1/7C15+dcf2G5QrbbB8aGFh8/j07AN3MOBxOgKO39SOb7Q2HzheaY4b
Fb75X++TwB9OFvEfp1kGCqV0mlJSA5411sul7GQmpDlGw/h75aPEOFTokFf+n2YJe1nPZnawj8Mc
YDeX2c8JrpF7Fb3ASzxHEcYPajLUIKUJrOE4Ioyv2aSxim8TSHAI0gRimdMmfmMvJWssFJ4+fa6m
qzDsBtjd1LXcn01FIjvOtcLK9xnLc3PkwBtb+fM9cfGm2qnl+6aAsbkshuVd7lg4hRNoBk/qU6fz
2N66dl+S9c3iu4JZ5PieKAbQHymqBabDYiNCV1leUUFWh108KgwniReNvcryB5M1wcakiKy8QgOI
OVmEbfvWFguTyxopC1JlOxB0PH4vAz7jIyV/MCdBJ4oBmGgYKFgkJMj9xqC7X57TCIht7heIHgER
vUhMj1CzWozi9YjGt/G853HYvX63q/xTr8d/cpvyYK33HxRp9cNHT8vvPxRezF6PKGVot1e10hwQ
y7TQX/WDPK8Z8z8vtyn/i2BYvEBS5c8Q5S5Hh+tFFL1DSN2XioqVN9X6RZJKZwkdRdGFm8+SyeCX
C/e+JSCATuO/20cOTRbVu1eheQH4vt+GKgA/re079E/7M3HHRo95vwQaCgCBP34RKVMrxo0cCr6y
ZcSlpX4GxkLVTv4cTx0u6PP5CcUulQEqDoBEEcAiB5uJAgjwiPeKySIaovuAVxBIKMwrpAjyE2n0
9T9QV5JBDemYZpK/sSap6bJnQ4ItV0jhlRdIo4S8QTpR+YcMKimdTCxVjTxkqAbkJchPA33DycJW
Q8lPaTWRbMJqCwXIVEcoiFd9QCFKqC8oTESDkqigRSiCpcUpmQytNZUlXZtI5fBqSyiToLaGymNr
uz5OBSztmCYstiWkkMioPFWoRGVqEKOrh/C4rZgoAcrTEbKoBJwlsZFp4vLi9RAxIcY4Fp5CpBDw
8gxigk+23Gy2UZR1wCI4U9hM8nR3sagrgQ0Qw1v7zIKfwzOLPVV5cRQmSbcdf0lcKkBPRONPopJT
eV1GUFAdalNlmKjMYEIib7QVdvbqPoaajGDko6E6EB9TXgqEwmLKD+zpkORHttdEDhMi356vIu2T
kBvLioXR81l9yuJzC1QVvGvSY3P3pt5fdIS493rCiinjorOavwaMC33CouPNXRp6v/HB77LX1u4s
4iR9iyPXzH5t16nO6II3NoCfWU6KzFRp6RpVdqP4hcV4O0V74IPltQIAAAA=
+459
View File
@@ -930,6 +930,164 @@ fn non_stretched_auto_grid_item_shrink_wraps_inside_grid_area() {
);
}
#[test]
fn percentage_max_width_clamps_grid_item_intrinsic_size() {
let tree = parse_html(
r#"
<style>
body { margin: 0 }
#grid { display: grid; width: 1200px }
#item { max-width: 100% }
#gallery { display: flex; gap: 8px }
.column { flex: none; height: 20px }
</style>
<div id="grid">
<div id="item">
<div id="gallery">
<div class="column" style="width:303px"></div>
<div class="column" style="width:200px"></div>
<div class="column" style="width:500px"></div>
<div class="column" style="width:200px"></div>
<div class="column" style="width:293px"></div>
</div>
</div>
</div>
"#,
);
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#"
<style>
body { margin: 0 }
#grid {
display: grid;
width: 1200px;
grid-template-columns:
[extended-full-start] 16px
[full-start] 200px
[content-start] 768px
[content-end] 200px
[full-end] 16px
[extended-full-end];
}
#grid > * { grid-column: content }
#hero { grid-column-end: extended-full-end }
</style>
<div id="grid"><div id="hero"></div></div>
"#,
);
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#"
<style>
body { margin: 0 }
#box { width: 600px }
button {
display: flex;
margin: 0 auto;
padding: 19px 24px;
border: 2px solid;
gap: 6px;
font-size: 24px;
}
button::before { content: ""; width: 1em; height: 1em }
</style>
<div id="box"><button id="search">Search</button></div>
"#,
);
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#"
<style>
body { margin: 0 }
#bar {
display: flex;
width: 1280px;
gap: 16px;
justify-content: flex-end;
}
#crumb { width: 42px; height: 20px; margin: 0 auto 0 0 }
#theme { width: 91px; height: 20px }
#language { width: 131px; height: 20px }
</style>
<div id="bar">
<div id="crumb"></div>
<div id="theme"></div>
<div id="language"></div>
</div>
"#,
);
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#"
<style>
pre { display: flex }
code code { position: relative }
:is(.\*\*\:\[\.line\]\:block *).line { display: block }
</style>
<div class="**:[.line]:block">
<pre><code><code><span id="one" class="line">one</span><span id="two" class="line">two</span></code></code></pre>
</div>
"#,
);
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#"
<style>
@supports (((-webkit-hyphens:none)) and
(not (margin-trim:inline))) or
((-moz-orient:inline) and
(not (color:rgb(from red r g b)))) {
* { --leading:initial }
}
:root { --hero-line-height:1 }
body { margin:0; line-height:1.5 }
#hero {
margin:0;
width:600px;
font-size:96px;
line-height:var(--leading,var(--hero-line-height))
}
</style>
<h1 id="hero">one<br>two<br>three</h1>
"#,
);
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#"
<style>
:root {
--tone:#112233;
--light:initial;
--toggle:var(--light) #18191b;
--page:var(--toggle,#ffffff)
}
#invalid { --tone:initial; color:var(--tone,#445566) }
#inherited { --tone:unset; color:var(--tone,#445566) }
#nested { background-color:var(--page) }
</style>
<div id="invalid">fallback</div>
<div id="inherited">inherit</div>
<div id="nested">nested fallback</div>
"#,
);
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#"
<style>
html { overflow-y:auto; scrollbar-gutter:stable }
body { margin:0 }
#fill { width:100%; height:40px }
#center { width:401px; height:40px; margin-inline:auto }
</style>
<div id="fill"></div>
<div id="center"></div>
"#,
);
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#"
<style>
* { box-sizing:border-box }
html, body { margin:0 }
#grid {
display:grid;
width:1016px;
grid-template-columns:auto auto;
grid-template-rows:320px;
column-gap:16px;
}
#copy {
grid-column:1;
font:400 48px/57.6px sans-serif;
}
#art {
grid-column:2;
overflow:hidden;
}
#art > div { width:560px; height:560px }
</style>
<div id="grid">
<h1 id="copy">Resources for Developers,<br> by Developers</h1>
<div id="art"><div></div></div>
</div>
"#,
);
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#"
<style>
html, body { margin:0 }
body { font-size:20px; line-height:20px }
#host { width:200px; background:#eee }
#host::before {
content:"";
display:block;
width:80px;
height:40px;
margin-bottom:10px;
background:#06c;
}
#next { width:20px; height:10px; background:#0a0 }
</style>
<div id="host">TEXT</div><div id="next"></div>
"#,
);
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#"
<style>
html, body { margin:0 }
#host { display:flex; width:100px; height:20px }
#host::before {
content:"";
display:inline-block;
width:.5em;
height:.5em;
margin-right:5px;
border:2px solid #06c;
}
#label { width:20px; height:10px }
</style>
<div id="host"><span id="label"></span></div>
"#,
);
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#"
<style>
:root {
--surface:light-dark(#f5f5f5,#18191b);
}
#dark {
background-color:var(--surface);
color:light-dark(#111111,#eeeeee);
border:1px solid light-dark(#dddddd,#333333);
}
#dark {
color-scheme:dark;
}
#dark::before {
content:"";
display:block;
width:10px;
height:10px;
background:light-dark(#ff0000,#0000ff);
}
#child {
background-image:linear-gradient(
light-dark(#ffffff,#101010),
light-dark(#eeeeee,#202020)
);
}
#icon rect {
fill:light-dark(#c3c7cb,#51565d);
stroke:light-dark(#ffffff,#000000);
}
#mixed {
background:light-dark(#abcdef,#123456);
color-scheme:light dark;
}
</style>
<section id="dark">
<div id="child"></div>
<svg id="icon"><rect width="10" height="10"/></svg>
</section>
<div id="mixed"></div>
"#,
);
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]));
}
+12 -2
View File
@@ -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;
}