Improve modern layout and viewport rendering
This commit is contained in:
1 parent
aff0172987
commit
10b8492116
22 files changed
+5978
-450
No files matched your search
Generated
+1
@@ -2070,6 +2070,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"obscura-dom",
|
"obscura-dom",
|
||||||
"resvg",
|
"resvg",
|
||||||
|
"swash",
|
||||||
"taffy",
|
"taffy",
|
||||||
"tiny-skia",
|
"tiny-skia",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
|||||||
@@ -1790,7 +1790,15 @@ impl Page {
|
|||||||
// the overwhelming majority of real markup.
|
// the overwhelming majority of real markup.
|
||||||
let base_url = self.resolve_base_url();
|
let base_url = self.resolve_base_url();
|
||||||
let base_url = base_url.as_ref().map(|u| u.as_str());
|
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
|
/// Absolute URLs the page pulled in via fetch()/XHR (issue #301). Empty
|
||||||
@@ -2288,6 +2296,45 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use obscura_dom::parse_html;
|
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]
|
#[test]
|
||||||
fn truncate_never_splits_a_multibyte_char() {
|
fn truncate_never_splits_a_multibyte_char() {
|
||||||
// A caller-supplied expression whose byte 80 lands inside a multi-byte
|
// A caller-supplied expression whose byte 80 lands inside a multi-byte
|
||||||
|
|||||||
@@ -406,25 +406,47 @@ pub async fn handle(
|
|||||||
.get_session_page(session_id)
|
.get_session_page(session_id)
|
||||||
.map(|page| (page.viewport.0 as f64, page.viewport.1 as f64))
|
.map(|page| (page.viewport.0 as f64, page.viewport.1 as f64))
|
||||||
.unwrap_or((1280.0, 720.0));
|
.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)
|
.get_session_page_mut(session_id)
|
||||||
.map(|p| p.evaluate("document.documentElement && document.documentElement.scrollHeight"))
|
.map(|page| {
|
||||||
.and_then(|v| v.as_f64())
|
page.evaluate(
|
||||||
.filter(|n| *n > 0.0)
|
"[window.scrollX, window.scrollY, \
|
||||||
.unwrap_or(height);
|
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!({
|
let layout_viewport = json!({
|
||||||
"pageX": 0, "pageY": 0,
|
"pageX": page_x, "pageY": page_y,
|
||||||
"clientWidth": width, "clientHeight": height,
|
"clientWidth": width, "clientHeight": height,
|
||||||
});
|
});
|
||||||
let visual_viewport = json!({
|
let visual_viewport = json!({
|
||||||
"offsetX": 0.0, "offsetY": 0.0,
|
"offsetX": 0.0, "offsetY": 0.0,
|
||||||
"pageX": 0.0, "pageY": 0.0,
|
"pageX": page_x, "pageY": page_y,
|
||||||
"clientWidth": width, "clientHeight": height,
|
"clientWidth": width, "clientHeight": height,
|
||||||
"scale": 1.0, "zoom": 1.0,
|
"scale": 1.0, "zoom": 1.0,
|
||||||
});
|
});
|
||||||
let content_size = json!({
|
let content_size = json!({
|
||||||
"x": 0.0, "y": 0.0,
|
"x": 0.0, "y": 0.0,
|
||||||
"width": width, "height": content_height,
|
"width": content_width, "height": content_height,
|
||||||
});
|
});
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"layoutViewport": layout_viewport,
|
"layoutViewport": layout_viewport,
|
||||||
@@ -606,6 +628,59 @@ mod tests {
|
|||||||
assert_eq!(content["height"].as_f64(), Some(720.0));
|
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]
|
#[tokio::test]
|
||||||
async fn unknown_page_method_still_errors() {
|
async fn unknown_page_method_still_errors() {
|
||||||
let mut ctx = CdpContext::new();
|
let mut ctx = CdpContext::new();
|
||||||
|
|||||||
@@ -641,10 +641,21 @@ async fn run_fetch(
|
|||||||
// nor by the V8 watchdog (terminate_execution only unwinds JS bytecode, not
|
// 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
|
// 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
|
// 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
|
// operation overruns navigation + every configured settle pass + grace. A
|
||||||
// the process exits before this fires.
|
// 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::spawn(move || {
|
||||||
std::thread::sleep(hard);
|
std::thread::sleep(hard);
|
||||||
eprintln!("obscura: hard timeout exceeded ({}s); forcing exit", hard.as_secs());
|
eprintln!("obscura: hard timeout exceeded ({}s); forcing exit", hard.as_secs());
|
||||||
@@ -671,11 +682,62 @@ async fn run_fetch(
|
|||||||
// pages stay fast.
|
// pages stay fast.
|
||||||
page.settle(wait_secs.saturating_mul(1000)).await;
|
page.settle(wait_secs.saturating_mul(1000)).await;
|
||||||
|
|
||||||
// --screenshot renders the settled page to a PNG. Requires the render
|
let mut deferred_eval_output = None;
|
||||||
// feature; without it, page.screenshot is absent and we report clearly.
|
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 {
|
if let Some(ref path) = screenshot {
|
||||||
#[cfg(feature = "render")]
|
#[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.
|
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
|
||||||
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
||||||
// capture below-the-fold content in one shot).
|
// capture below-the-fold content in one shot).
|
||||||
@@ -684,6 +746,19 @@ async fn run_fetch(
|
|||||||
Some(bytes) => std::fs::write(path, &bytes)?,
|
Some(bytes) => std::fs::write(path, &bytes)?,
|
||||||
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
|
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 {
|
if !quiet {
|
||||||
eprintln!("Screenshot written: {} ({} bytes)", path.display(), std::fs::metadata(path).map(|m| m.len()).unwrap_or(0));
|
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 {
|
let rendered = match dump {
|
||||||
DumpFormat::Html => dump_html(&page),
|
DumpFormat::Html => dump_html(&page),
|
||||||
DumpFormat::Text => dump_text(&mut 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");
|
||||||
|
}
|
||||||
@@ -1031,6 +1031,22 @@ mod tests {
|
|||||||
assert_eq!(node.as_element().unwrap().local.as_ref(), "span");
|
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]
|
#[test]
|
||||||
fn test_query_selector_attribute() {
|
fn test_query_selector_attribute() {
|
||||||
let tree = parse_html(
|
let tree = parse_html(
|
||||||
|
|||||||
Vendored
+657
-120
@@ -657,8 +657,66 @@ globalThis.setInterval = (fn, delay = 0, ...args) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); };
|
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));
|
globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
|
||||||
|
|
||||||
class MessageChannel {
|
class MessageChannel {
|
||||||
@@ -2687,49 +2745,103 @@ class Element extends Node {
|
|||||||
// returning 100x20 there made every element appear off-screen and broke .click().
|
// returning 100x20 there made every element appear off-screen and broke .click().
|
||||||
get clientWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
|
get clientWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
|
||||||
get clientHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
|
get clientHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
|
||||||
get scrollWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
|
get scrollWidth() {
|
||||||
get scrollHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
|
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() {
|
_isViewportRoot() {
|
||||||
const t = this.tagName;
|
const t = this.tagName;
|
||||||
return t === 'HTML' || t === 'BODY';
|
return t === 'HTML' || t === 'BODY';
|
||||||
}
|
}
|
||||||
// No layout engine, so there is no real overflow to scroll and the offset is
|
_renderScrollMetrics() {
|
||||||
// deliberately NOT clamped: without real geometry any synthetic max is a
|
if (typeof Deno.core.ops.op_layout_metrics !== 'function') return null;
|
||||||
// guess, and a max derived from a stub scroll box pins scrollTop at 0, which
|
try {
|
||||||
// deadlocks scroll-driven lazy loaders (no scroll -> no content -> no scroll).
|
const raw = Deno.core.ops.op_layout_metrics();
|
||||||
// We track the offset so scrollTop/scrollLeft round-trip, and fire a scroll
|
return raw ? JSON.parse(raw) : null;
|
||||||
// event on direct assignment -- lazy loaders that set `el.scrollTop = N` rely
|
} catch (_e) {
|
||||||
// on that event, and scrollTo/scrollBy below would otherwise be its only source.
|
return null;
|
||||||
get scrollTop() { return this._scrollTop || 0; }
|
}
|
||||||
|
}
|
||||||
|
_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) {
|
set scrollTop(v) {
|
||||||
v = +v;
|
v = +v;
|
||||||
const nv = Number.isFinite(v) && v > 0 ? v : 0;
|
const nv = Number.isFinite(v) && v > 0 ? v : 0;
|
||||||
const changed = nv !== (this._scrollTop || 0);
|
const old = this.scrollTop;
|
||||||
this._scrollTop = nv;
|
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._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) {
|
set scrollLeft(v) {
|
||||||
v = +v;
|
v = +v;
|
||||||
const nv = Number.isFinite(v) && v > 0 ? v : 0;
|
const nv = Number.isFinite(v) && v > 0 ? v : 0;
|
||||||
const changed = nv !== (this._scrollLeft || 0);
|
const old = this.scrollLeft;
|
||||||
this._scrollLeft = nv;
|
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._scrollSuppress) this._fireScroll();
|
||||||
|
if (changed && this._isViewportRoot() &&
|
||||||
|
typeof globalThis.__obscura_recompute_intersections === "function") {
|
||||||
|
globalThis.__obscura_recompute_intersections();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
getBoundingClientRect() {
|
getBoundingClientRect() {
|
||||||
globalThis.__obscura_click_target = this;
|
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
|
// 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
|
// 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.
|
// 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);
|
var _r = JSON.parse(_geom);
|
||||||
if (_r && typeof _r.width === 'number') {
|
if (_r && typeof _r.width === 'number') {
|
||||||
var _x = _r.x, _y = _r.y, _w = _r.width, _h = _r.height;
|
var _x = _r.x, _y = _r.y, _w = _r.width, _h = _r.height;
|
||||||
return {
|
var _rect = {
|
||||||
x: _x, y: _y, width: _w, height: _h,
|
x: _x, y: _y, width: _w, height: _h,
|
||||||
top: _y, right: _x + _w, bottom: _y + _h, left: _x,
|
top: _y, right: _x + _w, bottom: _y + _h, left: _x,
|
||||||
toJSON() { return this; },
|
toJSON() { return this; },
|
||||||
};
|
};
|
||||||
|
Object.defineProperty(_rect, "__obscuraViewportFixed", {
|
||||||
|
value: !!_r.viewportFixed,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
return _rect;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_e) {}
|
} 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
|
// No layout engine (default build) or this node has no box: synthesize a
|
||||||
// deterministic position from the node id so Playwright's actionability
|
// deterministic position from the node id so Playwright's actionability
|
||||||
// polling still gets a stable, distinct rect for hit-testing (issue #45).
|
// 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)); }
|
set ariaHidden(v) { if (v == null) this.removeAttribute('aria-hidden'); else this.setAttribute('aria-hidden', String(v)); }
|
||||||
get ariaSelected() { return this.getAttribute('aria-selected'); }
|
get ariaSelected() { return this.getAttribute('aria-selected'); }
|
||||||
set ariaSelected(v) { if (v == null) this.removeAttribute('aria-selected'); else this.setAttribute('aria-selected', String(v)); }
|
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.
|
// 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
|
// 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
|
// 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
|
// ResizeObserver is defined earlier with real per-target firing; the stub
|
||||||
// that previously lived here was a no-op that clobbered the real class.
|
// that previously lived here was a no-op that clobbered the real class.
|
||||||
//
|
//
|
||||||
// IntersectionObserver: without a layout engine we can't compute real
|
// Viewport IntersectionObserver. Render builds provide real, scroll-relative
|
||||||
// intersection geometry, so every observed target is treated as fully
|
// target boxes through getBoundingClientRect. Element scroll-container roots
|
||||||
// in-viewport (`isIntersecting: true`, `intersectionRatio: 1`). Real
|
// remain intentionally unsupported until their offsets participate in layout.
|
||||||
// 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.
|
|
||||||
globalThis.__intersectionObservers = [];
|
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 {
|
globalThis.IntersectionObserver = class IntersectionObserver {
|
||||||
constructor(callback, options) {
|
constructor(callback, options) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
throw new TypeError("IntersectionObserver callback must be a function");
|
||||||
|
}
|
||||||
this._callback = callback;
|
this._callback = callback;
|
||||||
this._options = options || {};
|
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._targets = new Set();
|
||||||
|
this._previous = new Map();
|
||||||
|
this._records = [];
|
||||||
this._connected = true;
|
this._connected = true;
|
||||||
this._fireCount = 0;
|
this._deliveryPending = false;
|
||||||
globalThis.__intersectionObservers.push(this);
|
globalThis.__intersectionObservers.push(this);
|
||||||
}
|
}
|
||||||
_fireFor(targets) {
|
_rootBounds() {
|
||||||
if (!this._connected || !targets.length || this._fireCount >= 256) return;
|
const width = globalThis.innerWidth || 1280;
|
||||||
this._fireCount++;
|
const height = globalThis.innerHeight || 720;
|
||||||
const records = targets.map(target => ({
|
const resolve = (margin, basis) =>
|
||||||
target,
|
margin.unit === "%" ? margin.value * basis / 100 : margin.value;
|
||||||
isIntersecting: true,
|
// IntersectionObserver resolves every rootMargin percentage against the
|
||||||
intersectionRatio: 1,
|
// root rectangle's width, including the block-axis sides.
|
||||||
boundingClientRect: target.getBoundingClientRect
|
const top = resolve(this._margins[0], width);
|
||||||
? target.getBoundingClientRect()
|
const right = resolve(this._margins[1], width);
|
||||||
: { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
|
const bottom = resolve(this._margins[2], width);
|
||||||
intersectionRect: target.getBoundingClientRect
|
const left = resolve(this._margins[3], width);
|
||||||
? target.getBoundingClientRect()
|
return _ioRect(-left, -top, width + left + right, height + top + bottom);
|
||||||
: { 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 */ }
|
|
||||||
}
|
}
|
||||||
observe(el) {
|
_entry(target) {
|
||||||
if (!el || !this._connected) return;
|
const rect = target.getBoundingClientRect();
|
||||||
if (this._targets.has(el)) return;
|
const root = this._rootBounds();
|
||||||
this._targets.add(el);
|
const left = Math.max(rect.left, root.left);
|
||||||
Promise.resolve().then(() => this._fireFor([el]));
|
const top = Math.max(rect.top, root.top);
|
||||||
// Exponential burst to cover infinite-scroll sentinels that "re-arm"
|
const right = Math.min(rect.right, root.right);
|
||||||
// after content lands. Without a real scroll/layout signal, we fake the
|
const bottom = Math.min(rect.bottom, root.bottom);
|
||||||
// re-fire schedule. Beyond ~10s the page has usually settled.
|
const edgesTouch = right >= left && bottom >= top;
|
||||||
[120, 500, 1500, 3500, 7000].forEach(delay => {
|
const width = Math.max(0, right - left);
|
||||||
setTimeout(() => {
|
const height = Math.max(0, bottom - top);
|
||||||
if (this._connected && this._targets.has(el)) this._fireFor([el]);
|
const targetArea = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||||
}, delay);
|
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() {
|
disconnect() {
|
||||||
this._connected = false;
|
this._connected = false;
|
||||||
this._targets.clear();
|
this._targets.clear();
|
||||||
const idx = globalThis.__intersectionObservers.indexOf(this);
|
this._previous.clear();
|
||||||
if (idx >= 0) globalThis.__intersectionObservers.splice(idx, 1);
|
this._records.length = 0;
|
||||||
|
const index = globalThis.__intersectionObservers.indexOf(this);
|
||||||
|
if (index >= 0) globalThis.__intersectionObservers.splice(index, 1);
|
||||||
}
|
}
|
||||||
takeRecords() { return []; }
|
takeRecords() { return this._records.splice(0); }
|
||||||
get root() { return this._options.root || null; }
|
get root() { return null; }
|
||||||
get rootMargin() { return this._options.rootMargin || "0px 0px 0px 0px"; }
|
get rootMargin() {
|
||||||
get thresholds() {
|
return this._margins.map((margin) => `${margin.value}${margin.unit}`).join(" ");
|
||||||
const t = this._options.threshold;
|
|
||||||
if (t == null) return [0];
|
|
||||||
return Array.isArray(t) ? t.slice() : [t];
|
|
||||||
}
|
}
|
||||||
|
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() {
|
(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;
|
let pending = false;
|
||||||
|
const recompute = () => {
|
||||||
|
if (pending) return;
|
||||||
|
pending = true;
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
pending = false;
|
||||||
|
for (const observer of globalThis.__intersectionObservers) {
|
||||||
|
if (observer._connected && observer._targets.size) {
|
||||||
|
observer._check([...observer._targets], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
globalThis.__obscura_recompute_intersections = recompute;
|
||||||
|
globalThis.addEventListener("resize", recompute);
|
||||||
const wireUp = () => {
|
const wireUp = () => {
|
||||||
if (!globalThis.document?.body) return;
|
if (!globalThis.document?.body) return;
|
||||||
const mo = new MutationObserver(() => {
|
const observer = new MutationObserver(recompute);
|
||||||
if (pending) return;
|
try {
|
||||||
pending = true;
|
observer.observe(globalThis.document.body, {
|
||||||
Promise.resolve().then(() => { pending = false; reFire(); });
|
childList: true,
|
||||||
});
|
subtree: true,
|
||||||
try { mo.observe(globalThis.document.body, {childList: true, subtree: true}); } catch {}
|
attributes: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
if (globalThis.document?.body) wireUp();
|
if (globalThis.document?.body) wireUp();
|
||||||
else Promise.resolve().then(wireUp);
|
else Promise.resolve().then(wireUp);
|
||||||
@@ -6718,21 +6958,312 @@ globalThis.screenLeft = 0; globalThis.screenTop = 0;
|
|||||||
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
|
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
|
||||||
globalThis.scrollX = 0; globalThis.scrollY = 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 = {
|
globalThis.CSS = {
|
||||||
supports(prop, value){
|
supports(prop, value){
|
||||||
try {
|
try {
|
||||||
var p, v;
|
if (arguments.length >= 2) {
|
||||||
if (arguments.length >= 2) { p = String(prop).trim(); v = String(value).trim(); }
|
return _cssSupportsDeclaration(String(prop), String(value));
|
||||||
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 (!p || !v) return false;
|
return _cssSupportsCondition(String(prop));
|
||||||
// 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;
|
|
||||||
} catch (e) { return false; }
|
} catch (e) { return false; }
|
||||||
},
|
},
|
||||||
escape(s){ return s; }
|
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
|
// The page offset is stored on the scrolling element rather than in separate
|
||||||
// window state, so window.scrollY and document.scrollingElement.scrollTop are
|
// 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
|
// two views of one value, which is what pages assume. Render builds clamp that
|
||||||
// layout, so the offset still cannot be clamped to a real maximum.
|
// shared root offset against measured document overflow; non-render builds keep
|
||||||
|
// the legacy synthetic offset used by automation-only consumers.
|
||||||
function _scrollRoot() {
|
function _scrollRoot() {
|
||||||
const doc = globalThis.document;
|
const doc = globalThis.document;
|
||||||
return (doc && doc.scrollingElement) || null;
|
return (doc && doc.scrollingElement) || null;
|
||||||
@@ -8299,6 +8831,8 @@ function _scrollRoot() {
|
|||||||
function _windowScroll(x, y, relative) {
|
function _windowScroll(x, y, relative) {
|
||||||
const root = _scrollRoot();
|
const root = _scrollRoot();
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
const beforeLeft = root.scrollLeft || 0;
|
||||||
|
const beforeTop = root.scrollTop || 0;
|
||||||
let left, top;
|
let left, top;
|
||||||
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
|
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
|
||||||
else { left = x; top = y; }
|
else { left = x; top = y; }
|
||||||
@@ -8308,6 +8842,9 @@ function _windowScroll(x, y, relative) {
|
|||||||
if (top !== undefined) {
|
if (top !== undefined) {
|
||||||
root.scrollTop = (relative ? (root.scrollTop || 0) : 0) + (+top || 0);
|
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
|
// Async, matching the element path #431 added. Dispatched at the document
|
||||||
// AND the window: a page scroll event reaches both in Chrome, but
|
// AND the window: a page scroll event reaches both in Chrome, but
|
||||||
// Document.dispatchEvent here runs only its own listeners and does not
|
// Document.dispatchEvent here runs only its own listeners and does not
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ pub use v8_flags::set_v8_flags;
|
|||||||
// Screenshot rasterization (PNG bytes) from the render layer. Available when the
|
// Screenshot rasterization (PNG bytes) from the render layer. Available when the
|
||||||
// render feature (which enables obscura-render/paint) is compiled in.
|
// render feature (which enables obscura-render/paint) is compiled in.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub use obscura_render::screenshot_png;
|
pub use obscura_render::{screenshot_png, screenshot_png_scrolled};
|
||||||
+155
-27
@@ -64,6 +64,14 @@ pub struct JsNetworkEvent {
|
|||||||
pub timestamp: f64,
|
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 struct ObscuraState {
|
||||||
pub dom: Option<DomTree>,
|
pub dom: Option<DomTree>,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -104,14 +112,17 @@ pub struct ObscuraState {
|
|||||||
// Network.requestWillBeSent / responseReceived for them (issue #406).
|
// Network.requestWillBeSent / responseReceived for them (issue #406).
|
||||||
pub js_network_events: Vec<JsNetworkEvent>,
|
pub js_network_events: Vec<JsNetworkEvent>,
|
||||||
/// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The
|
/// 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
|
/// cache is computed on first geometry read and cleared on navigation,
|
||||||
/// not invalidated on every DOM mutation yet (a follow-up), so geometry read
|
/// viewport changes, and DOM/style mutations that can affect layout.
|
||||||
/// mid-script may lag a frame; reading after settle is reliable.
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub layout_cache:
|
pub layout_cache: Option<LayoutCache>,
|
||||||
Option<std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>>,
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub viewport: (f32, f32),
|
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 {
|
impl ObscuraState {
|
||||||
@@ -141,6 +152,8 @@ impl ObscuraState {
|
|||||||
layout_cache: None,
|
layout_cache: None,
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
viewport: (1280.0, 720.0),
|
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 {
|
fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> String {
|
||||||
let gs = state.borrow::<SharedState>().clone();
|
let shared = state.borrow::<SharedState>().clone();
|
||||||
let gs = gs.borrow();
|
#[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 {
|
let dom = match &gs.dom {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return "null".to_string(),
|
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
|
// 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.
|
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
ops.push(op_layout_geometry());
|
{
|
||||||
|
ops.push(op_layout_geometry());
|
||||||
|
ops.push(op_layout_metrics());
|
||||||
|
ops.push(op_scroll_offset());
|
||||||
|
ops.push(op_scroll_to());
|
||||||
|
}
|
||||||
Extension {
|
Extension {
|
||||||
name: "obscura_dom",
|
name: "obscura_dom",
|
||||||
ops: std::borrow::Cow::Owned(ops),
|
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
|
/// 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
|
/// 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
|
/// (see `set_dom`). Coordinates are viewport-relative after the shared root
|
||||||
/// an empty string when the node has no box. Feature-gated.
|
/// 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")]
|
#[cfg(feature = "render")]
|
||||||
#[op2]
|
#[op2]
|
||||||
#[string]
|
#[string]
|
||||||
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
|
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
|
||||||
let shared = state.borrow::<SharedState>().clone();
|
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);
|
let nid: u32 = nid_str.parse().unwrap_or(0);
|
||||||
if let Some(rect) = gs
|
let nid = obscura_dom::tree::NodeId::new(nid);
|
||||||
.layout_cache
|
let mut gs = shared.borrow_mut();
|
||||||
.as_ref()
|
let scroll = gs.scroll_offset;
|
||||||
.and_then(|c| c.get(&obscura_dom::tree::NodeId::new(nid)))
|
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!(
|
return format!(
|
||||||
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{}}}",
|
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}",
|
||||||
rect.x, rect.y, rect.width, rect.height
|
x, y, rect.width, rect.height, viewport_fixed
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
String::new()
|
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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ use obscura_dom::DomTree;
|
|||||||
pub use deno_core::v8::IsolateHandle;
|
pub use deno_core::v8::IsolateHandle;
|
||||||
|
|
||||||
use crate::module_loader::ObscuraModuleLoader;
|
use crate::module_loader::ObscuraModuleLoader;
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
use crate::ops::clamp_scroll_offset;
|
||||||
use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
|
use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
|
||||||
|
|
||||||
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
|
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
|
||||||
@@ -195,6 +197,7 @@ impl ObscuraJsRuntime {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
gs.layout_cache = None;
|
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 {
|
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||||
return;
|
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(
|
let _ = self.runtime.execute_script(
|
||||||
"<set-viewport>",
|
"<set-viewport>",
|
||||||
format!(
|
format!(
|
||||||
@@ -294,11 +303,22 @@ impl ObscuraJsRuntime {
|
|||||||
if(globalThis.visualViewport){{\
|
if(globalThis.visualViewport){{\
|
||||||
globalThis.visualViewport.width={width};\
|
globalThis.visualViewport.width={width};\
|
||||||
globalThis.visualViewport.height={height};\
|
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.)
|
/// 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.
|
/// have been set. Must be called once per page setup, after all set_* methods.
|
||||||
pub fn run_page_init(&mut self) {
|
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]
|
#[test]
|
||||||
fn test_document_url() {
|
fn test_document_url() {
|
||||||
let mut rt = setup_runtime("<html><body></body></html>");
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
@@ -1892,6 +2052,38 @@ mod tests {
|
|||||||
assert_eq!(count.as_f64().unwrap() as i64, 3);
|
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]
|
#[test]
|
||||||
fn test_get_element_by_id() {
|
fn test_get_element_by_id() {
|
||||||
let mut rt = setup_runtime(r#"<div id="test">Content</div>"#);
|
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
|
/// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the
|
||||||
/// dominant infinite-scroll idiom never advanced the page offset.
|
/// dominant infinite-scroll idiom never advanced the page offset.
|
||||||
|
#[cfg(not(feature = "render"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn window_scroll_methods_move_the_page_offset() {
|
fn window_scroll_methods_move_the_page_offset() {
|
||||||
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
|
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
|
/// Issue #468: the page offset is one value, readable and writable through
|
||||||
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
|
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
|
||||||
|
#[cfg(not(feature = "render"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn window_scroll_offset_is_shared_with_the_scrolling_element() {
|
fn window_scroll_offset_is_shared_with_the_scrolling_element() {
|
||||||
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
|
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
|
/// Issue #468: a scroll event must reach listeners on both the window and
|
||||||
/// the document — that is the signal lazy loaders wait for.
|
/// the document — that is the signal lazy loaders wait for.
|
||||||
|
#[cfg(not(feature = "render"))]
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn window_scroll_fires_a_scroll_event() {
|
async fn window_scroll_fires_a_scroll_event() {
|
||||||
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
|
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]));
|
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
|
/// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so
|
||||||
/// firstChild()/lastChild() must descend into them. FILTER_REJECT must not.
|
/// firstChild()/lastChild() must descend into them. FILTER_REJECT must not.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ default = []
|
|||||||
# Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text
|
# Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text
|
||||||
# shaping + line breaking, ab_glyph fallback) and images.
|
# shaping + line breaking, ab_glyph fallback) and images.
|
||||||
# Off by default; the layout core (getBoundingClientRect etc.) works without it.
|
# 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]
|
[dependencies]
|
||||||
taffy = { workspace = true }
|
taffy = { workspace = true }
|
||||||
@@ -22,6 +22,10 @@ ab_glyph = { workspace = true, optional = true }
|
|||||||
# breaking, the same algorithms browsers use. Built with only our embedded
|
# breaking, the same algorithms browsers use. Built with only our embedded
|
||||||
# fonts (no system-font scan) so layout stays deterministic across hosts.
|
# 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 }
|
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 }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"], optional = true }
|
||||||
ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true }
|
ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true }
|
||||||
base64 = { workspace = true, optional = true }
|
base64 = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ impl Stylesheet {
|
|||||||
matcher: &mut Matcher,
|
matcher: &mut Matcher,
|
||||||
nid: NodeId,
|
nid: NodeId,
|
||||||
props: &HashMap<String, String>,
|
props: &HashMap<String, String>,
|
||||||
|
host_style: &LayoutStyle,
|
||||||
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
|
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
|
||||||
let build = |rules: &[PseudoRule], matcher: &mut Matcher| {
|
let build = |rules: &[PseudoRule], matcher: &mut Matcher| {
|
||||||
let mut matched: Vec<(u32, usize, &PseudoRule)> = rules
|
let mut matched: Vec<(u32, usize, &PseudoRule)> = rules
|
||||||
@@ -140,24 +141,55 @@ impl Stylesheet {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
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;
|
let mut content = None;
|
||||||
for &(_, _, rule) in &matched {
|
for &(_, _, rule) in &matched {
|
||||||
let expanded = substitute_vars(&rule.normal_decls, props, 0);
|
let expanded = substitute_declarations(&rule.normal_decls, props);
|
||||||
crate::style::apply_declarations(&mut style, &expanded);
|
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) {
|
if let Some(value) = extract_content(&expanded, tree, nid) {
|
||||||
content = value;
|
content = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for &(_, _, rule) in &matched {
|
for &(_, _, rule) in &matched {
|
||||||
let expanded = substitute_vars(&rule.important_decls, props, 0);
|
let expanded = substitute_declarations(&rule.important_decls, props);
|
||||||
crate::style::apply_declarations(&mut style, &expanded);
|
crate::style::apply_declarations_with_locked_color_scheme(
|
||||||
|
&mut style,
|
||||||
|
&expanded,
|
||||||
|
);
|
||||||
if let Some(value) = extract_content(&expanded, tree, nid) {
|
if let Some(value) = extract_content(&expanded, tree, nid) {
|
||||||
content = value;
|
content = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
style.before_content = content;
|
style.before_content = content;
|
||||||
if style.before_content.is_some() {
|
if style.before_content.is_some() || style.content_image.is_some() {
|
||||||
Some(style)
|
Some(style)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -254,20 +286,80 @@ impl Stylesheet {
|
|||||||
} else {
|
} else {
|
||||||
let mut m = parent_props.clone();
|
let mut m = parent_props.clone();
|
||||||
for (k, v) in own {
|
for (k, v) in own {
|
||||||
m.insert(k, v);
|
match v.trim().to_ascii_lowercase().as_str() {
|
||||||
|
// CSS-wide keywords on a custom property are cascade
|
||||||
|
// instructions, not literal token streams. `initial`
|
||||||
|
// produces the guaranteed-invalid value so var() must use
|
||||||
|
// its fallback even when an ancestor defined the token.
|
||||||
|
"initial" => {
|
||||||
|
m.remove(&k);
|
||||||
|
}
|
||||||
|
// Custom properties inherit by default. `unset` therefore
|
||||||
|
// has the same effect as `inherit`; approximate both
|
||||||
|
// revert forms with the inherited author value as well.
|
||||||
|
"inherit" | "unset" | "revert" | "revert-layer" => {
|
||||||
|
if let Some(inherited) = parent_props.get(&k) {
|
||||||
|
m.insert(k, inherited.clone());
|
||||||
|
} else {
|
||||||
|
m.remove(&k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
m.insert(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(m)
|
Some(m)
|
||||||
};
|
};
|
||||||
let props = effective.as_ref().unwrap_or(parent_props);
|
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
|
// Pass 2: apply normal declarations with `var()` substituted against
|
||||||
// the resolved custom-property map.
|
// the resolved custom-property map.
|
||||||
for &(_, _, i) in &matched {
|
for &(_, _, i) in &matched {
|
||||||
let expanded = substitute_vars(&self.rules[i].normal_decls, props, 0);
|
let expanded = substitute_declarations(&self.rules[i].normal_decls, props);
|
||||||
crate::style::apply_declarations(style, &expanded);
|
crate::style::apply_declarations_with_locked_color_scheme(
|
||||||
|
style,
|
||||||
|
&expanded,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let expanded = substitute_vars(&inline_normal, props, 0);
|
let expanded = substitute_declarations(&inline_normal, props);
|
||||||
crate::style::apply_declarations(style, &expanded);
|
crate::style::apply_declarations_with_locked_color_scheme(style, &expanded);
|
||||||
|
|
||||||
// CSS animations form a cascade origin above normal author rules and
|
// CSS animations form a cascade origin above normal author rules and
|
||||||
// below author !important. A static renderer has no compositor clock;
|
// 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 style.animation_fill_forwards && !style.animation_iteration_infinite {
|
||||||
if let Some(name) = style.animation_name.as_deref() {
|
if let Some(name) = style.animation_name.as_deref() {
|
||||||
if let Some(decls) = self.keyframe_ends.get(name) {
|
if let Some(decls) = self.keyframe_ends.get(name) {
|
||||||
let expanded = substitute_vars(decls, props, 0);
|
let expanded = substitute_declarations(decls, props);
|
||||||
crate::style::apply_declarations(style, &expanded);
|
crate::style::apply_declarations_with_locked_color_scheme(
|
||||||
|
style,
|
||||||
|
&expanded,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for &(_, _, i) in &matched {
|
for &(_, _, i) in &matched {
|
||||||
let expanded = substitute_vars(&self.rules[i].important_decls, props, 0);
|
let expanded = substitute_declarations(&self.rules[i].important_decls, props);
|
||||||
crate::style::apply_declarations(style, &expanded);
|
crate::style::apply_declarations_with_locked_color_scheme(
|
||||||
|
style,
|
||||||
|
&expanded,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let expanded = substitute_vars(&inline_important, props, 0);
|
let expanded = substitute_declarations(&inline_important, props);
|
||||||
crate::style::apply_declarations(style, &expanded);
|
crate::style::apply_declarations_with_locked_color_scheme(style, &expanded);
|
||||||
effective
|
effective
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,14 +470,36 @@ fn extract_keyframe_end_states(css: &str) -> Vec<(String, String)> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Substitute every `var(--name, fallback?)` in `input` with its resolved
|
/// Resolve variables one declaration at a time. An invalid variable poisons
|
||||||
/// value from `props` (recursively, so a token that itself references another
|
/// its entire declaration at computed-value time, but must not erase unrelated
|
||||||
/// token resolves; a depth cap guards against reference cycles). A missing
|
/// declarations in the same rule.
|
||||||
/// variable with no fallback expands to empty, which makes the declaration
|
fn substitute_declarations(css: &str, props: &HashMap<String, String>) -> String {
|
||||||
/// invalid and dropped, exactly as CSS specifies.
|
let mut expanded = String::new();
|
||||||
pub(crate) fn substitute_vars(input: &str, props: &HashMap<String, String>, depth: u8) -> String {
|
for declaration in crate::style::split_declarations(css) {
|
||||||
if depth > 16 || !input.contains("var(") {
|
let Some((name, value)) = declaration.split_once(':') else {
|
||||||
return input.to_string();
|
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 out = String::new();
|
||||||
let mut rest = input;
|
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 {
|
let Some(end) = end else {
|
||||||
out.push_str(&rest[pos..]);
|
return None;
|
||||||
return out;
|
|
||||||
};
|
};
|
||||||
let inner = &after[..end];
|
let inner = &after[..end];
|
||||||
let (name, fallback) = match inner.split_once(',') {
|
let (name, fallback) = match inner.split_once(',') {
|
||||||
Some((n, f)) => (n.trim(), Some(f.trim())),
|
Some((n, f)) => (n.trim(), Some(f.trim())),
|
||||||
None => (inner.trim(), None),
|
None => (inner.trim(), None),
|
||||||
};
|
};
|
||||||
let replacement = match props.get(name) {
|
let resolved = props
|
||||||
Some(v) => substitute_vars(v, props, depth + 1),
|
.get(name)
|
||||||
None => fallback.map(|f| substitute_vars(f, props, depth + 1)).unwrap_or_default(),
|
.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);
|
out.push_str(&replacement);
|
||||||
rest = &after[end + 1..];
|
rest = &after[end + 1..];
|
||||||
}
|
}
|
||||||
out.push_str(rest);
|
out.push_str(rest);
|
||||||
out
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `selector`'s rightmost compound is the pseudo-element `which`
|
/// 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 {
|
if let Some(parsed) = parsed {
|
||||||
result = Some(Some(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
|
result
|
||||||
@@ -615,7 +751,9 @@ fn flush_at_rule(
|
|||||||
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
|
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
|
||||||
}
|
}
|
||||||
} else if at.starts_with("supports") {
|
} else if at.starts_with("supports") {
|
||||||
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
|
if supports_condition_applies(sel) {
|
||||||
|
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
|
||||||
|
}
|
||||||
} else if at.starts_with("property") {
|
} else if at.starts_with("property") {
|
||||||
// `@property --x { syntax:..; initial-value: V }` declares a custom
|
// `@property --x { syntax:..; initial-value: V }` declares a custom
|
||||||
// property and its default. Register the initial-value as a `:root`
|
// 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.
|
// 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.
|
/// Coarse `@media` evaluation against an assumed 1280px-wide desktop viewport.
|
||||||
///
|
///
|
||||||
/// Real stylesheets format media features inconsistently
|
/// Real stylesheets format media features inconsistently
|
||||||
@@ -678,16 +962,26 @@ fn single_media_query_applies_for_viewport(
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
let viewport_w = viewport.0;
|
let viewport_w = viewport.0;
|
||||||
let viewport_h = viewport.1;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
let compact: String = query.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
|
if compact.contains("print") {
|
||||||
|
|
||||||
// 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") {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,7 +1227,11 @@ fn denest(
|
|||||||
if media_query_applies_for_viewport(pre, viewport) {
|
if media_query_applies_for_viewport(pre, viewport) {
|
||||||
denest(sel, &inner, rules, 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);
|
denest(sel, &inner, rules, viewport);
|
||||||
}
|
}
|
||||||
} else if !pre.is_empty() {
|
} 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]
|
#[test]
|
||||||
fn media_breakpoints_support_font_relative_lengths_and_ranges() {
|
fn media_breakpoints_support_font_relative_lengths_and_ranges() {
|
||||||
assert!(!media_query_applies_for_viewport(
|
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]
|
#[test]
|
||||||
fn media_query_lists_are_or_conditions() {
|
fn media_query_lists_are_or_conditions() {
|
||||||
assert!(media_query_applies_for_viewport(
|
assert!(media_query_applies_for_viewport(
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -14,7 +14,12 @@
|
|||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
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};
|
use obscura_dom::tree::{DomTree, NodeId};
|
||||||
|
|
||||||
@@ -90,7 +95,15 @@ struct LoadedFace {
|
|||||||
max_weight: u16,
|
max_weight: u16,
|
||||||
shape_weight: u16,
|
shape_weight: u16,
|
||||||
italic: bool,
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -106,7 +119,7 @@ fn resolve_loaded_font(
|
|||||||
requested_weight: u16,
|
requested_weight: u16,
|
||||||
requested_italic: bool,
|
requested_italic: bool,
|
||||||
loaded: &HashMap<String, LoadedFamily>,
|
loaded: &HashMap<String, LoadedFamily>,
|
||||||
) -> (Arc<str>, u16) {
|
) -> ResolvedFont {
|
||||||
if let Some(stack) = fam {
|
if let Some(stack) = fam {
|
||||||
for token in stack.split(',') {
|
for token in stack.split(',') {
|
||||||
let name = token
|
let name = token
|
||||||
@@ -124,15 +137,25 @@ fn resolve_loaded_font(
|
|||||||
} else {
|
} else {
|
||||||
exact_style
|
exact_style
|
||||||
};
|
};
|
||||||
if let Some(face) = candidates.iter().copied().find(|face| {
|
if let Some(face) = candidates
|
||||||
(face.min_weight..=face.max_weight).contains(&requested_weight)
|
.iter()
|
||||||
}) {
|
.copied()
|
||||||
let weight = if face.variable {
|
.find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
|
||||||
requested_weight
|
{
|
||||||
} else {
|
// cosmic-text 0.12 only accepts the named family when the
|
||||||
face.shape_weight
|
// 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<_> =
|
let available: Vec<_> =
|
||||||
candidates.iter().map(|face| face.min_weight).collect();
|
candidates.iter().map(|face| face.min_weight).collect();
|
||||||
@@ -141,14 +164,22 @@ fn resolve_loaded_font(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|face| face.min_weight == matched)
|
.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 fallback = resolve_font_family(fam);
|
||||||
let weights: &[u16] = &[400, 700];
|
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
|
/// 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.
|
/// Resolve `line-height: normal` from the selected face's horizontal header.
|
||||||
///
|
///
|
||||||
/// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and
|
/// 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.
|
/// into an [`InlineItem`]'s clip-text fills.
|
||||||
const META_UNDERLINE: usize = 1;
|
const META_UNDERLINE: usize = 1;
|
||||||
const META_FILL_SHIFT: 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>)>);
|
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
|
||||||
|
|
||||||
@@ -286,10 +309,106 @@ pub struct TextEngine {
|
|||||||
font_system: FontSystem,
|
font_system: FontSystem,
|
||||||
loaded_families: HashMap<String, LoadedFamily>,
|
loaded_families: HashMap<String, LoadedFamily>,
|
||||||
swash: SwashCache,
|
swash: SwashCache,
|
||||||
|
variable_swash: VariableSwashCache,
|
||||||
items: Vec<InlineItem>,
|
items: Vec<InlineItem>,
|
||||||
replaced: Vec<ReplacedItem>,
|
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);
|
const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1);
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -407,9 +526,6 @@ impl TextEngine {
|
|||||||
let shape_weight = face.weight.0;
|
let shape_weight = face.weight.0;
|
||||||
let italic = declared_italic
|
let italic = declared_italic
|
||||||
.unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal));
|
.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 weight = declared_weight.unwrap_or((shape_weight, shape_weight));
|
||||||
let declared_names: Vec<String> = declared_family
|
let declared_names: Vec<String> = declared_family
|
||||||
.map(|name| vec![name])
|
.map(|name| vec![name])
|
||||||
@@ -424,7 +540,6 @@ impl TextEngine {
|
|||||||
max_weight: weight.1,
|
max_weight: weight.1,
|
||||||
shape_weight,
|
shape_weight,
|
||||||
italic,
|
italic,
|
||||||
variable,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +549,7 @@ impl TextEngine {
|
|||||||
font_system,
|
font_system,
|
||||||
loaded_families,
|
loaded_families,
|
||||||
swash: SwashCache::new(),
|
swash: SwashCache::new(),
|
||||||
|
variable_swash: VariableSwashCache::new(),
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
replaced: Vec::new(),
|
replaced: Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -458,18 +574,13 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
let base = styles.get(&id)?;
|
let base = styles.get(&id)?;
|
||||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
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(),
|
base.font_family.as_deref(),
|
||||||
crate::style::used_font_weight(base),
|
crate::style::used_font_weight(base),
|
||||||
base.font_style_italic.unwrap_or(false),
|
base.font_style_italic.unwrap_or(false),
|
||||||
&self.loaded_families,
|
&self.loaded_families,
|
||||||
);
|
);
|
||||||
let ctx = base_span_ctx(
|
let ctx = base_span_ctx(base, font, &mut collector);
|
||||||
base,
|
|
||||||
family,
|
|
||||||
weight,
|
|
||||||
&mut collector,
|
|
||||||
);
|
|
||||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||||
collect_spans(
|
collect_spans(
|
||||||
tree,
|
tree,
|
||||||
@@ -508,18 +619,13 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
let base = styles.get(&parent)?;
|
let base = styles.get(&parent)?;
|
||||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
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(),
|
base.font_family.as_deref(),
|
||||||
crate::style::used_font_weight(base),
|
crate::style::used_font_weight(base),
|
||||||
base.font_style_italic.unwrap_or(false),
|
base.font_style_italic.unwrap_or(false),
|
||||||
&self.loaded_families,
|
&self.loaded_families,
|
||||||
);
|
);
|
||||||
let ctx = base_span_ctx(
|
let ctx = base_span_ctx(base, font, &mut collector);
|
||||||
base,
|
|
||||||
family,
|
|
||||||
weight,
|
|
||||||
&mut collector,
|
|
||||||
);
|
|
||||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||||
for &cid in run {
|
for &cid in run {
|
||||||
collect_node_spans(
|
collect_node_spans(
|
||||||
@@ -551,17 +657,18 @@ impl TextEngine {
|
|||||||
last_was_space: true,
|
last_was_space: true,
|
||||||
clip_fills: Vec::new(),
|
clip_fills: Vec::new(),
|
||||||
};
|
};
|
||||||
let (family, weight) = resolve_loaded_font(
|
let font = resolve_loaded_font(
|
||||||
style.font_family.as_deref(),
|
style.font_family.as_deref(),
|
||||||
crate::style::used_font_weight(style),
|
crate::style::used_font_weight(style),
|
||||||
style.font_style_italic.unwrap_or(false),
|
style.font_style_italic.unwrap_or(false),
|
||||||
&self.loaded_families,
|
&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 {
|
let attrs = SpanAttrs {
|
||||||
font_size: context.font_size,
|
font_size: context.font_size,
|
||||||
line_height: context.line_height,
|
line_height: context.line_height,
|
||||||
weight: context.weight,
|
weight: context.weight,
|
||||||
|
variation_weight: context.variation_weight,
|
||||||
italic: context.italic,
|
italic: context.italic,
|
||||||
underline: context.underline,
|
underline: context.underline,
|
||||||
color: context.color,
|
color: context.color,
|
||||||
@@ -735,6 +842,7 @@ struct SpanAttrs {
|
|||||||
font_size: f32,
|
font_size: f32,
|
||||||
line_height: f32,
|
line_height: f32,
|
||||||
weight: u16,
|
weight: u16,
|
||||||
|
variation_weight: Option<u16>,
|
||||||
italic: bool,
|
italic: bool,
|
||||||
underline: bool,
|
underline: bool,
|
||||||
color: [u8; 4],
|
color: [u8; 4],
|
||||||
@@ -759,9 +867,14 @@ impl SpanAttrs {
|
|||||||
// reaches paint; the real gradient is selected through metadata.
|
// reaches paint; the real gradient is selected through metadata.
|
||||||
let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
|
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]));
|
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);
|
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
|
a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -773,6 +886,7 @@ struct SpanCtx {
|
|||||||
line_height: f32,
|
line_height: f32,
|
||||||
color: [u8; 4],
|
color: [u8; 4],
|
||||||
weight: u16,
|
weight: u16,
|
||||||
|
variation_weight: Option<u16>,
|
||||||
italic: bool,
|
italic: bool,
|
||||||
underline: bool,
|
underline: bool,
|
||||||
transform: TextTransform,
|
transform: TextTransform,
|
||||||
@@ -800,8 +914,7 @@ struct Collector {
|
|||||||
/// transparent (and invisible), unchanged.
|
/// transparent (and invisible), unchanged.
|
||||||
fn base_span_ctx(
|
fn base_span_ctx(
|
||||||
base: &LayoutStyle,
|
base: &LayoutStyle,
|
||||||
family: Arc<str>,
|
font: ResolvedFont,
|
||||||
weight: u16,
|
|
||||||
collector: &mut Collector,
|
collector: &mut Collector,
|
||||||
) -> SpanCtx {
|
) -> SpanCtx {
|
||||||
let clip_fill = clip_text_fill(base).map(|fill| {
|
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),
|
font_size: base.font_size.unwrap_or(16.0),
|
||||||
line_height: used_line_height(base),
|
line_height: used_line_height(base),
|
||||||
color: base.color.unwrap_or([0, 0, 0, 255]),
|
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),
|
italic: base.font_style_italic.unwrap_or(false),
|
||||||
underline: base.underline.unwrap_or(false),
|
underline: base.underline.unwrap_or(false),
|
||||||
transform: base.text_transform.unwrap_or(TextTransform::None),
|
transform: base.text_transform.unwrap_or(TextTransform::None),
|
||||||
family,
|
family: font.family,
|
||||||
clip_fill,
|
clip_fill,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -864,6 +978,7 @@ fn collect_node_spans(
|
|||||||
font_size: ctx.font_size,
|
font_size: ctx.font_size,
|
||||||
line_height: ctx.line_height,
|
line_height: ctx.line_height,
|
||||||
weight: ctx.weight,
|
weight: ctx.weight,
|
||||||
|
variation_weight: ctx.variation_weight,
|
||||||
italic: ctx.italic,
|
italic: ctx.italic,
|
||||||
underline: ctx.underline,
|
underline: ctx.underline,
|
||||||
color: ctx.color,
|
color: ctx.color,
|
||||||
@@ -883,6 +998,7 @@ fn collect_node_spans(
|
|||||||
font_size: ctx.font_size,
|
font_size: ctx.font_size,
|
||||||
line_height: ctx.line_height,
|
line_height: ctx.line_height,
|
||||||
weight: ctx.weight,
|
weight: ctx.weight,
|
||||||
|
variation_weight: ctx.variation_weight,
|
||||||
italic: ctx.italic,
|
italic: ctx.italic,
|
||||||
underline: ctx.underline,
|
underline: ctx.underline,
|
||||||
color: ctx.color,
|
color: ctx.color,
|
||||||
@@ -907,7 +1023,7 @@ fn collect_node_spans(
|
|||||||
let requested_weight = style
|
let requested_weight = style
|
||||||
.map(crate::style::used_font_weight)
|
.map(crate::style::used_font_weight)
|
||||||
.unwrap_or(ctx.weight);
|
.unwrap_or(ctx.weight);
|
||||||
let (family, weight) = style
|
let font = style
|
||||||
.and_then(|style| style.font_family.as_deref())
|
.and_then(|style| style.font_family.as_deref())
|
||||||
.map(|family| {
|
.map(|family| {
|
||||||
resolve_loaded_font(
|
resolve_loaded_font(
|
||||||
@@ -919,7 +1035,17 @@ fn collect_node_spans(
|
|||||||
loaded_families,
|
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 {
|
let child = SpanCtx {
|
||||||
font_size: style
|
font_size: style
|
||||||
.and_then(|style| style.font_size)
|
.and_then(|style| style.font_size)
|
||||||
@@ -928,13 +1054,14 @@ fn collect_node_spans(
|
|||||||
.map(used_line_height)
|
.map(used_line_height)
|
||||||
.unwrap_or(ctx.line_height),
|
.unwrap_or(ctx.line_height),
|
||||||
color,
|
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),
|
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
|
||||||
// Underline propagates in: an ancestor's underline covers
|
// Underline propagates in: an ancestor's underline covers
|
||||||
// descendant text; an element only sets its own via CSS.
|
// descendant text; an element only sets its own via CSS.
|
||||||
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
|
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
|
||||||
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
|
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
|
||||||
family,
|
family: font.family,
|
||||||
clip_fill,
|
clip_fill,
|
||||||
};
|
};
|
||||||
collect_spans(
|
collect_spans(
|
||||||
@@ -998,8 +1125,17 @@ fn buffer_size(buffer: &Buffer) -> (f32, f32) {
|
|||||||
/// plain inline formatting (no atomic inline boxes, no block children, no
|
/// plain inline formatting (no atomic inline boxes, no block children, no
|
||||||
/// inline element carrying its own box)? Such a container collapses cleanly
|
/// inline element carrying its own box)? Such a container collapses cleanly
|
||||||
/// to one shaped buffer; anything else keeps the general build path.
|
/// 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 {
|
fn is_pure_text_ifc(
|
||||||
let Some(style) = styles.get(&id) else { return false };
|
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
|
// 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
|
// 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.
|
// 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.position.is_none()
|
||||||
&& style.float.is_none()
|
&& style.float.is_none()
|
||||||
&& !style.overflow_hidden
|
&& !style.overflow_hidden
|
||||||
&& style.before_content.is_none()
|
&& style.before_pseudo.is_none()
|
||||||
&& style.after_content.is_none();
|
&& style.after_pseudo.is_none();
|
||||||
if !foldable_inline {
|
if !foldable_inline {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1177,6 +1313,7 @@ impl TextEngine {
|
|||||||
let TextEngine {
|
let TextEngine {
|
||||||
font_system,
|
font_system,
|
||||||
swash,
|
swash,
|
||||||
|
variable_swash,
|
||||||
items,
|
items,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
@@ -1203,7 +1340,7 @@ impl TextEngine {
|
|||||||
let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
|
let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
|
||||||
for g in run.glyphs {
|
for g in run.glyphs {
|
||||||
let underlined = g.metadata & META_UNDERLINE != 0;
|
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) {
|
if let Some(bounds) = fill_bounds.get_mut(fill_index) {
|
||||||
let glyph_bounds = (
|
let glyph_bounds = (
|
||||||
g.x,
|
g.x,
|
||||||
@@ -1259,12 +1396,8 @@ impl TextEngine {
|
|||||||
for glyph in run.glyphs {
|
for glyph in run.glyphs {
|
||||||
let physical = glyph.physical((0.0, 0.0), 1.0);
|
let physical = glyph.physical((0.0, 0.0), 1.0);
|
||||||
let glyph_color = glyph.color_opt.unwrap_or(default);
|
let glyph_color = glyph.color_opt.unwrap_or(default);
|
||||||
let fill_index = (glyph.metadata >> META_FILL_SHIFT).checked_sub(1);
|
let fill_index = metadata_fill(glyph.metadata);
|
||||||
swash.with_pixels(
|
let mut draw_pixel = |x, y, color: Color| {
|
||||||
font_system,
|
|
||||||
physical.cache_key,
|
|
||||||
glyph_color,
|
|
||||||
|x, y, color| {
|
|
||||||
let a = color.a() as u32;
|
let a = color.a() as u32;
|
||||||
if a == 0 {
|
if a == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -1311,8 +1444,23 @@ impl TextEngine {
|
|||||||
let out_b = sb + (dst.blue() as u32 * inv / 255);
|
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)
|
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);
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||||
@@ -1383,7 +1532,6 @@ mod tests {
|
|||||||
max_weight: 400,
|
max_weight: 400,
|
||||||
shape_weight: 400,
|
shape_weight: 400,
|
||||||
italic: false,
|
italic: false,
|
||||||
variable: false,
|
|
||||||
},
|
},
|
||||||
LoadedFace {
|
LoadedFace {
|
||||||
name: Arc::from("Poppins Medium"),
|
name: Arc::from("Poppins Medium"),
|
||||||
@@ -1391,7 +1539,6 @@ mod tests {
|
|||||||
max_weight: 500,
|
max_weight: 500,
|
||||||
shape_weight: 500,
|
shape_weight: 500,
|
||||||
italic: false,
|
italic: false,
|
||||||
variable: false,
|
|
||||||
},
|
},
|
||||||
LoadedFace {
|
LoadedFace {
|
||||||
name: Arc::from("Poppins"),
|
name: Arc::from("Poppins"),
|
||||||
@@ -1399,17 +1546,203 @@ mod tests {
|
|||||||
max_weight: 700,
|
max_weight: 700,
|
||||||
shape_weight: 700,
|
shape_weight: 700,
|
||||||
italic: false,
|
italic: false,
|
||||||
variable: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let loaded = HashMap::from([("poppins".to_string(), family)]);
|
let loaded = HashMap::from([("poppins".to_string(), family)]);
|
||||||
let medium = resolve_loaded_font(Some("Poppins, sans-serif"), 500, false, &loaded);
|
let medium = resolve_loaded_font(Some("Poppins, sans-serif"), 500, false, &loaded);
|
||||||
assert_eq!(medium.0.as_ref(), "Poppins Medium");
|
assert_eq!(medium.family.as_ref(), "Poppins Medium");
|
||||||
assert_eq!(medium.1, 500);
|
assert_eq!(medium.shape_weight, 500);
|
||||||
|
assert_eq!(medium.variation_weight, None);
|
||||||
let semibold = resolve_loaded_font(Some("Poppins"), 600, false, &loaded);
|
let semibold = resolve_loaded_font(Some("Poppins"), 600, false, &loaded);
|
||||||
assert_eq!(semibold.0.as_ref(), "Poppins");
|
assert_eq!(semibold.family.as_ref(), "Poppins");
|
||||||
assert_eq!(semibold.1, 700);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ pub mod style;
|
|||||||
pub use style::compute_style;
|
pub use style::compute_style;
|
||||||
|
|
||||||
pub mod dom;
|
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")]
|
#[cfg(feature = "paint")]
|
||||||
mod paint;
|
mod paint;
|
||||||
#[cfg(feature = "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
|
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
||||||
// layout-only build keeps the lighter word-split geometry. The stub lets
|
// 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)>,
|
pub mask_repeat: Option<(bool, bool)>,
|
||||||
/// Foreground (text) color for the paint step.
|
/// Foreground (text) color for the paint step.
|
||||||
pub color: Option<[u8; 4]>,
|
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]>,
|
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>,
|
pub font_size: Option<f32>,
|
||||||
/// `font-size` given in a font/viewport-relative unit, resolved to
|
/// `font-size` given in a font/viewport-relative unit, resolved to
|
||||||
/// `font_size` (px) during the inheritance pass against the parent and
|
/// `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
|
/// Distinguishes `fixed` from `absolute`; both map to taffy's absolute
|
||||||
/// layout mode, but fixed boxes use the initial containing block.
|
/// layout mode, but fixed boxes use the initial containing block.
|
||||||
pub position_fixed: bool,
|
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
|
pub inset: [Option<Dimension>; 4], // top, right, bottom, left
|
||||||
/// Deferred functional inset expressions in top/right/bottom/left order.
|
/// Deferred functional inset expressions in top/right/bottom/left order.
|
||||||
pub inset_expressions: [Option<String>; 4],
|
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
|
/// clipped box used for skip-links and screen-reader-only labels) actually
|
||||||
/// invisible instead of painting its text wherever it lands.
|
/// invisible instead of painting its text wherever it lands.
|
||||||
pub overflow_hidden: bool,
|
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: 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
|
/// float's shape, which taffy's block/flex/grid modes do not do; see
|
||||||
@@ -445,6 +472,11 @@ pub struct LayoutStyle {
|
|||||||
/// compositing.
|
/// compositing.
|
||||||
pub effectively_invisible: bool,
|
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
|
/// Literal text injected by a `::before`/`::after` rule with a plain
|
||||||
/// string-literal `content` (see `css::Stylesheet::pseudo_content`).
|
/// string-literal `content` (see `css::Stylesheet::pseudo_content`).
|
||||||
/// Rendered as an extra word-run at the start/end of this element's
|
/// Rendered as an extra word-run at the start/end of this element's
|
||||||
@@ -863,12 +895,14 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
|||||||
// Positioning. Absolute/fixed take the box out of flow.
|
// Positioning. Absolute/fixed take the box out of flow.
|
||||||
if let Some(pos) = style.position {
|
if let Some(pos) = style.position {
|
||||||
s.position = pos;
|
s.position = pos;
|
||||||
s.inset = taffy::Rect {
|
if !style.position_sticky {
|
||||||
top: inset_lpa(style.inset[0]),
|
s.inset = taffy::Rect {
|
||||||
right: inset_lpa(style.inset[1]),
|
top: inset_lpa(style.inset[0]),
|
||||||
bottom: inset_lpa(style.inset[2]),
|
right: inset_lpa(style.inset[1]),
|
||||||
left: inset_lpa(style.inset[3]),
|
bottom: inset_lpa(style.inset[2]),
|
||||||
};
|
left: inset_lpa(style.inset[3]),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.margin = rect_auto(style.margin, style.margin_auto);
|
s.margin = rect_auto(style.margin, style.margin_auto);
|
||||||
|
|||||||
+1027
-23
File diff suppressed because it is too large.
Load diff
@@ -237,13 +237,16 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
|
|||||||
|
|
||||||
pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
|
pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
|
||||||
let (normal, important) = partition_declarations(css);
|
let (normal, important) = partition_declarations(css);
|
||||||
apply_declarations(style, &normal);
|
let inherited_scheme = style.color_scheme_dark;
|
||||||
apply_declarations(style, &important);
|
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
|
/// Split a declaration block into normal and `!important` declarations while
|
||||||
/// preserving source order inside each priority. The returned declarations
|
/// 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) {
|
pub(crate) fn partition_declarations(css: &str) -> (String, String) {
|
||||||
let mut normal = String::new();
|
let mut normal = String::new();
|
||||||
let mut important = String::new();
|
let mut important = String::new();
|
||||||
@@ -270,16 +273,72 @@ pub(crate) fn partition_declarations(css: &str) -> (String, String) {
|
|||||||
(normal, important)
|
(normal, important)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply declarations in the order provided. Priority ordering must already
|
/// Apply only `color-scheme` from a declaration list. The stylesheet cascade
|
||||||
/// have been resolved by the caller.
|
/// runs this over every matching block before resolving color-valued
|
||||||
pub(crate) fn apply_declarations(style: &mut LayoutStyle, css: &str) {
|
/// 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) {
|
for raw in split_declarations(css) {
|
||||||
let decl = raw.trim();
|
let decl = raw.trim();
|
||||||
if decl.is_empty() {
|
if decl.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some((name, value)) = decl.split_once(':') else { continue };
|
let Some((name, value)) = decl.split_once(':') else {
|
||||||
apply_value(style, &name.trim().to_ascii_lowercase(), value.trim());
|
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;
|
style.border_color = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for p in value.split_whitespace() {
|
for p in split_ws_paren(value) {
|
||||||
if let Some(c) = parse_color(p) {
|
if let Some(c) =
|
||||||
|
parse_color_for_scheme(p, style.color_scheme_dark)
|
||||||
|
{
|
||||||
style.border_color = Some(c);
|
style.border_color = Some(c);
|
||||||
} else if p.ends_with("px") || p.chars().all(|c| c.is_ascii_digit()) {
|
} else if p.ends_with("px") || p.chars().all(|c| c.is_ascii_digit()) {
|
||||||
if let Some(e) = edges(p) { style.border = e; }
|
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" => {
|
"border-left-width" | "border-left" => {
|
||||||
set_edge(&mut style.border, Side::Left, border_side_width(value))
|
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" => {
|
"background" => {
|
||||||
// A shorthand resets every omitted background longhand to its
|
// A shorthand resets every omitted background longhand to its
|
||||||
// initial value before applying the layers it does name.
|
// 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.
|
// so leave the prior cascade winner untouched in that case.
|
||||||
if !value.trim().is_empty() {
|
if !value.trim().is_empty() {
|
||||||
style.background_color = None;
|
style.background_color = None;
|
||||||
style.background_gradient = parse_linear_gradient(value);
|
style.background_gradient =
|
||||||
style.background_radial_gradient = parse_radial_gradient(value);
|
parse_linear_gradient(value, style.color_scheme_dark);
|
||||||
style.background_conic_gradient = parse_conic_gradient(value);
|
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()
|
if style.background_gradient.is_none()
|
||||||
&& style.background_radial_gradient.is_none()
|
&& style.background_radial_gradient.is_none()
|
||||||
&& style.background_conic_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_image = parse_url(value);
|
||||||
style.background_size = None;
|
style.background_size = None;
|
||||||
@@ -498,9 +566,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"background-image" => {
|
"background-image" => {
|
||||||
style.background_gradient = parse_linear_gradient(value);
|
style.background_gradient =
|
||||||
style.background_radial_gradient = parse_radial_gradient(value);
|
parse_linear_gradient(value, style.color_scheme_dark);
|
||||||
style.background_conic_gradient = parse_conic_gradient(value);
|
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);
|
style.background_image = parse_url(value);
|
||||||
}
|
}
|
||||||
"background-size" => {
|
"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);
|
style.background_size_fit = parse_background_size_fit(value);
|
||||||
}
|
}
|
||||||
"background-position" => style.background_position = parse_background_position(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-image" | "-webkit-mask-image" => style.mask_image = parse_url(value),
|
||||||
"mask-size" | "-webkit-mask-size" => {
|
"mask-size" | "-webkit-mask-size" => {
|
||||||
style.mask_size = parse_background_size(value)
|
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
|
// transparent through this inherited property while clipping a
|
||||||
// background gradient to the text. We model one effective text color,
|
// background gradient to the text. We model one effective text color,
|
||||||
// so the vendor fill color is the paint-time color winner.
|
// so the vendor fill color is the paint-time color winner.
|
||||||
"color" | "-webkit-text-fill-color" => style.color = parse_color(value),
|
"color" | "-webkit-text-fill-color" => {
|
||||||
"border-color" => style.border_color = parse_color(value),
|
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" => {
|
"font-size" => {
|
||||||
// Absolute lengths resolve now; font/viewport-relative ones defer
|
// Absolute lengths resolve now; font/viewport-relative ones defer
|
||||||
// to the inheritance pass (they need parent/root font-size).
|
// 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" => {
|
"absolute" => {
|
||||||
style.position = Some(taffy::Position::Absolute);
|
style.position = Some(taffy::Position::Absolute);
|
||||||
style.position_fixed = false;
|
style.position_fixed = false;
|
||||||
|
style.position_sticky = false;
|
||||||
}
|
}
|
||||||
"fixed" => {
|
"fixed" => {
|
||||||
style.position = Some(taffy::Position::Absolute);
|
style.position = Some(taffy::Position::Absolute);
|
||||||
style.position_fixed = true;
|
style.position_fixed = true;
|
||||||
|
style.position_sticky = false;
|
||||||
}
|
}
|
||||||
"relative" | "sticky" => {
|
"relative" => {
|
||||||
style.position = Some(taffy::Position::Relative);
|
style.position = Some(taffy::Position::Relative);
|
||||||
style.position_fixed = false;
|
style.position_fixed = false;
|
||||||
|
style.position_sticky = false;
|
||||||
|
}
|
||||||
|
"sticky" => {
|
||||||
|
style.position = Some(taffy::Position::Relative);
|
||||||
|
style.position_fixed = false;
|
||||||
|
style.position_sticky = true;
|
||||||
}
|
}
|
||||||
"static" => {
|
"static" => {
|
||||||
style.position = None;
|
style.position = None;
|
||||||
style.position_fixed = false;
|
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" => {
|
"overflow" | "overflow-x" | "overflow-y" => {
|
||||||
style.overflow_hidden = value != "visible";
|
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")),
|
"visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")),
|
||||||
"opacity" => style.opacity = value.trim().parse::<f32>().ok(),
|
"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"),
|
value.trim().eq_ignore_ascii_case("auto"),
|
||||||
),
|
),
|
||||||
"box-shadow" | "-webkit-box-shadow" => {
|
"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) {
|
fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) {
|
||||||
style.animation_name = None;
|
style.animation_name = None;
|
||||||
style.animation_fill_forwards = false;
|
style.animation_fill_forwards = false;
|
||||||
@@ -1509,13 +1870,20 @@ fn set_grid_placement_side(
|
|||||||
} else {
|
} else {
|
||||||
&mut style.grid_row_raw
|
&mut style.grid_row_raw
|
||||||
};
|
};
|
||||||
let (mut start, mut end) = raw_slot
|
let (mut start, mut end) = match raw_slot.as_deref() {
|
||||||
.as_deref()
|
Some(raw) if raw.contains('/') => {
|
||||||
.and_then(|raw| raw.split_once('/'))
|
let (start, end) = raw.split_once('/').unwrap();
|
||||||
.map(|(start, end)| {
|
|
||||||
(start.trim().to_string(), end.trim().to_string())
|
(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 {
|
if is_start {
|
||||||
start = value.trim().to_string();
|
start = value.trim().to_string();
|
||||||
} else {
|
} 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
|
/// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named
|
||||||
/// colors. Returns None for anything else (transparent).
|
/// colors. Returns None for anything else (transparent).
|
||||||
pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
|
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();
|
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
|
// CSS custom property with a fallback: var(--name, <fallback>). We cannot
|
||||||
// resolve the variable, but the fallback after the comma is a real color.
|
// resolve the variable, but the fallback after the comma is a real color.
|
||||||
if let Some(rest) = raw.strip_prefix("var(") {
|
if let Some(rest) = raw.strip_prefix("var(") {
|
||||||
let inner = rest.strip_suffix(')').unwrap_or(rest);
|
let inner = rest.strip_suffix(')').unwrap_or(rest);
|
||||||
if let Some((_, fallback)) = inner.split_once(',') {
|
if let Some((_, fallback)) = inner.split_once(',') {
|
||||||
return parse_color(fallback.trim());
|
return parse_color_for_scheme(fallback.trim(), dark_scheme);
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// rgb()/rgba() functional notation.
|
// rgb()/rgba() functional notation.
|
||||||
let lower_full = raw.to_ascii_lowercase();
|
if let Some(rest) = lower_full
|
||||||
if let Some(rest) = lower_full.strip_prefix("rgb(").or_else(|| lower_full.strip_prefix("rgba(")) {
|
.strip_prefix("rgb(")
|
||||||
|
.or_else(|| lower_full.strip_prefix("rgba("))
|
||||||
|
{
|
||||||
let inner = rest.strip_suffix(')').unwrap_or(rest);
|
let inner = rest.strip_suffix(')').unwrap_or(rest);
|
||||||
let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect();
|
let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect();
|
||||||
if parts.len() >= 3 {
|
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) {
|
if let Some(idx) = s.rfind(char::is_whitespace) {
|
||||||
let tail = s[idx + 1..].trim();
|
let tail = s[idx + 1..].trim();
|
||||||
if let Some(p) = tail.strip_suffix('%').and_then(|x| x.parse::<f32>().ok()) {
|
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])) {
|
if let (Some((c1, p1)), Some((c2, p2))) = (parse_arg(args[1]), parse_arg(args[2])) {
|
||||||
let (w1, w2) = match (p1, p2) {
|
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 remaining common CSS named colors (the hot ones from real sites) beyond
|
||||||
/// the handful spelled out above.
|
/// the handful spelled out above.
|
||||||
fn named_color(v: &str) -> Option<[u8; 4]> {
|
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])
|
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 `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.
|
/// 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.
|
/// (oklch is converted to oklab by the caller.) Standard Björn Ottosson matrix.
|
||||||
@@ -2166,10 +2628,19 @@ fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option<f32> {
|
|||||||
current.push(character);
|
current.push(character);
|
||||||
}
|
}
|
||||||
'+' | '-' if depth == 0 => {
|
'+' | '-' if depth == 0 => {
|
||||||
if !current.trim().is_empty() {
|
let follows_product_operator = current
|
||||||
terms.push((sign, std::mem::take(&mut current)));
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.find(|character| !character.is_whitespace())
|
||||||
|
.is_some_and(|character| matches!(character, '*' | '/'));
|
||||||
|
if follows_product_operator {
|
||||||
|
current.push(character);
|
||||||
|
} else {
|
||||||
|
if !current.trim().is_empty() {
|
||||||
|
terms.push((sign, std::mem::take(&mut current)));
|
||||||
|
}
|
||||||
|
sign = if character == '-' { -1.0 } else { 1.0 };
|
||||||
}
|
}
|
||||||
sign = if character == '-' { -1.0 } else { 1.0 };
|
|
||||||
}
|
}
|
||||||
_ => current.push(character),
|
_ => current.push(character),
|
||||||
}
|
}
|
||||||
@@ -2352,10 +2823,19 @@ fn eval_calc(expr: &str) -> Option<f32> {
|
|||||||
'(' => { depth += 1; cur.push(c); }
|
'(' => { depth += 1; cur.push(c); }
|
||||||
')' => { depth -= 1; cur.push(c); }
|
')' => { depth -= 1; cur.push(c); }
|
||||||
'+' | '-' if depth == 0 => {
|
'+' | '-' if depth == 0 => {
|
||||||
if !cur.trim().is_empty() {
|
let follows_product_operator = cur
|
||||||
terms.push((sign, std::mem::take(&mut cur)));
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.find(|character| !character.is_whitespace())
|
||||||
|
.is_some_and(|character| matches!(character, '*' | '/'));
|
||||||
|
if follows_product_operator {
|
||||||
|
cur.push(c);
|
||||||
|
} else {
|
||||||
|
if !cur.trim().is_empty() {
|
||||||
|
terms.push((sign, std::mem::take(&mut cur)));
|
||||||
|
}
|
||||||
|
sign = if c == '-' { -1.0 } else { 1.0 };
|
||||||
}
|
}
|
||||||
sign = if c == '-' { -1.0 } else { 1.0 };
|
|
||||||
}
|
}
|
||||||
_ => cur.push(c),
|
_ => 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
|
/// 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
|
/// 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).
|
/// 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 v = value.trim();
|
||||||
let lower = v.to_ascii_lowercase();
|
let lower = v.to_ascii_lowercase();
|
||||||
let start = lower.find("linear-gradient(")?;
|
let start = lower.find("linear-gradient(")?;
|
||||||
@@ -2997,7 +3480,7 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)
|
|||||||
} else {
|
} else {
|
||||||
(t, None)
|
(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));
|
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(
|
fn parse_radial_gradient(
|
||||||
value: &str,
|
value: &str,
|
||||||
|
dark_scheme: bool,
|
||||||
) -> Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)> {
|
) -> Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)> {
|
||||||
let lower = value.to_ascii_lowercase();
|
let lower = value.to_ascii_lowercase();
|
||||||
let start = lower.find("radial-gradient(")?;
|
let start = lower.find("radial-gradient(")?;
|
||||||
@@ -3037,13 +3521,13 @@ fn parse_radial_gradient(
|
|||||||
center.1 = y;
|
center.1 = y;
|
||||||
}
|
}
|
||||||
stop_start = 1;
|
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;
|
stop_start = 1;
|
||||||
}
|
}
|
||||||
let mut stops = Vec::new();
|
let mut stops = Vec::new();
|
||||||
for part in &parts[stop_start..] {
|
for part in &parts[stop_start..] {
|
||||||
let (color, position) = split_color_stop(part.trim());
|
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));
|
stops.push((color, position));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3055,6 +3539,7 @@ fn parse_radial_gradient(
|
|||||||
/// is retained as box-relative fractions for paint-time resolution.
|
/// is retained as box-relative fractions for paint-time resolution.
|
||||||
fn parse_conic_gradient(
|
fn parse_conic_gradient(
|
||||||
value: &str,
|
value: &str,
|
||||||
|
dark_scheme: bool,
|
||||||
) -> Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)> {
|
) -> Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)> {
|
||||||
let v = value.trim();
|
let v = value.trim();
|
||||||
let lower = v.to_ascii_lowercase();
|
let lower = v.to_ascii_lowercase();
|
||||||
@@ -3106,7 +3591,7 @@ fn parse_conic_gradient(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (color, position) = split_color_stop(part);
|
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));
|
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
|
/// 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`
|
/// multiples are accepted but only the first layer is stored. `current_color`
|
||||||
/// supplies the default when the color is omitted (CSS `currentColor`).
|
/// 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();
|
let v = value.trim();
|
||||||
if v.is_empty() || v.eq_ignore_ascii_case("none") {
|
if v.is_empty() || v.eq_ignore_ascii_case("none") {
|
||||||
return None;
|
return None;
|
||||||
@@ -3411,7 +3900,7 @@ fn parse_box_shadow(value: &str, current_color: Option<[u8; 4]>) -> Option<crate
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(c) = parse_color(t) {
|
if let Some(c) = parse_color_for_scheme(t, dark_scheme) {
|
||||||
color = Some(c);
|
color = Some(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3835,6 +4324,17 @@ mod tests {
|
|||||||
),
|
),
|
||||||
Some(122.0)
|
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!(
|
assert_eq!(
|
||||||
resolve_contextual_length(
|
resolve_contextual_length(
|
||||||
"calc(round(247px * 1, 10px))",
|
"calc(round(247px * 1, 10px))",
|
||||||
@@ -3896,6 +4396,58 @@ mod tests {
|
|||||||
assert!(l.background_clip_text);
|
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]
|
#[test]
|
||||||
fn background_shorthand_resets_omitted_layers() {
|
fn background_shorthand_resets_omitted_layers() {
|
||||||
let s = compute_style(
|
let s = compute_style(
|
||||||
@@ -3998,6 +4550,9 @@ mod tests {
|
|||||||
// The exact shape MediaWiki uses to offset a TOC toggle button into
|
// The exact shape MediaWiki uses to offset a TOC toggle button into
|
||||||
// the left margin: a negative product divided by a constant.
|
// the left margin: a negative product divided by a constant.
|
||||||
assert_eq!(resolve_length("calc(-1 * 22px / 2)"), Some(-11.0));
|
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]
|
#[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=
|
||||||
@@ -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]
|
#[test]
|
||||||
fn opposing_floats_share_header_band_through_inline_wrapper() {
|
fn opposing_floats_share_header_band_through_inline_wrapper() {
|
||||||
let tree = parse_html(include_str!("../../../render-repros/opposing-header-floats.html"));
|
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:?}"
|
"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
@@ -1399,15 +1399,25 @@ fn distribute_space_up_to_limits(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|track| track_affected_property(track) + track.item_incurred_increase < track_limit(track))
|
.filter(|track| track_affected_property(track) + track.item_incurred_increase < track_limit(track))
|
||||||
.filter(|track| track_is_affected(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))
|
.min_by(|a, b| a.total_cmp(b))
|
||||||
.unwrap(); // We will never pass an empty track list to this function
|
.unwrap(); // We will never pass an empty track list to this function
|
||||||
let iteration_item_incurred_increase =
|
let iteration_item_incurred_increase =
|
||||||
f32_min(min_increase_limit, space_to_distribute / track_distribution_proportion_sum);
|
f32_min(min_increase_limit, space_to_distribute / track_distribution_proportion_sum);
|
||||||
|
|
||||||
for track in tracks.iter_mut().filter(|track| track_is_affected(track)) {
|
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);
|
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;
|
track.item_incurred_increase += increase;
|
||||||
space_to_distribute -= increase;
|
space_to_distribute -= increase;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user