diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 041e289..453963d 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -4233,6 +4233,27 @@ mod tests { ); } + #[test] + fn supports_polygon_clip_path_activates_only_painted_shape_subset() { + let css = "@supports (clip-path:polygon(0 0,100% 0,50% 100%)){\ + .transition{height:90px}\ + }\ + @supports (clip-path:polygon(0 0,100% 0,50% 100%) content-box){\ + .unsupported{height:999px}\ + }"; + let rules = parse_stylesheet_for_viewport(css, (1280.0, 720.0)); + assert!( + rules.iter().any(|(selector, declarations)| { + selector == ".transition" && declarations.contains("height:90px") + }), + "the supported polygon branch must enter the cascade: {rules:?}" + ); + assert!( + !rules.iter().any(|(selector, _)| selector == ".unsupported"), + "an unpainted geometry-box variant must not pass @supports: {rules:?}" + ); + } + #[test] fn media_breakpoints_support_font_relative_lengths_and_ranges() { assert!(!media_query_applies_for_viewport( diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 42eccd5..809a1cf 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -235,6 +235,26 @@ impl BorderRadius { } } +/// Fill rule for a CSS `clip-path: polygon(...)`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ClipPathFillRule { + #[default] + Nonzero, + Evenodd, +} + +/// The supported CSS basic-shape clip. +/// +/// Coordinates retain their CSS length/percentage unit until paint, where +/// percentages resolve independently against the border box width and height. +/// This mirrors the reference-box resolution used by browser engines and +/// avoids baking responsive polygon geometry into computed style. +#[derive(Debug, Clone, PartialEq)] +pub struct ClipPathPolygon { + pub fill_rule: ClipPathFillRule, + pub points: Vec<(Dimension, Dimension)>, +} + /// The subset of CSS that influences box layout. Expanded in later phases. #[derive(Debug, Clone, Default)] pub struct LayoutStyle { @@ -334,6 +354,9 @@ pub struct LayoutStyle { /// `border-radius` (uniform; the first value of the shorthand). Rounds the /// background fill, replaced content, border, and shadow. pub border_radius: BorderRadius, + /// `clip-path: polygon(...)`, resolved against the final border box at + /// paint time. `None` is the computed `none` value. + pub clip_path: Option, /// RGBA for the paint step. Parsed always (cheap), used only with `paint`. pub background_color: Option<[u8; 4]>, /// `linear-gradient(...)` background: (angle in degrees clockwise from 12 diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 3cbdf07..535c5c9 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -710,6 +710,17 @@ fn paint_laid_dom_scrolled( Some(r) => r, None => continue, }; + let clip_path_mask = style.clip_path.as_ref().and_then(|polygon| { + polygon_clip_mask( + pixmap.width(), + pixmap.height(), + polygon, + &rect, + style.font_size.unwrap_or(16.0), + root_font_size, + viewport, + ) + }); // Outset box-shadow paints behind this element's own background/border. // Geometry comes from the full (translate-adjusted) border box; the @@ -751,7 +762,7 @@ fn paint_laid_dom_scrolled( &paint, FillRule::Winding, Transform::identity(), - None, + clip_path_mask.as_ref(), ); } } @@ -763,6 +774,7 @@ fn paint_laid_dom_scrolled( &visible_rect, *center, stops, + clip_path_mask.as_ref(), ); } } @@ -774,11 +786,19 @@ fn paint_laid_dom_scrolled( *angle, *center, stops, + clip_path_mask.as_ref(), ); } if let Some((angle, stops)) = &style.background_gradient { if let Some(path) = bg_path() { - paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops); + paint_linear_gradient( + &mut pixmap, + &path, + &visible_rect, + *angle, + stops, + clip_path_mask.as_ref(), + ); } } } @@ -796,6 +816,7 @@ fn paint_laid_dom_scrolled( style.background_conic_gradient.as_ref(), style.mask_size, style.mask_repeat, + clip_path_mask.as_ref(), &mut pixmap, image_cache, ); @@ -832,6 +853,7 @@ fn paint_laid_dom_scrolled( image_cache, None, radius, + clip_path_mask.as_ref(), ); } } @@ -874,7 +896,13 @@ fn paint_laid_dom_scrolled( paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3])); paint.anti_alias = true; let stroke = tiny_skia::Stroke { width: w, ..Default::default() }; - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + pixmap.stroke_path( + &path, + &paint, + &stroke, + Transform::identity(), + clip_path_mask.as_ref(), + ); } } else if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 { let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]); @@ -905,7 +933,13 @@ fn paint_laid_dom_scrolled( push_clipped(rect.x, rect.y, style.border.left, rect.height); } if let Some(path) = path.finish() { - pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + clip_path_mask.as_ref(), + ); } } @@ -926,6 +960,7 @@ fn paint_laid_dom_scrolled( image_cache, projected_image, radius, + clip_path_mask.as_ref(), ); // Fall back when the image itself did not paint, following // what browsers show for a broken image: a non-empty alt @@ -2458,7 +2493,14 @@ fn raster_to_pixmap(bytes: &[u8], w: u32, h: u32) -> Option { /// 12 o'clock (0 = to top). The gradient line length uses the CSS formula so /// the stops land where a browser puts them. Positionless stops are spread /// evenly; positions are clamped monotonic (tiny-skia requires ascending). -fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &crate::Rect, angle: f32, stops: &[([u8; 4], Option)]) { +fn paint_linear_gradient( + pixmap: &mut Pixmap, + path: &tiny_skia::Path, + rect: &crate::Rect, + angle: f32, + stops: &[([u8; 4], Option)], + clip: Option<&tiny_skia::Mask>, +) { if stops.len() < 2 { return; } @@ -2482,7 +2524,7 @@ fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &cra let mut paint = Paint::default(); paint.shader = shader; paint.anti_alias = true; - pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None); + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), clip); } } @@ -2492,6 +2534,7 @@ fn paint_radial_gradient( rect: &crate::Rect, center: (f32, f32), stops: &[([u8; 4], Option)], + clip: Option<&tiny_skia::Mask>, ) { if stops.len() < 2 { return; @@ -2530,7 +2573,7 @@ fn paint_radial_gradient( let mut paint = Paint::default(); paint.shader = shader; paint.anti_alias = true; - pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None); + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), clip); } } @@ -2541,6 +2584,7 @@ fn paint_conic_gradient( angle: f32, center: (f32, f32), stops: &[([u8; 4], Option)], + extra_clip: Option<&tiny_skia::Mask>, ) { if rect.width <= 0.0 || rect.height <= 0.0 || stops.len() < 2 { return; @@ -2564,16 +2608,36 @@ fn paint_conic_gradient( layer.pixels_mut()[(y * width + x) as usize] = premultiplied(color); } } - let clip = if border_radius.0 > 0.5 && border_radius.1 > 0.5 { - rounded_box_clip_mask( - pixmap.width(), - pixmap.height(), - rect, - border_radius, - ) - } else { - None - }; + let mut clip = extra_clip.cloned(); + if border_radius.0 > 0.5 && border_radius.1 > 0.5 { + let path = rounded_rect_path( + rect.x, + rect.y, + rect.width, + rect.height, + border_radius.0, + border_radius.1, + ); + match (clip.as_mut(), path) { + (Some(mask), Some(path)) => { + mask.intersect_path( + &path, + FillRule::Winding, + true, + Transform::identity(), + ); + } + (None, _) => { + clip = rounded_box_clip_mask( + pixmap.width(), + pixmap.height(), + rect, + border_radius, + ); + } + _ => {} + } + } pixmap.draw_pixmap( rect.x.floor() as i32, rect.y.floor() as i32, @@ -2920,6 +2984,17 @@ fn paint_in_flow_generated_box( } let radius = style.border_radius.resolve(rect.width, rect.height); let has_radius = radius.0 > 0.5 && radius.1 > 0.5; + let clip_path_mask = style.clip_path.as_ref().and_then(|polygon| { + polygon_clip_mask( + pixmap.width(), + pixmap.height(), + polygon, + &rect, + style.font_size.unwrap_or(16.0), + root_font_size, + viewport, + ) + }); let path = if has_radius { rounded_rect_path( visible.x, @@ -2947,17 +3022,39 @@ fn paint_in_flow_generated_box( &paint, FillRule::Winding, Transform::identity(), - None, + clip_path_mask.as_ref(), ); } if let Some((center, stops)) = &style.background_radial_gradient { - paint_radial_gradient(pixmap, &path, &rect, *center, stops); + paint_radial_gradient( + pixmap, + &path, + &rect, + *center, + stops, + clip_path_mask.as_ref(), + ); } if let Some((angle, center, stops)) = &style.background_conic_gradient { - paint_conic_gradient(pixmap, &rect, radius, *angle, *center, stops); + paint_conic_gradient( + pixmap, + &rect, + radius, + *angle, + *center, + stops, + clip_path_mask.as_ref(), + ); } if let Some((angle, stops)) = &style.background_gradient { - paint_linear_gradient(pixmap, &path, &rect, *angle, stops); + paint_linear_gradient( + pixmap, + &path, + &rect, + *angle, + stops, + clip_path_mask.as_ref(), + ); } } if let Some(mask_url) = &style.mask_image { @@ -2976,6 +3073,7 @@ fn paint_in_flow_generated_box( style.background_conic_gradient.as_ref(), style.mask_size, style.mask_repeat, + clip_path_mask.as_ref(), pixmap, image_cache, ); @@ -3003,6 +3101,7 @@ fn paint_in_flow_generated_box( image_cache, None, radius, + clip_path_mask.as_ref(), ); } } @@ -3065,7 +3164,7 @@ fn paint_in_flow_generated_box( &border_paint, FillRule::Winding, Transform::identity(), - None, + clip_path_mask.as_ref(), ); } } @@ -3122,6 +3221,17 @@ fn paint_positioned_pseudo( let Some(visible) = visible else { return }; let radius = style.border_radius.resolve(rect.width, rect.height); let has_radius = radius.0 > 0.5 && radius.1 > 0.5; + let clip_path_mask = style.clip_path.as_ref().and_then(|polygon| { + polygon_clip_mask( + pixmap.width(), + pixmap.height(), + polygon, + &rect, + em, + root_font_size, + viewport, + ) + }); let path = if has_radius { rounded_rect_path( visible.x, @@ -3148,17 +3258,39 @@ fn paint_positioned_pseudo( &paint, FillRule::Winding, Transform::identity(), - None, + clip_path_mask.as_ref(), ); } if let Some((center, stops)) = &style.background_radial_gradient { - paint_radial_gradient(pixmap, &path, &rect, *center, stops); + paint_radial_gradient( + pixmap, + &path, + &rect, + *center, + stops, + clip_path_mask.as_ref(), + ); } if let Some((angle, center, stops)) = &style.background_conic_gradient { - paint_conic_gradient(pixmap, &rect, radius, *angle, *center, stops); + paint_conic_gradient( + pixmap, + &rect, + radius, + *angle, + *center, + stops, + clip_path_mask.as_ref(), + ); } if let Some((angle, stops)) = &style.background_gradient { - paint_linear_gradient(pixmap, &path, &rect, *angle, stops); + paint_linear_gradient( + pixmap, + &path, + &rect, + *angle, + stops, + clip_path_mask.as_ref(), + ); } } if let Some(mask_url) = &style.mask_image { @@ -3177,6 +3309,7 @@ fn paint_positioned_pseudo( style.background_conic_gradient.as_ref(), style.mask_size, style.mask_repeat, + clip_path_mask.as_ref(), pixmap, image_cache, ); @@ -3204,6 +3337,7 @@ fn paint_positioned_pseudo( image_cache, None, radius, + clip_path_mask.as_ref(), ); } } @@ -3579,6 +3713,7 @@ fn paint_image( cache: &mut RenderResourceCache, transform: Option, clip_radius: (f32, f32), + extra_clip: Option<&tiny_skia::Mask>, ) -> bool { if rect.width <= 0.0 || rect.height <= 0.0 { return false; @@ -3617,21 +3752,53 @@ fn paint_image( // the image past the box, and an ancestor clip can cut into the box // itself. Only the fully-inside case takes the unmasked fast path. let has_radius = clip_radius.0 > 0.5 && clip_radius.1 > 0.5; - let clip = if has_radius + let needs_box_clip = has_radius || dest.width > visible_rect.width + 0.5 || dest.height > visible_rect.height + 0.5 || dest.x < visible_rect.x - 0.5 - || dest.y < visible_rect.y - 0.5 - { - rounded_box_clip_mask( - pixmap.width(), - pixmap.height(), - visible_rect, - clip_radius, - ) - } else { - None - }; + || dest.y < visible_rect.y - 0.5; + let mut clip = extra_clip.cloned(); + if needs_box_clip { + let path = if has_radius { + rounded_rect_path( + visible_rect.x, + visible_rect.y, + visible_rect.width, + visible_rect.height, + clip_radius.0, + clip_radius.1, + ) + } else { + Rect::from_xywh( + visible_rect.x, + visible_rect.y, + visible_rect.width, + visible_rect.height, + ) + .and_then(|rect| { + let mut builder = PathBuilder::new(); + builder.push_rect(rect); + builder.finish() + }) + }; + match (clip.as_mut(), path) { + (Some(mask), Some(path)) => mask.intersect_path( + &path, + FillRule::Winding, + true, + Transform::identity(), + ), + (None, _) => { + clip = rounded_box_clip_mask( + pixmap.width(), + pixmap.height(), + visible_rect, + clip_radius, + ); + } + _ => {} + } + } pixmap.draw_pixmap( dest.x as i32, dest.y as i32, @@ -3731,6 +3898,57 @@ fn rounded_box_clip_mask( Some(mask) } +/// Resolve a CSS polygon against its border-box reference and rasterize it as +/// a full-surface alpha clip. Gecko's basic-shape path builder likewise +/// resolves each x percentage against the reference width and each y +/// percentage against its height before closing the path. +fn polygon_clip_mask( + pw: u32, + ph: u32, + polygon: &crate::ClipPathPolygon, + rect: &crate::Rect, + em: f32, + rem: f32, + viewport: (f32, f32), +) -> Option { + let resolve = |coordinate: crate::Dimension, basis: f32| { + match coordinate.resolve( + em, + rem, + viewport.0 / 100.0, + viewport.1 / 100.0, + ) { + crate::Dimension::Px(value) => Some(value), + crate::Dimension::Percent(value) => Some(value * basis), + _ => None, + } + }; + let mut builder = PathBuilder::new(); + for (index, &(x, y)) in polygon.points.iter().enumerate() { + let x = rect.x + resolve(x, rect.width)?; + let y = rect.y + resolve(y, rect.height)?; + if index == 0 { + builder.move_to(x, y); + } else { + builder.line_to(x, y); + } + } + builder.close(); + let path = builder.finish(); + let fill_rule = match polygon.fill_rule { + crate::ClipPathFillRule::Nonzero => FillRule::Winding, + crate::ClipPathFillRule::Evenodd => FillRule::EvenOdd, + }; + let mut mask = tiny_skia::Mask::new(pw, ph)?; + // Degenerate polygons are valid CSS basic shapes but have no enclosed + // area. Keep the newly zeroed mask in that case: treating it as "no mask" + // would incorrectly reveal the whole element. + if let Some(path) = path { + mask.fill_path(&path, fill_rule, true, Transform::identity()); + } + Some(mask) +} + /// Sniff SVG content: either an XML/SVG prolog, or a bare ` bool { @@ -4433,6 +4651,7 @@ fn paint_mask( conic_gradient: Option<&(f32, (f32, f32), Vec<([u8; 4], Option)>)>, mask_size: Option<(f32, f32)>, mask_repeat: Option<(bool, bool)>, + extra_clip: Option<&tiny_skia::Mask>, pixmap: &mut Pixmap, cache: &mut RenderResourceCache, ) -> bool { @@ -4507,16 +4726,34 @@ fn paint_mask( premultiplied(color); } } - let clip = if border_radius.0 > 0.5 && border_radius.1 > 0.5 { - rounded_box_clip_mask( - pixmap.width(), - pixmap.height(), - rect, - border_radius, - ) - } else { - None - }; + let mut clip = extra_clip.cloned(); + if border_radius.0 > 0.5 && border_radius.1 > 0.5 { + let path = rounded_rect_path( + rect.x, + rect.y, + rect.width, + rect.height, + border_radius.0, + border_radius.1, + ); + match (clip.as_mut(), path) { + (Some(mask), Some(path)) => mask.intersect_path( + &path, + FillRule::Winding, + true, + Transform::identity(), + ), + (None, _) => { + clip = rounded_box_clip_mask( + pixmap.width(), + pixmap.height(), + rect, + border_radius, + ); + } + _ => {} + } + } pixmap.draw_pixmap( rect.x.floor() as i32, rect.y.floor() as i32, @@ -5044,6 +5281,96 @@ mod tests { ); } + #[test] + fn polygon_clip_path_paints_responsive_geometry_on_elements_and_pseudos() { + let tree = parse_html( + r#" +
+
+
+ "#, + ); + let pixmap = paint_dom(&tree, (300.0, 100.0), None).expect("pixmap"); + for (name, left, channel) in [ + ("ordinary element", 0, 0), + ("in-flow pseudo", 100, 1), + ("positioned pseudo", 200, 2), + ] { + let inside = pixmap.pixel(left + 40, 65).expect("inside pixel"); + let colored = match channel { + 0 => inside.red() > 180 && inside.green() < 80 && inside.blue() < 80, + 1 => inside.green() > 100 && inside.red() < 80 && inside.blue() < 80, + _ => inside.blue() > 180 && inside.red() < 80 && inside.green() < 80, + }; + assert!(colored, "{name} must paint inside its percentage polygon: {inside:?}"); + let outside = pixmap.pixel(left + 4, 65).expect("outside pixel"); + assert_eq!( + (outside.red(), outside.green(), outside.blue()), + (255, 255, 255), + "{name} must be clipped outside its triangle: {outside:?}" + ); + } + } + + #[test] + fn polygon_clip_path_honors_evenodd_fill_rule() { + let tree = parse_html( + r#" +
+ "#, + ); + let pixmap = paint_dom(&tree, (120.0, 120.0), None).expect("pixmap"); + let shell = pixmap.pixel(10, 50).expect("outer shell"); + assert!( + shell.red() > 180 && shell.green() < 80, + "the outer contour must remain painted: {shell:?}" + ); + let hole = pixmap.pixel(50, 50).expect("inner hole"); + assert_eq!( + (hole.red(), hole.green(), hole.blue()), + (255, 255, 255), + "evenodd must cut the inner winding out of the clip: {hole:?}" + ); + } + + #[test] + fn degenerate_polygon_clip_path_clips_the_element_away() { + let tree = parse_html( + r#" +
+ "#, + ); + let pixmap = paint_dom(&tree, (100.0, 100.0), None).expect("pixmap"); + for (x, y) in [(10, 10), (20, 20), (40, 40)] { + let pixel = pixmap.pixel(x, y).expect("pixel"); + assert_eq!( + (pixel.red(), pixel.green(), pixel.blue()), + (255, 255, 255), + "a zero-area polygon must not fall back to an unclipped box: {pixel:?}" + ); + } + } + #[test] fn paints_generated_style_images_and_sizes_content_url_as_replaced_content() { let tree = parse_html( diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 968f930..e23ed72 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -755,6 +755,17 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.border_radius = crate::BorderRadius::pixels(length); } } + "clip-path" | "-webkit-clip-path" => { + let lower = value.trim().to_ascii_lowercase(); + if matches!( + lower.as_str(), + "none" | "initial" | "unset" | "revert" | "revert-layer" + ) { + style.clip_path = None; + } else if let Some(polygon) = parse_clip_path_polygon(value) { + style.clip_path = Some(polygon); + } + } "border" => { if value.split_whitespace().any(|token| { token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden") @@ -1521,6 +1532,8 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "padding-block-start" | "padding-block-end" | "border-radius" + | "clip-path" + | "-webkit-clip-path" | "border" | "border-width" | "border-top-width" @@ -1703,6 +1716,9 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { "animation-delay" => parse_animation_time_ms(value).is_some(), "animation-iteration-count" => parse_animation_iteration_count(value).is_some(), "animation-direction" => parse_animation_direction(value).is_some(), + "clip-path" | "-webkit-clip-path" => { + value.eq_ignore_ascii_case("none") || parse_clip_path_polygon(value).is_some() + } "animation-fill-mode" => parse_animation_fill_mode(value).is_some(), "animation-play-state" => parse_animation_play_state(value).is_some(), "float" => matches!( @@ -4103,6 +4119,71 @@ fn dimension_value(tok: &str) -> crate::Dimension { Dimension::Auto } +/// Parse the currently painted basic-shape subset of `clip-path`. +/// +/// The default (and only supported) geometry box is `border-box`. Rejecting +/// the other reference boxes here keeps `@supports` aligned with the geometry +/// we actually paint. Functional coordinates are likewise rejected until +/// their unresolved math can be retained through computed style. +fn parse_clip_path_polygon(value: &str) -> Option { + let value = value.trim(); + let lower = value.to_ascii_lowercase(); + if !lower.starts_with("polygon(") { + return None; + } + let close = value.rfind(')')?; + let suffix = value[close + 1..].trim(); + if !suffix.is_empty() && !suffix.eq_ignore_ascii_case("border-box") { + return None; + } + let inner = value["polygon(".len()..close].trim(); + let mut components = split_top_level(inner, ','); + let mut fill_rule = crate::ClipPathFillRule::Nonzero; + if let Some(first) = components.first().map(|component| component.trim()) { + if first.eq_ignore_ascii_case("evenodd") { + fill_rule = crate::ClipPathFillRule::Evenodd; + components.remove(0); + } else if first.eq_ignore_ascii_case("nonzero") { + components.remove(0); + } + } + if components.is_empty() { + return None; + } + let mut points = Vec::with_capacity(components.len()); + for component in components { + let coordinates = split_ws_paren(component); + let [x, y] = coordinates.as_slice() else { + return None; + }; + if x.contains('(') || y.contains('(') { + return None; + } + let x = dimension_value(x); + let y = dimension_value(y); + if matches!(x, crate::Dimension::Auto) || matches!(y, crate::Dimension::Auto) { + return None; + } + let finite = |coordinate: crate::Dimension| match coordinate { + crate::Dimension::Auto => false, + crate::Dimension::Px(value) + | crate::Dimension::Percent(value) + | crate::Dimension::Em(value) + | crate::Dimension::Ex(value) + | crate::Dimension::Rem(value) + | crate::Dimension::Vw(value) + | crate::Dimension::Vh(value) + | crate::Dimension::Vmin(value) + | crate::Dimension::Vmax(value) => value.is_finite(), + }; + if !finite(x) || !finite(y) { + return None; + } + points.push((x, y)); + } + Some(crate::ClipPathPolygon { fill_rule, points }) +} + /// Parse every length token in a value as px (for box shorthands). fn edges(value: &str) -> Option { let dims: Vec = value.split_whitespace().filter_map(px_value).collect(); @@ -4879,6 +4960,46 @@ fn edges_from(dims: Vec) -> Option { mod tests { use super::*; + #[test] + fn clip_path_polygon_supports_only_geometry_it_can_resolve() { + assert!(supports_declaration( + "clip-path", + "polygon(0 0, 100% 0, 50% 90px)" + )); + assert!(supports_declaration( + "-webkit-clip-path", + "polygon(evenodd, 0 0, 10rem 0, 10rem 10vh) border-box" + )); + for unsupported in [ + "polygon(0 0, 100% 0, 50% 100%) content-box", + "content-box polygon(0 0, 100% 0, 50% 100%)", + "polygon(0, 100% 0)", + "polygon(evenodd 0 0, 100% 0)", + "polygon(0 0, calc(100% - 1px) 0, 0 100%)", + "circle(50%)", + ] { + assert!( + !supports_declaration("clip-path", unsupported), + "@supports must not advertise unpainted clip geometry: {unsupported}" + ); + } + + let style = compute_style( + "div", + Some("clip-path:polygon(evenodd, -10px 0, 100% 0, 50% 2em)"), + ); + let polygon = style.clip_path.expect("computed polygon"); + assert_eq!(polygon.fill_rule, crate::ClipPathFillRule::Evenodd); + assert_eq!( + polygon.points, + vec![ + (crate::Dimension::Px(-10.0), crate::Dimension::Px(0.0)), + (crate::Dimension::Percent(1.0), crate::Dimension::Px(0.0)), + (crate::Dimension::Percent(0.5), crate::Dimension::Em(2.0)), + ] + ); + } + #[test] fn parses_display_and_size() { let s = compute_style("div", Some("display: flex; width: 200px; height: 50px"));