render: unify responsive geometry and inline gradients

This commit is contained in:
SGavrl committed 2026-07-26 11:51:44 +00:00
1 parent 29d0bce3f5
commit 3618549842
16 files changed
+582 -97

No files matched your search

+22
View File
@@ -171,6 +171,9 @@ pub struct Page {
pub http_client: Arc<ObscuraHttpClient>, pub http_client: Arc<ObscuraHttpClient>,
pub context: Arc<BrowserContext>, pub context: Arc<BrowserContext>,
pub title: String, pub title: String,
/// CSS viewport used by responsive page JavaScript and CDP screenshots.
/// The physical `screen` fingerprint remains independent.
pub viewport: (f32, f32),
/// WHATWG canonical name of the current document's character encoding /// WHATWG canonical name of the current document's character encoding
/// (e.g. "UTF-8", "EUC-JP"), detected when the response body is decoded. /// (e.g. "UTF-8", "EUC-JP"), detected when the response body is decoded.
/// Exposed to JS as `document.characterSet` and used for the URL query /// Exposed to JS as `document.characterSet` and used for the URL query
@@ -468,6 +471,7 @@ impl Page {
http_client, http_client,
context, context,
title: String::new(), title: String::new(),
viewport: (1280.0, 720.0),
encoding: "UTF-8".to_string(), encoding: "UTF-8".to_string(),
history: Vec::new(), history: Vec::new(),
history_index: 0, history_index: 0,
@@ -502,6 +506,23 @@ impl Page {
false false
} }
/// Update the page's CSS viewport. Calling this before navigation makes
/// responsive scripts observe it from their first instruction; calling it
/// on a live page mirrors CDP's device-metrics override surfaces.
pub fn set_viewport(&mut self, viewport: (f32, f32)) {
if !viewport.0.is_finite()
|| !viewport.1.is_finite()
|| viewport.0 <= 0.0
|| viewport.1 <= 0.0
{
return;
}
self.viewport = viewport;
if let Some(js) = &mut self.js {
js.set_viewport(viewport.0 as f64, viewport.1 as f64);
}
}
async fn do_fetch(&self, url: &Url) -> Result<Response, ObscuraNetError> { async fn do_fetch(&self, url: &Url) -> Result<Response, ObscuraNetError> {
#[cfg(feature = "stealth")] #[cfg(feature = "stealth")]
if let Some(ref stealth) = self.stealth_client { if let Some(ref stealth) = self.stealth_client {
@@ -568,6 +589,7 @@ impl Page {
if let Some((lat, lon)) = env_geolocation() { if let Some((lat, lon)) = env_geolocation() {
rt.set_geolocation(lat, lon); rt.set_geolocation(lat, lon);
} }
rt.set_viewport(self.viewport.0 as f64, self.viewport.1 as f64);
rt.set_cookie_jar(self.context.cookie_jar.clone()); rt.set_cookie_jar(self.context.cookie_jar.clone());
rt.set_http_client(self.http_client.clone()); rt.set_http_client(self.http_client.clone());
+2 -1
View File
@@ -387,13 +387,14 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
"Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await, "Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await,
"IO" => domains::io::handle(method, &req.params, ctx).await, "IO" => domains::io::handle(method, &req.params, ctx).await,
"Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await, "Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await,
"Emulation" => domains::emulation::handle(method, &req.params, ctx, &req.session_id).await,
"Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await, "Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await,
"LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await, "LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await,
"Accessibility" => domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await, "Accessibility" => domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await,
// Accepted but no-op. Puppeteer's FrameManager.initialize calls // Accepted but no-op. Puppeteer's FrameManager.initialize calls
// Audits.enable on connect — refusing it breaks puppeteer.connect() // Audits.enable on connect — refusing it breaks puppeteer.connect()
// before any user code runs. // before any user code runs.
"Emulation" | "Log" | "Performance" | "Security" | "CSS" "Log" | "Performance" | "Security" | "CSS"
| "ServiceWorker" | "Inspector" | "ServiceWorker" | "Inspector"
| "Debugger" | "Profiler" | "HeapProfiler" | "Overlay" | "Debugger" | "Profiler" | "HeapProfiler" | "Overlay"
| "Audits" => { | "Audits" => {
@@ -0,0 +1,98 @@
use serde_json::{json, Value};
use crate::dispatch::CdpContext;
pub async fn handle(
method: &str,
params: &Value,
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<Value, String> {
match method {
"setDeviceMetricsOverride" => {
let width = params
.get("width")
.and_then(Value::as_f64)
.ok_or("Emulation.setDeviceMetricsOverride requires width")?;
let height = params
.get("height")
.and_then(Value::as_f64)
.ok_or("Emulation.setDeviceMetricsOverride requires height")?;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return Err(
"Emulation.setDeviceMetricsOverride requires positive finite dimensions"
.to_string(),
);
}
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
page.set_viewport((width as f32, height as f32));
Ok(json!({}))
}
"clearDeviceMetricsOverride" => {
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
page.set_viewport((1280.0, 720.0));
Ok(json!({}))
}
// Touch emulation does not affect layout yet, but acknowledging it is
// compatible with clients that pair it with a metrics override.
"setTouchEmulationEnabled" => Ok(json!({})),
_ => Ok(json!({})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn device_metrics_override_updates_page_and_window_viewport() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("viewport-session".to_string());
ctx.sessions
.insert(session_id.clone().unwrap(), page_id);
handle(
"setDeviceMetricsOverride",
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 1}),
&mut ctx,
&session_id,
)
.await
.expect("metrics override");
let page = ctx
.get_session_page_mut(&session_id)
.expect("page for session");
assert_eq!(page.viewport, (1024.0, 768.0));
assert_eq!(
page.evaluate(
"return [innerWidth, innerHeight, visualViewport.width,\
visualViewport.height];"
),
json!([1024, 768, 1024, 768])
);
}
#[tokio::test]
async fn device_metrics_override_rejects_zero_dimensions() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("viewport-session".to_string());
ctx.sessions
.insert(session_id.clone().unwrap(), page_id);
let error = handle(
"setDeviceMetricsOverride",
&json!({"width": 0, "height": 768}),
&mut ctx,
&session_id,
)
.await
.expect_err("zero width must fail");
assert!(error.contains("positive finite dimensions"));
}
}
+1
View File
@@ -11,3 +11,4 @@ pub mod input;
pub mod storage; pub mod storage;
pub mod accessibility; pub mod accessibility;
pub mod lp; pub mod lp;
pub mod emulation;
+7 -8
View File
@@ -400,13 +400,12 @@ pub async fn handle(
// configure; ack it so clients that set it do not warn (issue #340). // configure; ack it so clients that set it do not warn (issue #340).
"setDownloadBehavior" => Ok(json!({})), "setDownloadBehavior" => Ok(json!({})),
"getLayoutMetrics" => { "getLayoutMetrics" => {
// Obscura has no visual layout engine, so we return a fixed // Playwright calls this before every page.screenshot(). Report the
// 1280x720 viewport (Chrome's default) and try to derive the // same live CSS viewport that responsive page code and paint use.
// content height from document.documentElement.scrollHeight. let (width, height) = ctx
// Playwright calls this before every page.screenshot() and .get_session_page(session_id)
// would otherwise fail with "Unknown Page method". .map(|page| (page.viewport.0 as f64, page.viewport.1 as f64))
let width = 1280.0_f64; .unwrap_or((1280.0, 720.0));
let height = 720.0_f64;
let content_height = ctx let content_height = ctx
.get_session_page_mut(session_id) .get_session_page_mut(session_id)
.map(|p| p.evaluate("document.documentElement && document.documentElement.scrollHeight")) .map(|p| p.evaluate("document.documentElement && document.documentElement.scrollHeight"))
@@ -528,7 +527,7 @@ pub async fn handle(
#[cfg(feature = "render")] #[cfg(feature = "render")]
{ {
let page = ctx.get_session_page(session_id).ok_or("No page for session")?; let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
match page.screenshot((1280.0, 720.0)) { match page.screenshot(page.viewport) {
Some(png) => { Some(png) => {
use base64::Engine as _; use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(&png); let data = base64::engine::general_purpose::STANDARD.encode(&png);
+1 -1
View File
@@ -1227,7 +1227,7 @@ fn fast_path_response(text: &str) -> Option<String> {
"Page.enable" | "Page.setLifecycleEventsEnabled" | "Page.setInterceptFileChooserDialog" | "Page.enable" | "Page.setLifecycleEventsEnabled" | "Page.setInterceptFileChooserDialog" |
"Runtime.runIfWaitingForDebugger" | "Runtime.discardConsoleEntries" | "Runtime.runIfWaitingForDebugger" | "Runtime.discardConsoleEntries" |
"Performance.enable" | "Log.enable" | "Security.enable" | "Performance.enable" | "Log.enable" | "Security.enable" |
"Emulation.setDeviceMetricsOverride" | "Emulation.setTouchEmulationEnabled" | "Emulation.setTouchEmulationEnabled" |
"CSS.enable" | "Accessibility.enable" | "ServiceWorker.enable" | "CSS.enable" | "Accessibility.enable" | "ServiceWorker.enable" |
"Inspector.enable" | "Debugger.enable" | "Profiler.enable" | "Inspector.enable" | "Debugger.enable" | "Profiler.enable" |
"HeapProfiler.enable" | "Overlay.enable" | "Storage.enable" | "HeapProfiler.enable" | "Overlay.enable" | "Storage.enable" |
+20 -3
View File
@@ -607,6 +607,24 @@ async fn run_fetch(
allow_private_network, allow_private_network,
)); ));
let mut page = Page::new("fetch-page".to_string(), context.clone()); let mut page = Page::new("fetch-page".to_string(), context.clone());
// A screenshot viewport is also the navigation viewport: responsive
// frameworks must build the DOM for the same dimensions we later paint.
// Previously page JS saw a randomized screen-sized innerWidth while the
// screenshot used these values only at the final raster step.
let screenshot_viewport = screenshot.as_ref().map(|_| {
let width = std::env::var("OBSCURA_SHOT_W")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(1280.0);
let height = std::env::var("OBSCURA_SHOT_H")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(720.0);
(width, height)
});
if let Some(viewport) = screenshot_viewport {
page.set_viewport(viewport);
}
if let Some(ref ua) = user_agent { if let Some(ref ua) = user_agent {
page.http_client.set_user_agent(ua).await; page.http_client.set_user_agent(ua).await;
@@ -661,9 +679,8 @@ async fn run_fetch(
// 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).
let shot_w = std::env::var("OBSCURA_SHOT_W").ok().and_then(|v| v.parse::<f32>().ok()).unwrap_or(1280.0); let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0));
let shot_h = std::env::var("OBSCURA_SHOT_H").ok().and_then(|v| v.parse::<f32>().ok()).unwrap_or(720.0); match page.screenshot(viewport) {
match page.screenshot((shot_w, shot_h)) {
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"),
} }
+10 -2
View File
@@ -8907,10 +8907,18 @@ globalThis.__obscura_init = function() {
const scr = _fp('screen'); const scr = _fp('screen');
const sw = scr[0], sh = scr[1]; const sw = scr[0], sh = scr[1];
// The OS screen and the page viewport are different browser concepts.
// Keep the fingerprinted screen, but let the embedding browser provide the
// actual CSS viewport so responsive JavaScript, layout, and screenshots all
// observe the same dimensions.
const vw = Number.isFinite(globalThis.__obscura_viewport_w) && globalThis.__obscura_viewport_w > 0
? globalThis.__obscura_viewport_w : sw;
const vh = Number.isFinite(globalThis.__obscura_viewport_h) && globalThis.__obscura_viewport_h > 0
? globalThis.__obscura_viewport_h : sh - 80;
globalThis.screen = new Screen(sw, sh); globalThis.screen = new Screen(sw, sh);
globalThis.visualViewport = { width:sw, height:sh-80, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; globalThis.visualViewport = { width:vw, height:vh, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1; globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1;
globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80; globalThis.innerWidth = vw; globalThis.innerHeight = vh;
globalThis.outerWidth = sw; globalThis.outerHeight = sh - 40; globalThis.outerWidth = sw; globalThis.outerHeight = sh - 40;
var hwValues = globalThis.__obscura_stealth ? [4, 6, 8, 12, 16] : [2, 4, 6, 8, 12, 16]; var hwValues = globalThis.__obscura_stealth ? [4, 6, 8, 12, 16] : [2, 4, 6, 8, 12, 16];
+40
View File
@@ -278,6 +278,27 @@ impl ObscuraJsRuntime {
); );
} }
/// Set the CSS viewport exposed to page JavaScript. This must run before
/// `run_page_init` for navigation-time responsive code; it may also be
/// called later by CDP emulation to update the live window surfaces.
pub fn set_viewport(&mut self, width: f64, height: f64) {
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return;
}
let _ = self.runtime.execute_script(
"<set-viewport>",
format!(
"globalThis.__obscura_viewport_w={width};\
globalThis.__obscura_viewport_h={height};\
globalThis.innerWidth={width};globalThis.innerHeight={height};\
if(globalThis.visualViewport){{\
globalThis.visualViewport.width={width};\
globalThis.visualViewport.height={height};\
}}",
),
);
}
/// 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) {
@@ -1788,6 +1809,25 @@ mod tests {
assert_eq!(title, serde_json::json!("Test Page")); assert_eq!(title, serde_json::json!("Test Page"));
} }
#[test]
fn explicit_viewport_is_distinct_from_fingerprinted_screen() {
let dom = parse_html("<html><body></body></html>");
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(1024.0, 768.0);
rt.run_page_init();
let result = rt
.evaluate(
"return [innerWidth, innerHeight, visualViewport.width,\
visualViewport.height, screen.width > 0, screen.height > 0];",
)
.unwrap();
assert_eq!(
result,
serde_json::json!([1024, 768, 1024, 768, true, 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>");
+146 -20
View File
@@ -45,6 +45,18 @@ impl Stylesheet {
/// Parse and index a set of raw CSS sources (the text of each `<style>` /// Parse and index a set of raw CSS sources (the text of each `<style>`
/// block, in document order). Selectors that fail to parse are dropped. /// block, in document order). Selectors that fail to parse are dropped.
pub fn parse(tree: &DomTree, sources: &[String]) -> Self { pub fn parse(tree: &DomTree, sources: &[String]) -> Self {
Self::parse_for_viewport(tree, sources, (1280.0, 720.0))
}
/// Parse author CSS for the live CSS viewport. Media queries must use the
/// same dimensions as layout and page JavaScript; filtering them against a
/// fixed desktop width made responsive frameworks build one DOM while the
/// renderer applied another breakpoint.
pub fn parse_for_viewport(
tree: &DomTree,
sources: &[String],
viewport: (f32, f32),
) -> Self {
let mut sheet = Stylesheet { let mut sheet = Stylesheet {
rules: Vec::new(), rules: Vec::new(),
by_id: HashMap::new(), by_id: HashMap::new(),
@@ -56,7 +68,7 @@ impl Stylesheet {
}; };
let mut order = 0usize; let mut order = 0usize;
for src in sources { for src in sources {
for (selector, decls) in parse_stylesheet(src) { for (selector, decls) in parse_stylesheet_for_viewport(src, viewport) {
let sel_trim = selector.trim(); let sel_trim = selector.trim();
if let Some(base) = strip_pseudo_element(sel_trim, "before") { if let Some(base) = strip_pseudo_element(sel_trim, "before") {
if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) { if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) {
@@ -359,6 +371,13 @@ fn unescape_css_string(s: &str) -> String {
/// (`@media`, `@supports`, `@layer`); other at-rules (`@font-face`, `@keyframes`, ...) are /// (`@media`, `@supports`, `@layer`); other at-rules (`@font-face`, `@keyframes`, ...) are
/// skipped since they do not contribute layout-relevant declarations here. /// skipped since they do not contribute layout-relevant declarations here.
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> { pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
parse_stylesheet_for_viewport(css, (1280.0, 720.0))
}
fn parse_stylesheet_for_viewport(
css: &str,
viewport: (f32, f32),
) -> Vec<(String, String)> {
let mut rules = Vec::new(); let mut rules = Vec::new();
let mut current_selector = String::new(); let mut current_selector = String::new();
let mut current_decls = String::new(); let mut current_decls = String::new();
@@ -397,13 +416,13 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
let sel = current_selector.trim(); let sel = current_selector.trim();
let decls = current_decls.trim(); let decls = current_decls.trim();
if let Some(at) = sel.strip_prefix('@') { if let Some(at) = sel.strip_prefix('@') {
flush_at_rule(at, sel, decls, &mut rules); flush_at_rule(at, sel, decls, &mut rules, viewport);
} else { } else {
// The body may contain nested rules (CSS Nesting, ubiquitous // The body may contain nested rules (CSS Nesting, ubiquitous
// in Tailwind v4 / modern frameworks: `.a{ &:hover{} .b{} }`). // in Tailwind v4 / modern frameworks: `.a{ &:hover{} .b{} }`).
// Flatten them against this selector; denest also handles the // Flatten them against this selector; denest also handles the
// no-nesting case (just emits the rule's own declarations). // no-nesting case (just emits the rule's own declarations).
denest(sel, decls, &mut rules); denest(sel, decls, &mut rules, viewport);
} }
current_selector.clear(); current_selector.clear();
current_decls.clear(); current_decls.clear();
@@ -427,13 +446,19 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
/// Handle the at-rules whose bodies contain ordinary rules. For `@media`, apply /// Handle the at-rules whose bodies contain ordinary rules. For `@media`, apply
/// the inner rules only when the query holds for a desktop 1280px viewport. /// the inner rules only when the query holds for a desktop 1280px viewport.
fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, String)>) { fn flush_at_rule(
at: &str,
sel: &str,
inner: &str,
rules: &mut Vec<(String, String)>,
viewport: (f32, f32),
) {
if at.starts_with("media") { if at.starts_with("media") {
if media_query_applies(sel) { if media_query_applies_for_viewport(sel, viewport) {
rules.extend(parse_stylesheet(inner)); rules.extend(parse_stylesheet_for_viewport(inner, viewport));
} }
} else if at.starts_with("supports") { } else if at.starts_with("supports") {
rules.extend(parse_stylesheet(inner)); 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`
@@ -460,7 +485,7 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
// left whole pages unstyled (white backgrounds, collapsed layout). The // left whole pages unstyled (white backgrounds, collapsed layout). The
// `@layer a, b;` ordering-statement form has no block and is discarded // `@layer a, b;` ordering-statement form has no block and is discarded
// by parse_stylesheet's top-level `;` handling. // by parse_stylesheet's top-level `;` handling.
rules.extend(parse_stylesheet(inner)); rules.extend(parse_stylesheet_for_viewport(inner, viewport));
} }
// Other at-rules (@font-face, @keyframes, @import, ...) carry no // Other at-rules (@font-face, @keyframes, @import, ...) carry no
// layout-relevant rules for us, so drop them. // layout-relevant rules for us, so drop them.
@@ -474,7 +499,15 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
/// inside `(feature: value)`, so it's safe to discard them wholesale rather /// inside `(feature: value)`, so it's safe to discard them wholesale rather
/// than special-case every formatting variant a site might use. /// than special-case every formatting variant a site might use.
pub(crate) fn media_query_applies(query: &str) -> bool { pub(crate) fn media_query_applies(query: &str) -> bool {
const VIEWPORT_W: f32 = 1280.0; media_query_applies_for_viewport(query, (1280.0, 720.0))
}
pub(crate) fn media_query_applies_for_viewport(
query: &str,
viewport: (f32, f32),
) -> bool {
let viewport_w = viewport.0;
let viewport_h = viewport.1;
if query.contains("print") { if query.contains("print") {
return false; return false;
} }
@@ -508,35 +541,72 @@ pub(crate) fn media_query_applies(query: &str) -> bool {
// Width constraints, both `min-width:`/`max-width:` and the modern range // Width constraints, both `min-width:`/`max-width:` and the modern range
// forms `width>=Npx` / `(Npx<=width)`. // forms `width>=Npx` / `(Npx<=width)`.
if let Some(px) = extract_px(&compact, "max-width:") { if let Some(px) = extract_px(&compact, "max-width:") {
if VIEWPORT_W > px { if viewport_w > px {
return false; return false;
} }
} }
if let Some(px) = extract_px(&compact, "min-width:") { if let Some(px) = extract_px(&compact, "min-width:") {
if VIEWPORT_W < px { if viewport_w < px {
return false; return false;
} }
} }
if let Some(px) = extract_px(&compact, "width<=") { if let Some(px) = extract_px(&compact, "width<=") {
if VIEWPORT_W > px { if viewport_w > px {
return false; return false;
} }
} }
if let Some(px) = extract_px(&compact, "width>=") { if let Some(px) = extract_px(&compact, "width>=") {
if VIEWPORT_W < px { if viewport_w < px {
return false; return false;
} }
} }
if let Some(px) = extract_px(&compact, "width>") { if let Some(px) = extract_px(&compact, "width>") {
if VIEWPORT_W <= px { if viewport_w <= px {
return false; return false;
} }
} }
if let Some(px) = extract_px(&compact, "width<") { if let Some(px) = extract_px(&compact, "width<") {
if VIEWPORT_W >= px { if viewport_w >= px {
return false; return false;
} }
} }
if let Some(px) = extract_px(&compact, "max-height:") {
if viewport_h > px {
return false;
}
}
if let Some(px) = extract_px(&compact, "min-height:") {
if viewport_h < px {
return false;
}
}
if let Some(px) = extract_px(&compact, "height<=") {
if viewport_h > px {
return false;
}
}
if let Some(px) = extract_px(&compact, "height>=") {
if viewport_h < px {
return false;
}
}
if let Some(px) = extract_px(&compact, "height>") {
if viewport_h <= px {
return false;
}
}
if let Some(px) = extract_px(&compact, "height<") {
if viewport_h >= px {
return false;
}
}
if compact.contains("orientation:portrait") && viewport_w > viewport_h {
return false;
}
if compact.contains("orientation:landscape") && viewport_h > viewport_w {
return false;
}
true true
} }
@@ -573,7 +643,12 @@ fn combine_selectors(parent: &str, child: &str) -> String {
/// selector), emit `(sel, own-declarations)`, and recurse into each nested rule /// selector), emit `(sel, own-declarations)`, and recurse into each nested rule
/// with the combined selector. Without this, Tailwind v4 / modern-framework CSS /// with the combined selector. Without this, Tailwind v4 / modern-framework CSS
/// (which nests almost everything) loses the nested utility rules entirely. /// (which nests almost everything) loses the nested utility rules entirely.
fn denest(sel: &str, body: &str, rules: &mut Vec<(String, String)>) { fn denest(
sel: &str,
body: &str,
rules: &mut Vec<(String, String)>,
viewport: (f32, f32),
) {
let chars: Vec<char> = body.chars().collect(); let chars: Vec<char> = body.chars().collect();
let n = chars.len(); let n = chars.len();
let mut i = 0; let mut i = 0;
@@ -645,15 +720,15 @@ fn denest(sel: &str, body: &str, rules: &mut Vec<(String, String)>) {
if let Some(at) = pre.strip_prefix('@') { if let Some(at) = pre.strip_prefix('@') {
// A nested at-rule keeps the enclosing selector for its body. // A nested at-rule keeps the enclosing selector for its body.
if at.starts_with("media") { if at.starts_with("media") {
if media_query_applies(pre) { if media_query_applies_for_viewport(pre, viewport) {
denest(sel, &inner, rules); denest(sel, &inner, rules, viewport);
} }
} else if at.starts_with("supports") || at.starts_with("layer") { } else if at.starts_with("supports") || at.starts_with("layer") {
denest(sel, &inner, rules); denest(sel, &inner, rules, viewport);
} }
} else if !pre.is_empty() { } else if !pre.is_empty() {
let full = combine_selectors(sel, pre); let full = combine_selectors(sel, pre);
denest(&full, &inner, rules); denest(&full, &inner, rules, viewport);
} }
i = j; i = j;
seg = i; seg = i;
@@ -785,3 +860,54 @@ fn eval_px_sum(expr: &str) -> f32 {
flush(&mut num, sign, &mut total); flush(&mut num, sign, &mut total);
total total
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn media_rules_use_the_live_width_and_height() {
let css = r#"
.base { color: black; }
@media (max-width: 950px) { .narrow { color: green; } }
@media (min-height: 900px) { .tall { color: blue; } }
@media (orientation: portrait) { .portrait { color: red; } }
"#;
let selectors = |viewport| {
parse_stylesheet_for_viewport(css, viewport)
.into_iter()
.map(|(selector, _)| selector)
.collect::<Vec<_>>()
};
let narrow_tall = selectors((900.0, 1000.0));
assert!(narrow_tall.iter().any(|s| s == ".base"));
assert!(narrow_tall.iter().any(|s| s == ".narrow"));
assert!(narrow_tall.iter().any(|s| s == ".tall"));
assert!(narrow_tall.iter().any(|s| s == ".portrait"));
let wide_short = selectors((1280.0, 720.0));
assert!(wide_short.iter().any(|s| s == ".base"));
assert!(!wide_short.iter().any(|s| s == ".narrow"));
assert!(!wide_short.iter().any(|s| s == ".tall"));
assert!(!wide_short.iter().any(|s| s == ".portrait"));
}
#[test]
fn nested_media_rules_use_the_live_viewport() {
let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }";
let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0));
let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0));
assert!(
narrow
.iter()
.any(|(selector, declarations)| selector == ".card"
&& declarations.contains("width:100%"))
);
assert!(
!wide
.iter()
.any(|(_, declarations)| declarations.contains("width:100%"))
);
}
}
+1 -1
View File
@@ -385,7 +385,7 @@ pub fn layout_dom_with_images(
} }
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let sheet = crate::css::Stylesheet::parse(tree, &css_sources); let sheet = crate::css::Stylesheet::parse_for_viewport(tree, &css_sources, viewport);
let t_parse = t0.elapsed(); let t_parse = t0.elapsed();
let t1 = std::time::Instant::now(); let t1 = std::time::Instant::now();
+129 -59
View File
@@ -110,8 +110,13 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
} }
} }
/// Underline is flagged per glyph through cosmic-text's `metadata` field. /// Per-glyph flags carried through cosmic-text's `metadata` field. Bit zero is
/// the underline flag; the remaining bits encode an optional one-based index
/// into an [`InlineItem`]'s clip-text fills.
const META_UNDERLINE: usize = 1; const META_UNDERLINE: usize = 1;
const META_FILL_SHIFT: usize = 1;
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
/// One inline formatting context: a shaped cosmic-text buffer plus where to /// One inline formatting context: a shaped cosmic-text buffer plus where to
/// paint it (filled in after layout). /// paint it (filled in after layout).
@@ -125,11 +130,10 @@ pub struct InlineItem {
origin: (f32, f32), origin: (f32, f32),
/// Ancestor `overflow: hidden` clip, set by `finalize`. /// Ancestor `overflow: hidden` clip, set by `finalize`.
clip: Option<Rect>, clip: Option<Rect>,
/// `-webkit-background-clip: text` fill: when set, the glyphs are painted /// Per-span `-webkit-background-clip: text` fills. Glyph metadata selects
/// with this background (a linear gradient sampled across the text box, or a /// one entry, allowing an inline accent span to own a gradient without
/// solid color as a flat two-stop gradient) instead of the transparent text /// recoloring the rest of its heading.
/// color that would make them invisible. See [`clip_text_fill`]. clip_fills: Vec<ClipTextFill>,
clip_fill: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
} }
/// Owns the font set and shaping caches for one render pass, plus every /// Owns the font set and shaping caches for one render pass, plus every
@@ -244,11 +248,11 @@ impl TextEngine {
return None; return None;
} }
let base = styles.get(&id)?; let base = styles.get(&id)?;
let (ctx, clip_fill) = base_span_ctx(base); let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
let ctx = base_span_ctx(base, &mut collector);
let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
let mut collector = Collector { last_was_space: true };
collect_spans(tree, id, styles, ctx, &mut spans, &mut collector); collect_spans(tree, id, styles, ctx, &mut spans, &mut collector);
self.push_shaped_item(base, spans, clip_fill) self.push_shaped_item(base, spans, collector.clip_fills)
} }
/// Build an inline formatting context from a *run* of consecutive /// Build an inline formatting context from a *run* of consecutive
@@ -275,13 +279,13 @@ impl TextEngine {
return None; return None;
} }
let base = styles.get(&parent)?; let base = styles.get(&parent)?;
let (ctx, clip_fill) = base_span_ctx(base); let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
let ctx = base_span_ctx(base, &mut collector);
let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
let mut collector = Collector { last_was_space: true };
for &cid in run { for &cid in run {
collect_node_spans(tree, cid, styles, ctx, &mut spans, &mut collector); collect_node_spans(tree, cid, styles, ctx, &mut spans, &mut collector);
} }
self.push_shaped_item(base, spans, clip_fill) self.push_shaped_item(base, spans, collector.clip_fills)
} }
/// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected /// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected
@@ -291,7 +295,7 @@ impl TextEngine {
&mut self, &mut self,
base: &LayoutStyle, base: &LayoutStyle,
mut spans: Vec<(String, SpanAttrs)>, mut spans: Vec<(String, SpanAttrs)>,
clip_fill: Option<(f32, Vec<([u8; 4], Option<f32>)>)>, clip_fills: Vec<ClipTextFill>,
) -> Option<usize> { ) -> Option<usize> {
// Trim a single trailing space so it does not widen the last line. // Trim a single trailing space so it does not widen the last line.
if let Some((text, _)) = spans.last_mut() { if let Some((text, _)) = spans.last_mut() {
@@ -363,7 +367,7 @@ impl TextEngine {
forced_min_height, forced_min_height,
origin: (0.0, 0.0), origin: (0.0, 0.0),
clip: None, clip: None,
clip_fill, clip_fills,
}); });
Some(idx) Some(idx)
} }
@@ -442,6 +446,7 @@ struct SpanAttrs {
underline: bool, underline: bool,
color: [u8; 4], color: [u8; 4],
family: &'static str, family: &'static str,
clip_fill: Option<usize>,
} }
impl SpanAttrs { impl SpanAttrs {
@@ -449,10 +454,13 @@ impl SpanAttrs {
let mut a = Attrs::new().family(Family::Name(self.family)); let mut a = Attrs::new().family(Family::Name(self.family));
a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL }); a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL });
a = a.style(if self.italic { Style::Italic } else { Style::Normal }); a = a.style(if self.italic { Style::Italic } else { Style::Normal });
a = a.color(Color::rgba(self.color[0], self.color[1], self.color[2], self.color[3])); // Clip-text glyphs must be shaped with an opaque fill so their coverage
// Underline has no native cosmic-text attribute; carry it through the // reaches paint; the real gradient is selected through metadata.
// per-glyph metadata so paint can stroke it (see `paint_item`). let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
a = a.metadata(if self.underline { META_UNDERLINE } else { 0 }); a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
// Underline and the optional fill index share the per-glyph metadata.
let fill = self.clip_fill.map_or(0, |index| (index + 1) << META_FILL_SHIFT);
a = a.metadata(fill | usize::from(self.underline));
a a
} }
} }
@@ -466,10 +474,12 @@ struct SpanCtx {
underline: bool, underline: bool,
transform: TextTransform, transform: TextTransform,
family: &'static str, family: &'static str,
clip_fill: Option<usize>,
} }
struct Collector { struct Collector {
last_was_space: bool, last_was_space: bool,
clip_fills: Vec<ClipTextFill>,
} }
/// DFS the inline subtree, appending whitespace-collapsed text runs. Adjacent /// DFS the inline subtree, appending whitespace-collapsed text runs. Adjacent
@@ -485,22 +495,21 @@ struct Collector {
/// shape the glyphs in opaque white so their coverage renders, then recolor /// shape the glyphs in opaque white so their coverage renders, then recolor
/// them from the background at paint time; otherwise transparent text stays /// them from the background at paint time; otherwise transparent text stays
/// transparent (and invisible), unchanged. /// transparent (and invisible), unchanged.
fn base_span_ctx(base: &LayoutStyle) -> (SpanCtx, Option<(f32, Vec<([u8; 4], Option<f32>)>)>) { fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx {
let clip_fill = clip_text_fill(base); let clip_fill = clip_text_fill(base).map(|fill| {
let default_color = if clip_fill.is_some() { let index = collector.clip_fills.len();
[255, 255, 255, 255] collector.clip_fills.push(fill);
} else { index
base.color.unwrap_or([0, 0, 0, 255]) });
}; SpanCtx {
let ctx = SpanCtx { color: base.color.unwrap_or([0, 0, 0, 255]),
color: default_color,
bold: base.font_weight.as_deref() == Some("bold"), bold: base.font_weight.as_deref() == Some("bold"),
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: resolve_font_family(base.font_family.as_deref()), family: resolve_font_family(base.font_family.as_deref()),
}; clip_fill,
(ctx, clip_fill) }
} }
fn collect_spans( fn collect_spans(
@@ -531,7 +540,14 @@ fn collect_node_spans(
let Some(node) = tree.get_node(cid) else { return }; let Some(node) = tree.get_node(cid) else { return };
match &node.data { match &node.data {
obscura_dom::tree::NodeData::Text { contents } => { obscura_dom::tree::NodeData::Text { contents } => {
let attrs = SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family }; let attrs = SpanAttrs {
bold: ctx.bold,
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
family: ctx.family,
clip_fill: ctx.clip_fill,
};
push_text(contents, ctx.transform, &attrs, out, c); push_text(contents, ctx.transform, &attrs, out, c);
} }
_ => { _ => {
@@ -541,12 +557,31 @@ fn collect_node_spans(
return; return;
} }
if elem.local.as_ref() == "br" { if elem.local.as_ref() == "br" {
out.push(("\n".to_string(), SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family })); out.push(("\n".to_string(), SpanAttrs {
bold: ctx.bold,
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
family: ctx.family,
clip_fill: ctx.clip_fill,
}));
c.last_was_space = true; c.last_was_space = true;
return; return;
} }
let own_clip_fill = style.and_then(clip_text_fill).map(|fill| {
let index = c.clip_fills.len();
c.clip_fills.push(fill);
index
});
let color = style.and_then(|s| s.color).unwrap_or(ctx.color);
// A descendant with its own clip-text background replaces the
// inherited fill. Transparent descendants otherwise continue an
// ancestor's fill; an opaque text color paints normally.
let clip_fill = own_clip_fill.or_else(|| {
if color[3] == 0 { ctx.clip_fill } else { None }
});
let child = SpanCtx { let child = SpanCtx {
color: style.and_then(|s| s.color).unwrap_or(ctx.color), color,
bold: ctx.bold || style.map(|s| s.font_weight.as_deref() == Some("bold")).unwrap_or(false), bold: ctx.bold || style.map(|s| s.font_weight.as_deref() == Some("bold")).unwrap_or(false),
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
@@ -557,6 +592,7 @@ fn collect_node_spans(
.and_then(|s| s.font_family.as_deref()) .and_then(|s| s.font_family.as_deref())
.map(|f| resolve_font_family(Some(f))) .map(|f| resolve_font_family(Some(f)))
.unwrap_or(ctx.family), .unwrap_or(ctx.family),
clip_fill,
}; };
collect_spans(tree, cid, styles, child, out, c); collect_spans(tree, cid, styles, child, out, c);
} }
@@ -807,11 +843,32 @@ impl TextEngine {
// consecutive underlined glyphs on a line into one stroke below the // consecutive underlined glyphs on a line into one stroke below the
// baseline. Done first so the draw() mutable borrow does not overlap. // baseline. Done first so the draw() mutable borrow does not overlap.
let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color
let mut fill_bounds: Vec<Option<(f32, f32, f32, f32)>> =
vec![None; item.clip_fills.len()];
for run in item.buffer.layout_runs() { for run in item.buffer.layout_runs() {
let base_y = run.line_y; let base_y = run.line_y;
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 as usize; let underlined = g.metadata & META_UNDERLINE != 0;
if let Some(fill_index) = (g.metadata >> META_FILL_SHIFT).checked_sub(1) {
if let Some(bounds) = fill_bounds.get_mut(fill_index) {
let glyph_bounds = (
g.x,
run.line_top,
g.x + g.w,
run.line_top + run.line_height,
);
*bounds = Some(match *bounds {
Some((x0, y0, x1, y1)) => (
x0.min(glyph_bounds.0),
y0.min(glyph_bounds.1),
x1.max(glyph_bounds.2),
y1.max(glyph_bounds.3),
),
None => glyph_bounds,
});
}
}
let col = g.color_opt.map(|c| [c.r(), c.g(), c.b(), c.a()]).unwrap_or([0, 0, 0, 255]); let col = g.color_opt.map(|c| [c.r(), c.g(), c.b(), c.a()]).unwrap_or([0, 0, 0, 255]);
if underlined { if underlined {
match &mut seg { match &mut seg {
@@ -838,40 +895,52 @@ impl TextEngine {
// Fallback color if a glyph carries none (shouldn't happen: every span // Fallback color if a glyph carries none (shouldn't happen: every span
// sets one), black. // sets one), black.
let default = Color::rgba(0, 0, 0, 255); let default = Color::rgba(0, 0, 0, 255);
// `-webkit-background-clip: text`: recolor each glyph pixel from the // `-webkit-background-clip: text`: rasterize each glyph while its
// background gradient sampled across the shaped text box, keeping the // metadata is still available, then sample that span's background
// glyph's coverage as alpha. The glyphs were shaped opaque (see // gradient across the span's own shaped bounds. Buffer::draw omits
// `try_build`) so this coverage exists; without a clip fill the per-span // metadata from its callback, so using it here would force one fill
// colors pass through unchanged. // over the entire heading and lose inline accent gradients.
let clip_fill = item.clip_fill.clone(); let clip_fills = item.clip_fills.clone();
let fill_extent = clip_fill.as_ref().map(|_| {
let (width, height) = buffer_size(&item.buffer);
(width, height.max(item.forced_min_height))
});
let pixels = pixmap.pixels_mut(); let pixels = pixmap.pixels_mut();
item.buffer.draw(font_system, swash, default, |gx, gy, gw, gh, color| { for run in item.buffer.layout_runs() {
for glyph in run.glyphs {
let physical = glyph.physical((0.0, 0.0), 1.0);
let glyph_color = glyph.color_opt.unwrap_or(default);
let fill_index = (glyph.metadata >> META_FILL_SHIFT).checked_sub(1);
swash.with_pixels(
font_system,
physical.cache_key,
glyph_color,
|x, y, color| {
let a = color.a() as u32; let a = color.a() as u32;
if a == 0 { if a == 0 {
return; return;
} }
let (r, g, b) = match (&clip_fill, fill_extent) { let gx = physical.x + x;
(Some(fill), Some((tw, th))) => { let gy = run.line_y as i32 + physical.y + y;
let c = sample_gradient(fill, gx as f32 + gw as f32 / 2.0, gy as f32 + gh as f32 / 2.0, tw, th); let (r, g, b) = fill_index
(c[0], c[1], c[2]) .and_then(|index| {
} let fill = clip_fills.get(index)?;
_ => (color.r(), color.g(), color.b()), let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?;
}; let sampled = sample_gradient(
for dy in 0..gh as i32 { fill,
for dx in 0..gw as i32 { gx as f32 + 0.5 - x0,
let px = ox as i32 + gx + dx; gy as f32 + 0.5 - y0,
let py = oy as i32 + gy + dy; x1 - x0,
y1 - y0,
);
Some((sampled[0], sampled[1], sampled[2]))
})
.unwrap_or_else(|| (color.r(), color.g(), color.b()));
let px = ox as i32 + gx;
let py = oy as i32 + gy;
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds { if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 { if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 {
continue; return;
} }
} }
if px < 0 || px >= pw || py < 0 || py >= ph { if px < 0 || px >= pw || py < 0 || py >= ph {
continue; return;
} }
let idx = (py * pw + px) as usize; let idx = (py * pw + px) as usize;
let dst = pixels[idx]; let dst = pixels[idx];
@@ -882,16 +951,17 @@ impl TextEngine {
let inv = 255 - sa; let inv = 255 - sa;
let out_a = sa + (dst.alpha() as u32 * inv / 255); let out_a = sa + (dst.alpha() as u32 * inv / 255);
if out_a == 0 { if out_a == 0 {
continue; return;
} }
let out_r = sr + (dst.red() as u32 * inv / 255); let out_r = sr + (dst.red() as u32 * inv / 255);
let out_g = sg + (dst.green() as u32 * inv / 255); let out_g = sg + (dst.green() as u32 * inv / 255);
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);
},
);
} }
} }
});
// Stroke the underline segments (opaque; text is already drawn above). // Stroke the underline segments (opaque; text is already drawn above).
for (x0, x1, y, thick, col) in underlines { for (x0, x1, y, thick, col) in underlines {
+32
View File
@@ -1960,6 +1960,38 @@ mod tests {
assert!(found_green, "expected green text to be painted"); assert!(found_green, "expected green text to be painted");
} }
#[test]
fn paints_vendor_gradient_on_inline_text_span() {
// Vue and many other framework sites put the gradient on an inline
// accent span, not on the whole heading. The surrounding text must
// keep its normal color while this span samples both gradient ends.
let tree = parse_html(
r#"<html><head><style>
h1 { color:#17233c; font-size:50px; margin:0 }
html:not(.dark) .accent[data-v-x] {
-webkit-text-fill-color:transparent;
background:-webkit-linear-gradient(315deg,#42d392 25%,#647eff);
-webkit-background-clip:text;
background-clip:text
}
</style></head><body style="margin:0">
<h1>The <span class="accent" data-v-x>Progressive</span></h1>
</body></html>"#,
);
let pixmap = paint_dom(&tree, (500.0, 100.0), None).expect("pixmap");
let mut green = false;
let mut blue = false;
let mut normal = false;
for pixel in pixmap.pixels() {
let (r, g, b) = (pixel.red(), pixel.green(), pixel.blue());
green |= g > r.saturating_add(20) && g > b.saturating_add(10);
blue |= b > r.saturating_add(20) && b > g.saturating_add(5);
normal |= b > g.saturating_add(10) && r < 80 && g < 100;
}
assert!(normal, "surrounding heading text should retain its normal color");
assert!(green && blue, "inline accent should contain both gradient colors");
}
#[test] #[test]
fn serializes_inline_svg_subtree() { fn serializes_inline_svg_subtree() {
// A sprite-style svg: a <use> that references a <symbol> in the same // A sprite-style svg: a <use> that references a <symbol> in the same
+36 -2
View File
@@ -448,7 +448,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
// content-box) is an ordinary background, so clear the flag. // content-box) is an ordinary background, so clear the flag.
style.background_clip_text = value.trim().eq_ignore_ascii_case("text"); style.background_clip_text = value.trim().eq_ignore_ascii_case("text");
} }
"color" => style.color = parse_color(value), // Blink/WebKit gradient text commonly makes the glyph fill
// transparent through this inherited property while clipping a
// background gradient to the text. We model one effective text color,
// so the vendor fill color is the paint-time color winner.
"color" | "-webkit-text-fill-color" => style.color = parse_color(value),
"border-color" => style.border_color = parse_color(value), "border-color" => style.border_color = parse_color(value),
"font-size" => { "font-size" => {
// Absolute lengths resolve now; font/viewport-relative ones defer // Absolute lengths resolve now; font/viewport-relative ones defer
@@ -2508,6 +2512,13 @@ fn parse_linear_gradient(value: &str) -> 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(")?;
// The original prefixed WebKit gradient syntax predates the standardized
// angle system: 0deg points right and positive angles turn
// counter-clockwise. Blink still accepts that syntax for compatibility.
// Convert it to our standard CSS angle (0deg up, clockwise) before paint.
let prefix = &lower[..start];
let legacy_webkit_angle =
prefix.ends_with("-webkit-") || prefix.ends_with("-webkit-repeating-");
let open = start + "linear-gradient(".len(); let open = start + "linear-gradient(".len();
// Match the closing paren for this function. // Match the closing paren for this function.
let bytes = v.as_bytes(); let bytes = v.as_bytes();
@@ -2546,7 +2557,11 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)
let mut stop_start = 0; let mut stop_start = 0;
if first.ends_with("deg") { if first.ends_with("deg") {
if let Ok(a) = first.trim_end_matches("deg").trim().parse::<f32>() { if let Ok(a) = first.trim_end_matches("deg").trim().parse::<f32>() {
angle = a; angle = if legacy_webkit_angle {
(90.0 - a).rem_euclid(360.0)
} else {
a.rem_euclid(360.0)
};
} }
stop_start = 1; stop_start = 1;
} else if first.starts_with("to ") { } else if first.starts_with("to ") {
@@ -3123,6 +3138,25 @@ mod tests {
fn background_clip_text_flag() { fn background_clip_text_flag() {
let s = compute_style("h1", Some("color: transparent; -webkit-background-clip: text")); let s = compute_style("h1", Some("color: transparent; -webkit-background-clip: text"));
assert!(s.background_clip_text); assert!(s.background_clip_text);
let vendor_fill = compute_style(
"h1",
Some(
"color: black; -webkit-text-fill-color: transparent;\
background: linear-gradient(90deg, red, blue);\
-webkit-background-clip: text",
),
);
assert_eq!(vendor_fill.color, Some([0, 0, 0, 0]));
assert!(vendor_fill.background_clip_text);
assert!(vendor_fill.background_gradient.is_some());
let legacy_angle = compute_style(
"span",
Some("background:-webkit-linear-gradient(315deg,#42d392 25%,#647eff)"),
)
.background_gradient
.expect("prefixed gradient")
.0;
assert_eq!(legacy_angle, 135.0);
let n = compute_style("h1", Some("background-clip: border-box")); let n = compute_style("h1", Some("background-clip: border-box"));
assert!(!n.background_clip_text); assert!(!n.background_clip_text);
let l = compute_style("h1", Some("background-clip: text")); let l = compute_style("h1", Some("background-clip: text"));
+5
View File
@@ -253,6 +253,11 @@
{"name": "unsupported identity transform still establishes containing block", "color": "#7048e8", "x": 550, "y": 280, "width": 20, "height": 20}, {"name": "unsupported identity transform still establishes containing block", "color": "#7048e8", "x": 550, "y": 280, "width": 20, "height": 20},
{"name": "positioned ancestor does not capture fixed", "color": "#a61e4d", "x": 700, "y": 40, "width": 90, "height": 60} {"name": "positioned ancestor does not capture fixed", "color": "#a61e4d", "x": 700, "y": 40, "width": 90, "height": 60}
], ],
"viewport-consistency": [
{"name": "width media query uses the screenshot viewport", "color": "#087f5b", "x": 10, "y": 10, "width": 200, "height": 80},
{"name": "height media query uses the screenshot viewport", "color": "#1971c2", "x": 10, "y": 110, "width": 200, "height": 80},
{"name": "page JavaScript sees the screenshot viewport", "color": "#7048e8", "x": 10, "y": 210, "width": 200, "height": 80}
],
"z-index": [ "z-index": [
{"name": "positive layer", "color": "#008000", "width": 200, "height": 100}, {"name": "positive layer", "color": "#008000", "width": 200, "height": 100},
{"name": "negative layer", "color": "#ffa500", "width": 260, "height": 110} {"name": "negative layer", "color": "#ffa500", "width": 260, "height": 110}
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<meta charset="utf-8">
<title>viewport consistency</title>
<style>
html, body { margin: 0; }
.probe {
position: absolute;
left: 10px;
width: 200px;
height: 80px;
background: #c92a2a;
}
#width-probe { top: 10px; }
#height-probe { top: 110px; }
#script-probe { top: 210px; }
@media (max-width: 950px) {
#width-probe { background: #087f5b; }
}
@media (min-height: 900px) {
#height-probe { background: #1971c2; }
}
#script-probe.viewport-match { background: #7048e8; }
</style>
<div id="width-probe" class="probe"></div>
<div id="height-probe" class="probe"></div>
<div id="script-probe" class="probe"></div>
<script>
if (innerWidth === 900 && innerHeight === 1000 &&
visualViewport.width === 900 && visualViewport.height === 1000) {
document.getElementById('script-probe').classList.add('viewport-match');
}
</script>