diff --git a/crates/obscura-render/src/border.rs b/crates/obscura-render/src/border.rs new file mode 100644 index 0000000..83c92f7 --- /dev/null +++ b/crates/obscura-render/src/border.rs @@ -0,0 +1,452 @@ +//! Typed CSS border and outline state shared by style, layout, and paint. +//! +//! CSS keeps the specified border width even while `none` or `hidden` makes +//! the *used* width zero. Keeping those two values distinct is what lets a +//! later `border-style: solid` restore the previously specified width without +//! corrupting box geometry in the meantime. + +/// CSS's initial `medium` border and outline width in CSS pixels. +pub const MEDIUM_BORDER_WIDTH: f32 = 3.0; + +/// Four physical sides in CSS top-right-bottom-left order. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Sides { + pub top: T, + pub right: T, + pub bottom: T, + pub left: T, +} + +impl Sides { + pub const fn all(value: T) -> Self { + Self { + top: value, + right: value, + bottom: value, + left: value, + } + } + + pub fn map(self, mut f: impl FnMut(T) -> U) -> Sides { + Sides { + top: f(self.top), + right: f(self.right), + bottom: f(self.bottom), + left: f(self.left), + } + } + + pub const fn as_array(self) -> [T; 4] { + [self.top, self.right, self.bottom, self.left] + } +} + +impl Default for Sides { + fn default() -> Self { + Self { + top: T::default(), + right: T::default(), + bottom: T::default(), + left: T::default(), + } + } +} + +/// Expand a CSS 1--4 value list in top-right-bottom-left order. +pub fn expand_sides(values: &[T]) -> Option> { + match values { + [all] => Some(Sides::all(*all)), + [vertical, horizontal] => Some(Sides { + top: *vertical, + right: *horizontal, + bottom: *vertical, + left: *horizontal, + }), + [top, horizontal, bottom] => Some(Sides { + top: *top, + right: *horizontal, + bottom: *bottom, + left: *horizontal, + }), + [top, right, bottom, left] => Some(Sides { + top: *top, + right: *right, + bottom: *bottom, + left: *left, + }), + _ => None, + } +} + +/// The CSS border-line styles represented by the renderer. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum BorderStyle { + #[default] + None, + Hidden, + Dotted, + Dashed, + Solid, + Double, + Groove, + Ridge, + Inset, + Outset, + /// `outline-style:auto`; border sides themselves never use this value. + Auto, +} + +impl BorderStyle { + /// Whether this style contributes a used width and visible border paint. + pub const fn is_visible(self) -> bool { + !matches!(self, Self::None | Self::Hidden) + } + + pub const fn css_name(self) -> &'static str { + match self { + Self::None => "none", + Self::Hidden => "hidden", + Self::Dotted => "dotted", + Self::Dashed => "dashed", + Self::Solid => "solid", + Self::Double => "double", + Self::Groove => "groove", + Self::Ridge => "ridge", + Self::Inset => "inset", + Self::Outset => "outset", + Self::Auto => "auto", + } + } +} + +/// Four independent specified border widths, styles, and colors. +/// +/// A `None` color means CSS `currentcolor`, not transparent or absent. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BorderModel { + pub specified_widths: Sides, + pub styles: Sides, + pub colors: Sides>, + pub radii: BorderRadii, +} + +impl Default for BorderModel { + fn default() -> Self { + Self { + specified_widths: Sides::all(MEDIUM_BORDER_WIDTH), + styles: Sides::all(BorderStyle::None), + colors: Sides::all(None), + radii: BorderRadii::default(), + } + } +} + +impl BorderModel { + /// Used widths after applying CSS Backgrounds 3's `none`/`hidden` rule. + pub fn used_widths(self) -> Sides { + Sides { + top: used_width(self.specified_widths.top, self.styles.top), + right: used_width(self.specified_widths.right, self.styles.right), + bottom: used_width(self.specified_widths.bottom, self.styles.bottom), + left: used_width(self.specified_widths.left, self.styles.left), + } + } +} + +fn used_width(specified: f32, style: BorderStyle) -> f32 { + if style.is_visible() { + specified.max(0.0) + } else { + 0.0 + } +} + +/// Uniform outline state. Outlines paint outside the border edge and never +/// participate in layout geometry. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OutlineModel { + pub specified_width: f32, + pub style: BorderStyle, + /// `None` is CSS `currentcolor`. + pub color: Option<[u8; 4]>, + pub offset: f32, +} + +impl Default for OutlineModel { + fn default() -> Self { + Self { + specified_width: MEDIUM_BORDER_WIDTH, + style: BorderStyle::None, + color: None, + offset: 0.0, + } + } +} + +impl OutlineModel { + pub fn used_width(self) -> f32 { + used_width(self.specified_width, self.style) + } +} + +/// One CSS `` component of a corner radius. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct RadiusValue { + pub length: f32, + /// Fraction of the corresponding border-box axis (`0.5` is `50%`). + pub percentage: f32, +} + +impl RadiusValue { + pub const fn pixels(length: f32) -> Self { + Self { + length, + percentage: 0.0, + } + } + + pub const fn percentage(percentage: f32) -> Self { + Self { + length: 0.0, + percentage, + } + } + + pub fn resolve(self, axis: f32) -> f32 { + (self.length + self.percentage * axis).max(0.0) + } + + pub fn is_zero(self) -> bool { + self.length == 0.0 && self.percentage == 0.0 + } +} + +/// Horizontal and vertical radii for one corner. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct CornerRadius { + pub x: RadiusValue, + pub y: RadiusValue, +} + +impl CornerRadius { + pub const fn circular(value: RadiusValue) -> Self { + Self { x: value, y: value } + } +} + +/// Four elliptical radii in clockwise order from the top-left corner. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct BorderRadii { + pub top_left: CornerRadius, + pub top_right: CornerRadius, + pub bottom_right: CornerRadius, + pub bottom_left: CornerRadius, +} + +impl BorderRadii { + pub const fn all(radius: CornerRadius) -> Self { + Self { + top_left: radius, + top_right: radius, + bottom_right: radius, + bottom_left: radius, + } + } + + /// Resolve percentages and apply CSS Backgrounds 3's corner-overlap rule. + /// One common scale factor preserves every ellipse's aspect ratio. + pub fn resolve(self, width: f32, height: f32) -> ResolvedBorderRadii { + let mut resolved = ResolvedBorderRadii { + top_left: resolve_corner(self.top_left, width, height), + top_right: resolve_corner(self.top_right, width, height), + bottom_right: resolve_corner(self.bottom_right, width, height), + bottom_left: resolve_corner(self.bottom_left, width, height), + }; + let ratio = |available: f32, requested: f32| { + if requested > 0.0 { + available.max(0.0) / requested + } else { + 1.0 + } + }; + let scale = 1.0f32 + .min(ratio(width, resolved.top_left.0 + resolved.top_right.0)) + .min(ratio(width, resolved.bottom_left.0 + resolved.bottom_right.0)) + .min(ratio(height, resolved.top_left.1 + resolved.bottom_left.1)) + .min(ratio(height, resolved.top_right.1 + resolved.bottom_right.1)); + if scale < 1.0 { + resolved = resolved.scaled(scale); + } + resolved + } + + pub fn is_zero(self) -> bool { + self.top_left.x.is_zero() + && self.top_left.y.is_zero() + && self.top_right.x.is_zero() + && self.top_right.y.is_zero() + && self.bottom_right.x.is_zero() + && self.bottom_right.y.is_zero() + && self.bottom_left.x.is_zero() + && self.bottom_left.y.is_zero() + } +} + +fn resolve_corner(radius: CornerRadius, width: f32, height: f32) -> (f32, f32) { + let x = radius.x.resolve(width); + let y = radius.y.resolve(height); + // CSS treats a corner as square when either axis has a zero radius. + if x <= f32::EPSILON || y <= f32::EPSILON { + (0.0, 0.0) + } else { + (x, y) + } +} + +/// Final used radii in CSS pixels after percentage resolution and overlap +/// scaling. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct ResolvedBorderRadii { + pub top_left: (f32, f32), + pub top_right: (f32, f32), + pub bottom_right: (f32, f32), + pub bottom_left: (f32, f32), +} + +impl ResolvedBorderRadii { + pub fn scaled(self, scale: f32) -> Self { + let scale_corner = |(x, y): (f32, f32)| (x * scale, y * scale); + Self { + top_left: scale_corner(self.top_left), + top_right: scale_corner(self.top_right), + bottom_right: scale_corner(self.bottom_right), + bottom_left: scale_corner(self.bottom_left), + } + } + + pub fn is_zero(self) -> bool { + self.top_left == (0.0, 0.0) + && self.top_right == (0.0, 0.0) + && self.bottom_right == (0.0, 0.0) + && self.bottom_left == (0.0, 0.0) + } + + pub fn is_uniform(self) -> bool { + self.top_left == self.top_right + && self.top_right == self.bottom_right + && self.bottom_right == self.bottom_left + } + + /// Inner radii after removing the used border widths on each axis. + pub fn inset(self, widths: Sides) -> Self { + Self { + top_left: shrink_corner(self.top_left, widths.left, widths.top), + top_right: shrink_corner(self.top_right, widths.right, widths.top), + bottom_right: shrink_corner( + self.bottom_right, + widths.right, + widths.bottom, + ), + bottom_left: shrink_corner(self.bottom_left, widths.left, widths.bottom), + } + } + + /// Outer radii for an outline or shadow expanded from the border edge. + pub fn outset(self, amounts: Sides) -> Self { + Self { + top_left: grow_corner(self.top_left, amounts.left, amounts.top), + top_right: grow_corner(self.top_right, amounts.right, amounts.top), + bottom_right: grow_corner( + self.bottom_right, + amounts.right, + amounts.bottom, + ), + bottom_left: grow_corner(self.bottom_left, amounts.left, amounts.bottom), + } + } +} + +fn shrink_corner((x, y): (f32, f32), horizontal: f32, vertical: f32) -> (f32, f32) { + let x = (x - horizontal).max(0.0); + let y = (y - vertical).max(0.0); + if x <= f32::EPSILON || y <= f32::EPSILON { + (0.0, 0.0) + } else { + (x, y) + } +} + +fn grow_corner((x, y): (f32, f32), horizontal: f32, vertical: f32) -> (f32, f32) { + // Expanding a square corner must keep it square. Only an existing curve + // receives the extra outline/shadow distance on each axis. + if x <= f32::EPSILON || y <= f32::EPSILON { + return (0.0, 0.0); + } + let x = (x + horizontal).max(0.0); + let y = (y + vertical).max(0.0); + if x <= f32::EPSILON || y <= f32::EPSILON { + (0.0, 0.0) + } else { + (x, y) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn side_expansion_follows_css_trbl_rules() { + assert_eq!(expand_sides(&[1]), Some(Sides::all(1))); + assert_eq!( + expand_sides(&[1, 2, 3]), + Some(Sides { + top: 1, + right: 2, + bottom: 3, + left: 2, + }) + ); + assert_eq!(expand_sides::(&[]), None); + assert_eq!(expand_sides(&[1, 2, 3, 4, 5]), None); + } + + #[test] + fn hidden_style_zeroes_only_the_used_width() { + let mut border = BorderModel::default(); + border.specified_widths = Sides::all(10.0); + border.styles = Sides::all(BorderStyle::None); + assert_eq!(border.used_widths(), Sides::all(0.0)); + border.styles.left = BorderStyle::Solid; + assert_eq!(border.used_widths().left, 10.0); + assert_eq!(border.specified_widths.left, 10.0); + } + + #[test] + fn elliptical_radii_use_one_overlap_scale() { + let radii = BorderRadii { + top_left: CornerRadius { + x: RadiusValue::pixels(80.0), + y: RadiusValue::pixels(50.0), + }, + top_right: CornerRadius { + x: RadiusValue::pixels(60.0), + y: RadiusValue::pixels(40.0), + }, + bottom_right: CornerRadius { + x: RadiusValue::pixels(40.0), + y: RadiusValue::pixels(30.0), + }, + bottom_left: CornerRadius { + x: RadiusValue::pixels(20.0), + y: RadiusValue::pixels(10.0), + }, + }; + let resolved = radii.resolve(100.0, 50.0); + let scale = 5.0 / 7.0; + assert!((resolved.top_left.0 - 80.0 * scale).abs() < 0.001); + assert!((resolved.top_left.1 - 50.0 * scale).abs() < 0.001); + assert!((resolved.bottom_right.0 - 40.0 * scale).abs() < 0.001); + assert!((resolved.bottom_left.1 - 10.0 * scale).abs() < 0.001); + } +} diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index b1af896..0239e35 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -6439,7 +6439,7 @@ fn pseudo_requires_generated_box(style: &crate::LayoutStyle, content: Option<&st || style.background_image.is_some() || style.mask_image.is_some() || style.box_shadow.is_some() - || !style.border_radius.is_zero() + || !style.border_model.radii.is_zero() || style.overflow_hidden || style.transform_translate.is_some() || style.individual_translate.is_some() diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index fa4d506..c469b9e 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -19,6 +19,12 @@ pub use css::Stylesheet; pub mod style; pub use style::compute_style; +pub mod border; +pub use border::{ + BorderModel, BorderRadii, BorderStyle, CornerRadius, OutlineModel, RadiusValue, + ResolvedBorderRadii, Sides, +}; + pub mod dom; pub use dom::{ layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout, StickyLayout, @@ -294,37 +300,6 @@ pub struct FontVariationSetting { pub value: f32, } -/// The supported uniform `border-radius` value as a CSS -/// ``. Percentages resolve independently against the -/// border box's width and height, so `50%` produces an ellipse on a rectangle -/// and a circle on a square. -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct BorderRadius { - length: f32, - percentage: f32, -} - -impl BorderRadius { - pub const fn pixels(length: f32) -> Self { - Self { length, percentage: 0.0 } - } - - pub const fn percentage(percentage: f32) -> Self { - Self { length: 0.0, percentage } - } - - pub fn resolve(self, width: f32, height: f32) -> (f32, f32) { - ( - (self.length + self.percentage * width).max(0.0), - (self.length + self.percentage * height).max(0.0), - ) - } - - pub fn is_zero(self) -> bool { - self.length == 0.0 && self.percentage == 0.0 - } -} - /// One axis of a CSS `background-position`. /// /// CSS positions combine an absolute offset with a percentage of the space @@ -592,9 +567,13 @@ pub struct LayoutStyle { /// Deferred `calc()`/`min()`/`max()`/`clamp()` padding expressions. pub padding_expressions: [Option; 4], pub border: Edges, - /// `border-radius` (uniform; the first value of the shorthand). Rounds the - /// background fill, replaced content, border, and shadow. - pub border_radius: BorderRadius, + /// Specified border state. `border` above is the derived used-width view + /// consumed by layout; it is zero on `none`/`hidden` sides while this + /// model retains their specified widths for later cascade declarations. + pub border_model: BorderModel, + /// Outline paint state. It deliberately has no counterpart in Taffy: + /// outlines never contribute to box geometry. + pub outline: OutlineModel, /// `clip-path: polygon(...)`, resolved against the final border box at /// paint time. `None` is the computed `none` value. pub clip_path: Option, @@ -666,6 +645,8 @@ pub struct LayoutStyle { pub svg_fill: Option, pub svg_stroke: Option, pub svg_stroke_width: Option, + /// Compatibility mirror for uniform border colors. New code should use + /// `border_model.colors`; this remains for programmatic LayoutStyle users. 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` diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 9d70af4..6bbd14f 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -616,19 +616,42 @@ impl PreparedRender { ] { out.insert(name, css_px(value)); } - let border_color = css_color(style.border_color.or(style.color).unwrap_or([0, 0, 0, 255])); - out.insert("border-top-color", border_color.clone()); - out.insert("border-right-color", border_color.clone()); - out.insert("border-bottom-color", border_color.clone()); - out.insert("border-left-color", border_color); - for (name, width) in [ - ("border-top-style", style.border.top), - ("border-right-style", style.border.right), - ("border-bottom-style", style.border.bottom), - ("border-left-style", style.border.left), + let current_color = style.color.unwrap_or([0, 0, 0, 255]); + for (name, color) in [ + ("border-top-color", style.border_model.colors.top), + ("border-right-color", style.border_model.colors.right), + ("border-bottom-color", style.border_model.colors.bottom), + ("border-left-color", style.border_model.colors.left), ] { - out.insert(name, if width > 0.0 { "solid" } else { "none" }.to_string()); + out.insert( + name, + css_color(color.or(style.border_color).unwrap_or(current_color)), + ); } + let effective_border_styles = effective_border_styles(style); + for (name, line_style) in [ + ("border-top-style", effective_border_styles.top), + ("border-right-style", effective_border_styles.right), + ("border-bottom-style", effective_border_styles.bottom), + ("border-left-style", effective_border_styles.left), + ] { + out.insert(name, line_style.css_name().to_string()); + } + for (name, radius) in [ + ("border-top-left-radius", style.border_model.radii.top_left), + ("border-top-right-radius", style.border_model.radii.top_right), + ("border-bottom-right-radius", style.border_model.radii.bottom_right), + ("border-bottom-left-radius", style.border_model.radii.bottom_left), + ] { + out.insert(name, corner_radius_css(radius)); + } + out.insert("outline-width", css_px(style.outline.used_width())); + out.insert("outline-style", style.outline.style.css_name().to_string()); + out.insert( + "outline-color", + css_color(style.outline.color.unwrap_or(current_color)), + ); + out.insert("outline-offset", css_px(style.outline.offset)); out.insert( "flex-direction", @@ -800,6 +823,24 @@ fn dimension_css(value: crate::Dimension, auto: &str) -> String { } } +fn radius_value_css(value: crate::RadiusValue) -> String { + match (value.length, value.percentage) { + (length, 0.0) => css_px(length), + (0.0, percentage) => format!("{}%", css_number(percentage * 100.0)), + (length, percentage) => format!( + "calc({} + {}%)", + css_px(length), + css_number(percentage * 100.0) + ), + } +} + +fn corner_radius_css(radius: crate::CornerRadius) -> String { + let x = radius_value_css(radius.x); + let y = radius_value_css(radius.y); + if x == y { x } else { format!("{x} {y}") } +} + fn css_color([r, g, b, a]: [u8; 4]) -> String { if a == 255 { format!("rgb({r}, {g}, {b})") @@ -1409,21 +1450,20 @@ fn paint_laid_dom_scrolled( // ancestor overflow clip is reapplied inside so the shadow is clipped by // an ancestor exactly as the box itself is. if let Some(shadow) = style.box_shadow { - paint_box_shadow(&mut pixmap, &shadow, &rect, style.border_radius, clip); + paint_box_shadow(&mut pixmap, &shadow, &rect, style.border_model.radii, clip); } // Box path (rounded if border-radius), reused for gradient/color fill. - let radius = style.border_radius.resolve(rect.width, rect.height); - let has_radius = radius.0 > 0.5 && radius.1 > 0.5; + let radius = style.border_model.radii.resolve(rect.width, rect.height); + let has_radius = !radius.is_zero(); let bg_path = || { if has_radius { - rounded_rect_path( + rounded_rect_path_radii( visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height, - radius.0, - radius.1, + radius, ) } else { let mut pb = PathBuilder::new(); @@ -1613,73 +1653,18 @@ fn paint_laid_dom_scrolled( ); } - // Rounded, uniform border: stroke the rounded-rect outline instead of - // four sharp edge rects. - let uniform_border = style.border.top == style.border.right - && style.border.right == style.border.bottom - && style.border.bottom == style.border.left - && style.border.top > 0.0; - if has_radius && uniform_border { - let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]); - let w = style.border.top; - if let Some(path) = rounded_rect_path( - rect.x + w / 2.0, - rect.y + w / 2.0, - rect.width - w, - rect.height - w, - (radius.0 - w / 2.0).max(0.0), - (radius.1 - w / 2.0).max(0.0), - ) { - let mut paint = Paint::default(); - 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(), - 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]); - let mut paint = Paint::default(); - paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3])); - paint.anti_alias = false; - - let mut path = PathBuilder::new(); - let mut push_clipped = |x: f32, y: f32, w: f32, h: f32| { - let edge = crate::Rect { x, y, width: w, height: h }; - let edge = match clip { Some(c) => edge.intersect(&c), None => Some(edge) }; - if let Some(e) = edge { - if let Some(r) = Rect::from_xywh(e.x, e.y, e.width, e.height) { - path.push_rect(r); - } - } - }; - if style.border.top > 0.0 { - push_clipped(rect.x, rect.y, rect.width, style.border.top); - } - if style.border.right > 0.0 { - push_clipped(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height); - } - if style.border.bottom > 0.0 { - push_clipped(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom); - } - if style.border.left > 0.0 { - 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(), - clip_path_mask.as_ref(), - ); - } - } + paint_css_border( + &mut pixmap, + &rect, + style, + clip_path_mask.as_ref(), + ); + paint_css_outline( + &mut pixmap, + &rect, + style, + clip_path_mask.as_ref(), + ); if name.local.as_ref() == "img" { if let Some(source) = selected_images.get(&nid) { @@ -1775,7 +1760,7 @@ fn paint_laid_dom_scrolled( &svg_fonts, ) { let mask = if clip.is_some() || has_radius { - rounded_box_clip_mask( + rounded_box_clip_mask_radii( pixmap.width(), pixmap.height(), &visible_rect, @@ -2322,34 +2307,518 @@ fn rounded_rect_path( h: f32, rx: f32, ry: f32, +) -> Option { + rounded_rect_path_radii( + x, + y, + w, + h, + crate::ResolvedBorderRadii { + top_left: (rx, ry), + top_right: (rx, ry), + bottom_right: (rx, ry), + bottom_left: (rx, ry), + }, + ) +} + +fn rounded_rect_path_radii( + x: f32, + y: f32, + w: f32, + h: f32, + radii: crate::ResolvedBorderRadii, ) -> Option { if w <= 0.0 || h <= 0.0 { return None; } - let mut rx = rx.max(0.0); - let mut ry = ry.max(0.0); - if rx <= f32::EPSILON || ry <= f32::EPSILON { + if radii.is_zero() { let mut pb = PathBuilder::new(); pb.push_rect(Rect::from_xywh(x, y, w, h)?); return pb.finish(); } - let scale = 1.0f32.min(w / (2.0 * rx)).min(h / (2.0 * ry)); - rx *= scale; - ry *= scale; + let tl = radii.top_left; + let tr = radii.top_right; + let br = radii.bottom_right; + let bl = radii.bottom_left; let mut pb = PathBuilder::new(); - pb.move_to(x + rx, y); - pb.line_to(x + w - rx, y); - pb.quad_to(x + w, y, x + w, y + ry); - pb.line_to(x + w, y + h - ry); - pb.quad_to(x + w, y + h, x + w - rx, y + h); - pb.line_to(x + rx, y + h); - pb.quad_to(x, y + h, x, y + h - ry); - pb.line_to(x, y + ry); - pb.quad_to(x, y, x + rx, y); + pb.move_to(x + tl.0, y); + pb.line_to(x + w - tr.0, y); + pb.quad_to(x + w, y, x + w, y + tr.1); + pb.line_to(x + w, y + h - br.1); + pb.quad_to(x + w, y + h, x + w - br.0, y + h); + pb.line_to(x + bl.0, y + h); + pb.quad_to(x, y + h, x, y + h - bl.1); + pb.line_to(x, y + tl.1); + pb.quad_to(x, y, x + tl.0, y); pb.close(); pb.finish() } +#[derive(Clone, Copy)] +enum Side { + Top, + Right, + Bottom, + Left, +} + +fn effective_border_styles(style: &crate::LayoutStyle) -> crate::Sides { + let mut styles = style.border_model.styles; + // Preserve the public LayoutStyle contract for embedding code and older + // renderer tests that construct used `border` edges directly. Cascaded + // CSS never reaches this branch: none/hidden declarations synchronize the + // used edges to zero before layout. + if style.border.top > 0.0 && !styles.top.is_visible() { + styles.top = crate::BorderStyle::Solid; + } + if style.border.right > 0.0 && !styles.right.is_visible() { + styles.right = crate::BorderStyle::Solid; + } + if style.border.bottom > 0.0 && !styles.bottom.is_visible() { + styles.bottom = crate::BorderStyle::Solid; + } + if style.border.left > 0.0 && !styles.left.is_visible() { + styles.left = crate::BorderStyle::Solid; + } + styles +} + +fn paint_css_border( + pixmap: &mut Pixmap, + rect: &crate::Rect, + style: &crate::LayoutStyle, + mask: Option<&tiny_skia::Mask>, +) { + let widths = crate::Sides { + top: style.border.top, + right: style.border.right, + bottom: style.border.bottom, + left: style.border.left, + }; + if widths.as_array().iter().all(|width| *width <= 0.0) { + return; + } + let current = style.color.unwrap_or([0, 0, 0, 255]); + let colors = style + .border_model + .colors + .map(|color| color.or(style.border_color).unwrap_or(current)); + let styles = effective_border_styles(style); + let radii = style.border_model.radii.resolve(rect.width, rect.height); + let uniform = widths.top == widths.right + && widths.right == widths.bottom + && widths.bottom == widths.left + && styles.top == styles.right + && styles.right == styles.bottom + && styles.bottom == styles.left + && colors.top == colors.right + && colors.right == colors.bottom + && colors.bottom == colors.left; + if uniform + && !matches!( + styles.top, + crate::BorderStyle::Inset + | crate::BorderStyle::Outset + | crate::BorderStyle::Groove + | crate::BorderStyle::Ridge + ) + { + paint_uniform_border( + pixmap, + rect, + widths.top, + styles.top, + colors.top, + radii, + mask, + ); + return; + } + + for side in [Side::Top, Side::Right, Side::Bottom, Side::Left] { + let (width, line_style, color) = match side { + Side::Top => (widths.top, styles.top, colors.top), + Side::Right => (widths.right, styles.right, colors.right), + Side::Bottom => (widths.bottom, styles.bottom, colors.bottom), + Side::Left => (widths.left, styles.left, colors.left), + }; + if width <= 0.0 || !line_style.is_visible() { + continue; + } + match line_style { + crate::BorderStyle::Solid | crate::BorderStyle::Auto => { + fill_solid_border_side(pixmap, rect, widths, radii, side, color, mask); + } + crate::BorderStyle::Inset + | crate::BorderStyle::Outset + | crate::BorderStyle::Groove + | crate::BorderStyle::Ridge => { + // CSS's relief styles are two-tone. Preserve the directional + // light source even in the compact painter; groove/ridge use + // the same side polarity as inset/outset at narrow widths. + let top_left = matches!(side, Side::Top | Side::Left); + let dark_side = match line_style { + crate::BorderStyle::Inset | crate::BorderStyle::Groove => top_left, + crate::BorderStyle::Outset | crate::BorderStyle::Ridge => !top_left, + _ => false, + }; + let color = shade_border_color(color, if dark_side { -0.28 } else { 0.28 }); + fill_solid_border_side(pixmap, rect, widths, radii, side, color, mask); + } + crate::BorderStyle::Double => { + if width < 3.0 { + fill_solid_border_side(pixmap, rect, widths, radii, side, color, mask); + } else { + paint_straight_border_side( + pixmap, + rect, + side, + width / 3.0, + width / 6.0, + color, + None, + mask, + ); + paint_straight_border_side( + pixmap, + rect, + side, + width / 3.0, + width * 5.0 / 6.0, + color, + None, + mask, + ); + } + } + crate::BorderStyle::Dashed | crate::BorderStyle::Dotted => { + paint_straight_border_side( + pixmap, + rect, + side, + width, + width / 2.0, + color, + Some(line_style), + mask, + ); + } + crate::BorderStyle::None | crate::BorderStyle::Hidden => {} + } + } +} + +fn paint_css_outline( + pixmap: &mut Pixmap, + rect: &crate::Rect, + style: &crate::LayoutStyle, + mask: Option<&tiny_skia::Mask>, +) { + let width = style.outline.used_width(); + if width <= 0.0 { + return; + } + let center_expansion = style.outline.offset + width / 2.0; + let center_rect = crate::Rect { + x: rect.x - center_expansion, + y: rect.y - center_expansion, + width: rect.width + 2.0 * center_expansion, + height: rect.height + 2.0 * center_expansion, + }; + if center_rect.width <= 0.0 || center_rect.height <= 0.0 { + return; + } + let radii = style + .border_model + .radii + .resolve(rect.width, rect.height) + .outset(crate::Sides::all(center_expansion)); + let color = style + .outline + .color + .or(style.color) + .unwrap_or([0, 0, 0, 255]); + stroke_rounded_border_path( + pixmap, + ¢er_rect, + width, + style.outline.style, + color, + radii, + mask, + ); +} + +fn paint_uniform_border( + pixmap: &mut Pixmap, + rect: &crate::Rect, + width: f32, + line_style: crate::BorderStyle, + color: [u8; 4], + radii: crate::ResolvedBorderRadii, + mask: Option<&tiny_skia::Mask>, +) { + if width <= 0.0 || !line_style.is_visible() { + return; + } + if line_style == crate::BorderStyle::Double && width >= 3.0 { + let stripe = width / 3.0; + for inset in [stripe / 2.0, width - stripe / 2.0] { + let stripe_rect = crate::Rect { + x: rect.x + inset, + y: rect.y + inset, + width: rect.width - 2.0 * inset, + height: rect.height - 2.0 * inset, + }; + if stripe_rect.width > 0.0 && stripe_rect.height > 0.0 { + stroke_rounded_border_path( + pixmap, + &stripe_rect, + stripe, + crate::BorderStyle::Solid, + color, + radii.inset(crate::Sides::all(inset)), + mask, + ); + } + } + return; + } + let center = width / 2.0; + let center_rect = crate::Rect { + x: rect.x + center, + y: rect.y + center, + width: rect.width - width, + height: rect.height - width, + }; + if center_rect.width <= 0.0 || center_rect.height <= 0.0 { + return; + } + stroke_rounded_border_path( + pixmap, + ¢er_rect, + width, + line_style, + color, + radii.inset(crate::Sides::all(center)), + mask, + ); +} + +fn stroke_rounded_border_path( + pixmap: &mut Pixmap, + center_rect: &crate::Rect, + width: f32, + line_style: crate::BorderStyle, + color: [u8; 4], + radii: crate::ResolvedBorderRadii, + mask: Option<&tiny_skia::Mask>, +) { + let Some(path) = rounded_rect_path_radii( + center_rect.x, + center_rect.y, + center_rect.width, + center_rect.height, + radii, + ) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); + paint.anti_alias = !radii.is_zero(); + let mut stroke = tiny_skia::Stroke { + width, + ..Default::default() + }; + match line_style { + crate::BorderStyle::Dashed => { + stroke.dash = tiny_skia::StrokeDash::new(vec![width * 3.0, width * 3.0], 0.0); + } + crate::BorderStyle::Dotted => { + stroke.dash = tiny_skia::StrokeDash::new(vec![0.0, width * 2.0], 0.0); + stroke.line_cap = tiny_skia::LineCap::Round; + } + _ => {} + } + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), mask); +} + +fn fill_solid_border_side( + pixmap: &mut Pixmap, + rect: &crate::Rect, + widths: crate::Sides, + radii: crate::ResolvedBorderRadii, + side: Side, + color: [u8; 4], + mask: Option<&tiny_skia::Mask>, +) { + let Some(path) = solid_border_side_path(rect, widths, radii, side) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); + paint.anti_alias = !radii.is_zero(); + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), mask); +} + +fn arc_point(center: (f32, f32), radius: (f32, f32), degrees: f32) -> (f32, f32) { + let angle = degrees.to_radians(); + ( + center.0 + radius.0 * angle.cos(), + center.1 + radius.1 * angle.sin(), + ) +} + +fn append_arc( + path: &mut PathBuilder, + center: (f32, f32), + radius: (f32, f32), + start: f32, + end: f32, +) { + for step in 0..=4 { + let angle = start + (end - start) * step as f32 / 4.0; + let point = arc_point(center, radius, angle); + path.line_to(point.0, point.1); + } +} + +fn solid_border_side_path( + rect: &crate::Rect, + widths: crate::Sides, + outer: crate::ResolvedBorderRadii, + side: Side, +) -> Option { + let inner = outer.inset(widths); + let x = rect.x; + let y = rect.y; + let right = x + rect.width; + let bottom = y + rect.height; + let ix = x + widths.left; + let iy = y + widths.top; + let iright = right - widths.right; + let ibottom = bottom - widths.bottom; + let outer_centers = [ + (x + outer.top_left.0, y + outer.top_left.1), + (right - outer.top_right.0, y + outer.top_right.1), + (right - outer.bottom_right.0, bottom - outer.bottom_right.1), + (x + outer.bottom_left.0, bottom - outer.bottom_left.1), + ]; + let inner_centers = [ + (ix + inner.top_left.0, iy + inner.top_left.1), + (iright - inner.top_right.0, iy + inner.top_right.1), + (iright - inner.bottom_right.0, ibottom - inner.bottom_right.1), + (ix + inner.bottom_left.0, ibottom - inner.bottom_left.1), + ]; + let outer_radii = [outer.top_left, outer.top_right, outer.bottom_right, outer.bottom_left]; + let inner_radii = [inner.top_left, inner.top_right, inner.bottom_right, inner.bottom_left]; + let (a, b, outer_a, outer_b, inner_b, inner_a) = match side { + Side::Top => (225.0, 270.0, 0, 1, 1, 0), + Side::Right => (315.0, 360.0, 1, 2, 2, 1), + Side::Bottom => (45.0, 90.0, 2, 3, 3, 2), + Side::Left => (135.0, 180.0, 3, 0, 0, 3), + }; + let second_start = match side { + Side::Top => 270.0, + Side::Right => 0.0, + Side::Bottom => 90.0, + Side::Left => 180.0, + }; + let second_end = match side { + Side::Top => 315.0, + Side::Right => 45.0, + Side::Bottom => 135.0, + Side::Left => 225.0, + }; + let mut path = PathBuilder::new(); + let start = arc_point(outer_centers[outer_a], outer_radii[outer_a], a); + path.move_to(start.0, start.1); + append_arc(&mut path, outer_centers[outer_a], outer_radii[outer_a], a, b); + append_arc( + &mut path, + outer_centers[outer_b], + outer_radii[outer_b], + second_start, + second_end, + ); + append_arc( + &mut path, + inner_centers[inner_b], + inner_radii[inner_b], + second_end, + second_start, + ); + append_arc( + &mut path, + inner_centers[inner_a], + inner_radii[inner_a], + b, + a, + ); + path.close(); + path.finish() +} + +fn paint_straight_border_side( + pixmap: &mut Pixmap, + rect: &crate::Rect, + side: Side, + stroke_width: f32, + inward: f32, + color: [u8; 4], + pattern: Option, + mask: Option<&tiny_skia::Mask>, +) { + let mut path = PathBuilder::new(); + match side { + Side::Top => { + path.move_to(rect.x, rect.y + inward); + path.line_to(rect.x + rect.width, rect.y + inward); + } + Side::Right => { + path.move_to(rect.x + rect.width - inward, rect.y); + path.line_to(rect.x + rect.width - inward, rect.y + rect.height); + } + Side::Bottom => { + path.move_to(rect.x + rect.width, rect.y + rect.height - inward); + path.line_to(rect.x, rect.y + rect.height - inward); + } + Side::Left => { + path.move_to(rect.x + inward, rect.y + rect.height); + path.line_to(rect.x + inward, rect.y); + } + } + let Some(path) = path.finish() else { return }; + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); + let mut stroke = tiny_skia::Stroke { + width: stroke_width, + ..Default::default() + }; + if pattern == Some(crate::BorderStyle::Dashed) { + stroke.dash = tiny_skia::StrokeDash::new( + vec![stroke_width * 3.0, stroke_width * 3.0], + 0.0, + ); + } else if pattern == Some(crate::BorderStyle::Dotted) { + stroke.dash = tiny_skia::StrokeDash::new(vec![0.0, stroke_width * 2.0], 0.0); + stroke.line_cap = tiny_skia::LineCap::Round; + } + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), mask); +} + +fn shade_border_color(color: [u8; 4], amount: f32) -> [u8; 4] { + let target = if amount >= 0.0 { 255.0 } else { 0.0 }; + let factor = amount.abs().min(1.0); + let channel = |value: u8| { + (value as f32 + (target - value as f32) * factor) + .round() + .clamp(0.0, 255.0) as u8 + }; + [channel(color[0]), channel(color[1]), channel(color[2]), color[3]] +} + /// Paint an outset `box-shadow` layer behind the element's own box. `rect` is /// the element's (translate-adjusted) border box; the shadow is that box offset /// by (offset_x, offset_y), expanded by `spread`, with a `blur`-wide soft edge. @@ -2364,7 +2833,7 @@ fn paint_box_shadow( pixmap: &mut Pixmap, shadow: &crate::BoxShadow, rect: &crate::Rect, - border_radius: crate::BorderRadius, + border_radius: crate::BorderRadii, clip: Option, ) { if shadow.inset || shadow.color[3] == 0 { @@ -2378,7 +2847,7 @@ fn paint_box_shadow( if w0 <= 0.0 || h0 <= 0.0 { return; } - let radius = border_radius.resolve(rect.width, rect.height); + let radius = border_radius.resolve(rect.width, rect.height).top_left; let rx0 = (radius.0 + spread).max(0.0); let ry0 = (radius.1 + spread).max(0.0); let blur = shadow.blur.max(0.0); @@ -3491,7 +3960,7 @@ fn paint_background_gradient_layers( pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &crate::Rect, - border_radius: (f32, f32), + border_radius: crate::ResolvedBorderRadii, style: &crate::LayoutStyle, root_font_size: f32, viewport: (f32, f32), @@ -3523,7 +3992,7 @@ fn paint_background_gradient_layers( &mut tile, &tile_path, &tile_rect, - (0.0, 0.0), + crate::ResolvedBorderRadii::default(), layers, em, root_font_size, @@ -3628,7 +4097,7 @@ fn paint_gradient_layer_stack( pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &crate::Rect, - border_radius: (f32, f32), + border_radius: crate::ResolvedBorderRadii, layers: &[crate::BackgroundGradientLayer], em: f32, root_font_size: f32, @@ -3718,7 +4187,7 @@ fn background_gradient_tile_size( fn paint_conic_gradient( pixmap: &mut Pixmap, rect: &crate::Rect, - border_radius: (f32, f32), + border_radius: crate::ResolvedBorderRadii, angle: f32, center: (f32, f32), stops: &[([u8; 4], Option)], @@ -3747,14 +4216,13 @@ fn paint_conic_gradient( } } let mut clip = extra_clip.cloned(); - if border_radius.0 > 0.5 && border_radius.1 > 0.5 { - let path = rounded_rect_path( + if !border_radius.is_zero() { + let path = rounded_rect_path_radii( rect.x, rect.y, rect.width, rect.height, - border_radius.0, - border_radius.1, + border_radius, ); match (clip.as_mut(), path) { (Some(mask), Some(path)) => { @@ -3766,7 +4234,7 @@ fn paint_conic_gradient( ); } (None, _) => { - clip = rounded_box_clip_mask( + clip = rounded_box_clip_mask_radii( pixmap.width(), pixmap.height(), rect, @@ -4109,10 +4577,10 @@ fn paint_in_flow_generated_box( } if let Some(shadow) = style.box_shadow { - paint_box_shadow(pixmap, &shadow, &rect, style.border_radius, clip); + paint_box_shadow(pixmap, &shadow, &rect, style.border_model.radii, clip); } - let radius = style.border_radius.resolve(rect.width, rect.height); - let has_radius = radius.0 > 0.5 && radius.1 > 0.5; + let radius = style.border_model.radii.resolve(rect.width, rect.height); + let has_radius = !radius.is_zero(); let clip_path_mask = style.clip_path.as_ref().and_then(|polygon| { polygon_clip_mask( pixmap.width(), @@ -4125,13 +4593,12 @@ fn paint_in_flow_generated_box( ) }); let path = if has_radius { - rounded_rect_path( + rounded_rect_path_radii( visible.x, visible.y, visible.width, visible.height, - radius.0, - radius.1, + radius, ) } else { Rect::from_xywh(visible.x, visible.y, visible.width, visible.height).and_then(|rect| { @@ -4241,67 +4708,8 @@ fn paint_in_flow_generated_box( } } - let border_color = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]); - let mut border_paint = Paint::default(); - border_paint.set_color(Color::from_rgba8( - border_color[0], - border_color[1], - border_color[2], - border_color[3], - )); - let mut borders = PathBuilder::new(); - let mut push_edge = |edge: crate::Rect| { - let edge = match clip { - Some(clip) => edge.intersect(&clip), - None => Some(edge), - }; - if let Some(edge) = edge { - if let Some(rect) = Rect::from_xywh(edge.x, edge.y, edge.width, edge.height) { - borders.push_rect(rect); - } - } - }; - if style.border.top > 0.0 { - push_edge(crate::Rect { - x: rect.x, - y: rect.y, - width: rect.width, - height: style.border.top, - }); - } - if style.border.right > 0.0 { - push_edge(crate::Rect { - x: rect.x + rect.width - style.border.right, - y: rect.y, - width: style.border.right, - height: rect.height, - }); - } - if style.border.bottom > 0.0 { - push_edge(crate::Rect { - x: rect.x, - y: rect.y + rect.height - style.border.bottom, - width: rect.width, - height: style.border.bottom, - }); - } - if style.border.left > 0.0 { - push_edge(crate::Rect { - x: rect.x, - y: rect.y, - width: style.border.left, - height: rect.height, - }); - } - if let Some(path) = borders.finish() { - pixmap.fill_path( - &path, - &border_paint, - FillRule::Winding, - Transform::identity(), - clip_path_mask.as_ref(), - ); - } + paint_css_border(pixmap, &rect, style, clip_path_mask.as_ref()); + paint_css_outline(pixmap, &rect, style, clip_path_mask.as_ref()); } fn paint_positioned_pseudo( @@ -4377,8 +4785,8 @@ fn paint_positioned_pseudo( None => Some(rect), }; 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 radius = style.border_model.radii.resolve(rect.width, rect.height); + let has_radius = !radius.is_zero(); let clip_path_mask = style.clip_path.as_ref().and_then(|polygon| { polygon_clip_mask( pixmap.width(), @@ -4391,13 +4799,12 @@ fn paint_positioned_pseudo( ) }); let path = if has_radius { - rounded_rect_path( + rounded_rect_path_radii( visible.x, visible.y, visible.width, visible.height, - radius.0, - radius.1, + radius, ) } else { Rect::from_xywh(visible.x, visible.y, visible.width, visible.height).and_then(|rect| { @@ -4505,6 +4912,8 @@ fn paint_positioned_pseudo( ); } } + paint_css_border(pixmap, &rect, style, clip_path_mask.as_ref()); + paint_css_outline(pixmap, &rect, style, clip_path_mask.as_ref()); if let Some(item) = generated_item { let (text_width, text_height) = generated_intrinsic.unwrap_or_default(); // `text-align` positions inline content within the pseudo's content @@ -4882,7 +5291,7 @@ fn paint_image( pixmap: &mut Pixmap, cache: &mut RenderResourceCache, transform: Option, - clip_radius: (f32, f32), + clip_radius: crate::ResolvedBorderRadii, extra_clip: Option<&tiny_skia::Mask>, ) -> bool { if rect.width <= 0.0 || rect.height <= 0.0 { @@ -4921,7 +5330,7 @@ fn paint_image( // intersected with the ancestor overflow clip): `Cover`/`None` can size // 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 has_radius = !clip_radius.is_zero(); let needs_box_clip = has_radius || dest.width > visible_rect.width + 0.5 || dest.height > visible_rect.height + 0.5 @@ -4930,13 +5339,12 @@ fn paint_image( let mut clip = extra_clip.cloned(); if needs_box_clip { let path = if has_radius { - rounded_rect_path( + rounded_rect_path_radii( visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height, - clip_radius.0, - clip_radius.1, + clip_radius, ) } else { Rect::from_xywh( @@ -4959,7 +5367,7 @@ fn paint_image( Transform::identity(), ), (None, _) => { - clip = rounded_box_clip_mask( + clip = rounded_box_clip_mask_radii( pixmap.width(), pixmap.height(), visible_rect, @@ -5043,26 +5451,22 @@ fn box_clip_mask(pw: u32, ph: u32, rect: &crate::Rect) -> Option Option { - if radius.0 <= 0.5 || radius.1 <= 0.5 { + if radii.is_zero() { return box_clip_mask(pw, ph, rect); } let mut mask = tiny_skia::Mask::new(pw, ph)?; - let path = rounded_rect_path( + let path = rounded_rect_path_radii( rect.x, rect.y, rect.width, rect.height, - radius.0, - radius.1, + radii, )?; mask.fill_path(&path, FillRule::Winding, true, Transform::identity()); Some(mask) @@ -5841,7 +6245,7 @@ fn paint_mask( src: &str, base_url: Option<&str>, rect: &crate::Rect, - border_radius: (f32, f32), + border_radius: crate::ResolvedBorderRadii, fill: [u8; 4], radial_gradient: Option<&((f32, f32), Vec<([u8; 4], Option)>)>, linear_gradient: Option<&(f32, Vec<([u8; 4], Option)>)>, @@ -5924,14 +6328,13 @@ fn paint_mask( } } let mut clip = extra_clip.cloned(); - if border_radius.0 > 0.5 && border_radius.1 > 0.5 { - let path = rounded_rect_path( + if !border_radius.is_zero() { + let path = rounded_rect_path_radii( rect.x, rect.y, rect.width, rect.height, - border_radius.0, - border_radius.1, + border_radius, ); match (clip.as_mut(), path) { (Some(mask), Some(path)) => mask.intersect_path( @@ -5941,7 +6344,7 @@ fn paint_mask( Transform::identity(), ), (None, _) => { - clip = rounded_box_clip_mask( + clip = rounded_box_clip_mask_radii( pixmap.width(), pixmap.height(), rect, @@ -6440,6 +6843,101 @@ mod tests { ); } + #[test] + fn border_outline_computed_geometry_and_pixels_share_one_used_model() { + let tree = parse_html( + r##" +
+
+ "##, + ); + let none = tree.get_element_by_id("none").expect("none"); + let decorated = tree.get_element_by_id("decorated").expect("decorated"); + let mut resources = RenderResourceCache::default(); + let mut prepared = prepare_dom(&tree, (180.0, 180.0), None, &mut resources) + .expect("prepare"); + + let none_rect = prepared.document_rect(none).expect("none rect"); + assert_eq!((none_rect.width, none_rect.height), (100.0, 50.0)); + let none_style = prepared.computed_style(none).expect("none style"); + assert_eq!(none_style["border-top-width"], "0px"); + assert_eq!(none_style["border-top-style"], "none"); + + let rect = prepared.document_rect(decorated).expect("decorated rect"); + assert_eq!((rect.width, rect.height), (116.0, 66.0)); + let computed = prepared.computed_style(decorated).expect("computed style"); + assert_eq!(computed["border-top-color"], "rgb(255, 0, 0)"); + assert_eq!(computed["border-right-color"], "rgb(0, 128, 0)"); + assert_eq!(computed["border-bottom-color"], "rgb(0, 0, 255)"); + assert_eq!(computed["border-left-color"], "rgb(128, 0, 128)"); + assert_eq!(computed["border-top-left-radius"], "30px 20px"); + assert_eq!(computed["border-bottom-left-radius"], "8px 6px"); + assert_eq!(computed["outline-width"], "4px"); + assert_eq!(computed["outline-style"], "dashed"); + assert_eq!(computed["outline-offset"], "3px"); + + let pixmap = paint_prepared( + &tree, + &mut prepared, + &mut resources, + (0.0, 0.0), + ) + .expect("paint"); + let sample = |x: u32, y: u32| pixmap.pixel(x, y).expect("pixel"); + let top = sample((rect.x + rect.width / 2.0) as u32, (rect.y + 2.0) as u32); + let right = sample((rect.x + rect.width - 2.0) as u32, (rect.y + rect.height / 2.0) as u32); + let bottom = sample((rect.x + rect.width / 2.0) as u32, (rect.y + rect.height - 2.0) as u32); + let left = sample((rect.x + 2.0) as u32, (rect.y + rect.height / 2.0) as u32); + assert!(top.red() > 220 && top.green() < 40 && top.blue() < 40, "{top:?}"); + assert!(right.green() > 90 && right.red() < 40 && right.blue() < 40, "{right:?}"); + assert!(bottom.blue() > 220 && bottom.red() < 40 && bottom.green() < 40, "{bottom:?}"); + assert!(left.red() > 90 && left.blue() > 90 && left.green() < 40, "{left:?}"); + + let rounded_corner = sample((rect.x + 1.0) as u32, (rect.y + 1.0) as u32); + assert!( + !(rounded_corner.red() > 220 + && rounded_corner.green() > 220 + && rounded_corner.blue() < 40), + "asymmetric elliptical corner must stay outside the yellow background: {rounded_corner:?}" + ); + let mut outline_pixels = 0; + let outer_x = (rect.x - 8.0).max(0.0) as u32; + let outer_y = (rect.y - 8.0).max(0.0) as u32; + let outer_right = (rect.x + rect.width + 8.0) as u32; + let outer_bottom = (rect.y + rect.height + 8.0) as u32; + for y in outer_y..outer_bottom.min(pixmap.height()) { + for x in outer_x..outer_right.min(pixmap.width()) { + let outside = (x as f32) < rect.x + || (x as f32) >= rect.x + rect.width + || (y as f32) < rect.y + || (y as f32) >= rect.y + rect.height; + let pixel = sample(x, y); + if outside && pixel.red() < 30 && pixel.green() < 30 && pixel.blue() < 30 { + outline_pixels += 1; + } + } + } + assert!(outline_pixels > 40, "dashed outline should paint outside layout: {outline_pixels}"); + } + + #[test] + fn outline_none_retains_width_but_does_not_change_geometry_or_computed_used_width() { + let tree = parse_html( + r#"
"#, + ); + let id = tree.get_element_by_id("box").unwrap(); + let mut resources = RenderResourceCache::default(); + let prepared = prepare_dom(&tree, (80.0, 50.0), None, &mut resources).unwrap(); + let rect = prepared.document_rect(id).unwrap(); + assert_eq!((rect.width, rect.height), (40.0, 20.0)); + assert_eq!(prepared.layout.styles[&id].outline.specified_width, 9.0); + assert_eq!(prepared.computed_style(id).unwrap()["outline-width"], "0px"); + } + #[test] fn percentage_border_radius_paints_circles_ellipses_and_replaced_clips() { let tree = parse_html( diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 70574aa..08db52a 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -127,9 +127,10 @@ pub fn ua_style(tag: &str) -> LayoutStyle { style.underline = Some(true); // UA default: links are underlined } else if tag == "iframe" { // HTML's UA sheet gives frames a two-pixel inset border. The paint - // model does not distinguish inset yet, but border-box geometry still - // includes these edges around the 300x150 default object size. + // and geometry models share the same four-side used state. style.border = Edges { top: 2.0, right: 2.0, bottom: 2.0, left: 2.0 }; + style.border_model.specified_widths = crate::Sides::all(2.0); + style.border_model.styles = crate::Sides::all(crate::BorderStyle::Inset); } else if tag == "button" { // HTML buttons are inline-block controls whose anonymous inner // content is centered by the browser UA sheet. Keep the atomic outer @@ -172,6 +173,9 @@ pub fn ua_style(tag: &str) -> LayoutStyle { bottom: 1.0, left: 1.0, }; + style.border_model.specified_widths = crate::Sides::all(1.0); + style.border_model.styles = crate::Sides::all(crate::BorderStyle::Solid); + style.border_model.colors = crate::Sides::all(Some([118, 118, 118, 255])); style.border_color = Some([118, 118, 118, 255]); style.background_color = Some([255, 255, 255, 255]); } else if tag == "input" { @@ -198,6 +202,9 @@ pub fn ua_style(tag: &str) -> LayoutStyle { bottom: 2.0, left: 2.0, }; + style.border_model.specified_widths = crate::Sides::all(2.0); + style.border_model.styles = crate::Sides::all(crate::BorderStyle::Solid); + style.border_model.colors = crate::Sides::all(Some([118, 118, 118, 255])); style.border_color = Some([118, 118, 118, 255]); style.background_color = Some([255, 255, 255, 255]); } else if matches!(tag, "table" | "tbody" | "thead" | "tfoot") { @@ -859,30 +866,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "padding-block" => { let (s, e) = two(value); set_padding_side(style, 0, s); set_padding_side(style, 2, e); } "padding-block-start" => set_padding_side(style, 0, value), "padding-block-end" => set_padding_side(style, 2, value), - "border-radius" => { - // Uniform radius from the first value (ignore per-corner / the - // `/` vertical-radius form; the common case is one - // ). A percentage must remain unresolved until - // paint knows both axes of the final border box. - let token = value.split('/').next().and_then(|part| part.split_whitespace().next()); - if matches!( - value.trim().to_ascii_lowercase().as_str(), - "initial" | "unset" | "revert" | "revert-layer" - ) { - style.border_radius = crate::BorderRadius::default(); - } else if let Some(percentage) = token - .and_then(|token| token.strip_suffix('%')) - .and_then(|number| number.parse::().ok()) - .filter(|number| number.is_finite() && *number >= 0.0) - { - style.border_radius = crate::BorderRadius::percentage(percentage / 100.0); - } else if let Some(length) = token - .and_then(px) - .filter(|length| length.is_finite() && *length >= 0.0) - { - style.border_radius = crate::BorderRadius::pixels(length); - } - } + "border-radius" => apply_border_radius_shorthand(style, value), + "border-top-left-radius" => set_corner_radius(style, 0, value), + "border-top-right-radius" => set_corner_radius(style, 1, value), + "border-bottom-right-radius" => set_corner_radius(style, 2, value), + "border-bottom-left-radius" => set_corner_radius(style, 3, value), "clip-path" | "-webkit-clip-path" => { let lower = value.trim().to_ascii_lowercase(); if matches!( @@ -894,37 +882,31 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.clip_path = Some(polygon); } } - "border" => { - if value.split_whitespace().any(|token| { - token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden") - }) { - style.border = Edges::default(); - style.border_color = None; - return; - } - for p in split_ws_paren(value) { - if let Some(c) = - parse_color_for_scheme(p, style.color_scheme_dark) - { - style.border_color = Some(c); - } else if p.ends_with("px") || p.chars().all(|c| c.is_ascii_digit()) { - if let Some(e) = edges(p) { style.border = e; } - } - } - } - "border-width" => { if let Some(e) = edges(value) { style.border = e; } } - "border-top-width" | "border-top" => { - set_edge(&mut style.border, Side::Top, border_side_width(value)) - } - "border-right-width" | "border-right" => { - set_edge(&mut style.border, Side::Right, border_side_width(value)) - } - "border-bottom-width" | "border-bottom" => { - set_edge(&mut style.border, Side::Bottom, border_side_width(value)) - } - "border-left-width" | "border-left" => { - set_edge(&mut style.border, Side::Left, border_side_width(value)) - } + "border" => apply_border_shorthand(style, None, value), + "border-top" => apply_border_shorthand(style, Some(Side::Top), value), + "border-right" => apply_border_shorthand(style, Some(Side::Right), value), + "border-bottom" => apply_border_shorthand(style, Some(Side::Bottom), value), + "border-left" => apply_border_shorthand(style, Some(Side::Left), value), + "border-width" => apply_border_widths(style, value), + "border-top-width" => set_border_width(style, Side::Top, value), + "border-right-width" => set_border_width(style, Side::Right, value), + "border-bottom-width" => set_border_width(style, Side::Bottom, value), + "border-left-width" => set_border_width(style, Side::Left, value), + "border-style" => apply_border_styles(style, value), + "border-top-style" => set_border_style(style, Side::Top, value), + "border-right-style" => set_border_style(style, Side::Right, value), + "border-bottom-style" => set_border_style(style, Side::Bottom, value), + "border-left-style" => set_border_style(style, Side::Left, value), + "border-color" => apply_border_colors(style, value), + "border-top-color" => set_border_color(style, Side::Top, value), + "border-right-color" => set_border_color(style, Side::Right, value), + "border-bottom-color" => set_border_color(style, Side::Bottom, value), + "border-left-color" => set_border_color(style, Side::Left, value), + "outline" => apply_outline_shorthand(style, value), + "outline-width" => set_outline_width(style, value), + "outline-style" => set_outline_style(style, value), + "outline-color" => set_outline_color(style, value), + "outline-offset" => set_outline_offset(style, value), "background-color" => { style.background_color = parse_color_for_scheme(value, style.color_scheme_dark) @@ -1011,10 +993,6 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { )) } "stroke-width" => style.svg_stroke_width = Some(value.trim().to_string()), - "border-color" => { - style.border_color = - parse_color_for_scheme(value, style.color_scheme_dark) - } "font-size" => { // Absolute lengths resolve now; font/viewport-relative ones defer // to the inheritance pass (they need parent/root font-size). @@ -1697,6 +1675,10 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "padding-block-start" | "padding-block-end" | "border-radius" + | "border-top-left-radius" + | "border-top-right-radius" + | "border-bottom-right-radius" + | "border-bottom-left-radius" | "clip-path" | "-webkit-clip-path" | "border" @@ -1705,6 +1687,15 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "border-right-width" | "border-bottom-width" | "border-left-width" + | "border-style" + | "border-top-style" + | "border-right-style" + | "border-bottom-style" + | "border-left-style" + | "border-top-color" + | "border-right-color" + | "border-bottom-color" + | "border-left-color" | "border-top" | "border-right" | "border-bottom" @@ -1729,6 +1720,11 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "stroke" | "stroke-width" | "border-color" + | "outline" + | "outline-width" + | "outline-style" + | "outline-color" + | "outline-offset" | "color-scheme" | "font-size" | "letter-spacing" @@ -1947,7 +1943,42 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { "grid-auto-columns" | "grid-auto-rows" => { parse_grid_auto_track_list(value).is_some() } - "color" | "-webkit-text-fill-color" | "background-color" | "border-color" => { + "border" | "border-top" | "border-right" | "border-bottom" | "border-left" => { + parse_border_shorthand(value, false).is_some() + } + "border-width" => { + let values = split_ws_paren(value) + .iter() + .map(|token| border_width(token)) + .collect::>>(); + values.and_then(|values| crate::border::expand_sides(&values)).is_some() + } + "border-top-width" | "border-right-width" | "border-bottom-width" | "border-left-width" + | "outline-width" => border_width(value).is_some(), + "border-style" => { + let values = split_ws_paren(value) + .iter() + .map(|token| border_style(token)) + .collect::>>(); + values.and_then(|values| crate::border::expand_sides(&values)).is_some() + } + "border-top-style" | "border-right-style" | "border-bottom-style" | "border-left-style" => { + border_style(value).is_some() + } + "border-color" => parse_border_colors(value, false).is_some(), + "border-top-color" | "border-right-color" | "border-bottom-color" | "border-left-color" + | "outline-color" => border_color(value, false).is_some(), + "border-radius" => parsed_border_radii(value).is_some(), + "border-top-left-radius" | "border-top-right-radius" | "border-bottom-right-radius" + | "border-bottom-left-radius" => { + let tokens = split_ws_paren(value); + matches!(tokens.as_slice(), [one] if radius_value(one).is_some()) + || matches!(tokens.as_slice(), [x, y] if radius_value(x).is_some() && radius_value(y).is_some()) + } + "outline" => parse_outline_shorthand(value, false).is_some(), + "outline-style" => outline_style(value).is_some(), + "outline-offset" => strict_border_length(value).is_some(), + "color" | "-webkit-text-fill-color" | "background-color" => { parse_color(value).is_some() } "color-scheme" => { @@ -3879,25 +3910,475 @@ fn hsl_to_rgba(h: f32, s: f32, l: f32, a: u8) -> [u8; 4] { ] } +#[derive(Clone, Copy)] enum Side { Top, Right, Bottom, Left } -fn border_side_width(value: &str) -> Option { - if value.split_whitespace().any(|token| { - token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden") - }) { - Some(0.0) - } else { - px(value) +fn border_style(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "none" => Some(crate::BorderStyle::None), + "hidden" => Some(crate::BorderStyle::Hidden), + "dotted" => Some(crate::BorderStyle::Dotted), + "dashed" => Some(crate::BorderStyle::Dashed), + "solid" => Some(crate::BorderStyle::Solid), + "double" => Some(crate::BorderStyle::Double), + "groove" => Some(crate::BorderStyle::Groove), + "ridge" => Some(crate::BorderStyle::Ridge), + "inset" => Some(crate::BorderStyle::Inset), + "outset" => Some(crate::BorderStyle::Outset), + _ => None, } } -fn set_edge(edges: &mut Edges, side: Side, v: Option) { - let v = match v { Some(v) => v, None => return }; +fn outline_style(value: &str) -> Option { + if value.trim().eq_ignore_ascii_case("auto") { + Some(crate::BorderStyle::Auto) + } else { + border_style(value).filter(|style| *style != crate::BorderStyle::Hidden) + } +} + +fn border_width(value: &str) -> Option { + let value = value.trim(); + let width = match value.to_ascii_lowercase().as_str() { + "thin" => 1.0, + "medium" => crate::border::MEDIUM_BORDER_WIDTH, + "thick" => 5.0, + _ => strict_border_length(value)?, + }; + width.is_finite().then_some(width).filter(|width| *width >= 0.0) +} + +fn strict_border_length(value: &str) -> Option { + let value = value.trim(); + if value == "0" || value == "+0" || value == "-0" { + return Some(0.0); + } + let lower = value.to_ascii_lowercase(); + if lower.contains('(') { + if lower.contains('%') { + return None; + } + return px(value); + } + const UNITS: [&str; 5] = ["rem", "px", "pt", "em", "ex"]; + let unit = UNITS.iter().find(|unit| lower.ends_with(**unit))?; + let number = lower[..lower.len() - unit.len()].trim().parse::().ok()?; + number.is_finite().then(|| px(value)).flatten() +} + +fn border_color(value: &str, dark_scheme: bool) -> Option> { + if value.trim().eq_ignore_ascii_case("currentcolor") { + Some(None) + } else { + parse_color_for_scheme(value, dark_scheme).map(Some) + } +} + +fn sync_used_border(style: &mut LayoutStyle) { + let used = style.border_model.used_widths(); + style.border = Edges { + top: used.top, + right: used.right, + bottom: used.bottom, + left: used.left, + }; +} + +fn side_widths_mut(model: &mut crate::BorderModel, side: Side) -> &mut f32 { match side { - Side::Top => edges.top = v, - Side::Right => edges.right = v, - Side::Bottom => edges.bottom = v, - Side::Left => edges.left = v, + Side::Top => &mut model.specified_widths.top, + Side::Right => &mut model.specified_widths.right, + Side::Bottom => &mut model.specified_widths.bottom, + Side::Left => &mut model.specified_widths.left, + } +} + +fn side_styles_mut( + model: &mut crate::BorderModel, + side: Side, +) -> &mut crate::BorderStyle { + match side { + Side::Top => &mut model.styles.top, + Side::Right => &mut model.styles.right, + Side::Bottom => &mut model.styles.bottom, + Side::Left => &mut model.styles.left, + } +} + +fn side_colors_mut( + model: &mut crate::BorderModel, + side: Side, +) -> &mut Option<[u8; 4]> { + match side { + Side::Top => &mut model.colors.top, + Side::Right => &mut model.colors.right, + Side::Bottom => &mut model.colors.bottom, + Side::Left => &mut model.colors.left, + } +} + +fn apply_border_widths(style: &mut LayoutStyle, value: &str) { + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + style.border_model.specified_widths = + crate::Sides::all(crate::border::MEDIUM_BORDER_WIDTH); + sync_used_border(style); + return; + } + let tokens = split_ws_paren(value); + let Some(values) = tokens + .iter() + .map(|token| border_width(token)) + .collect::>>() + .and_then(|values| crate::border::expand_sides(&values)) + else { + return; + }; + style.border_model.specified_widths = values; + sync_used_border(style); +} + +fn set_border_width(style: &mut LayoutStyle, side: Side, value: &str) { + let width = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + Some(crate::border::MEDIUM_BORDER_WIDTH) + } else { + border_width(value) + }; + if let Some(width) = width { + *side_widths_mut(&mut style.border_model, side) = width; + sync_used_border(style); + } +} + +fn apply_border_styles(style: &mut LayoutStyle, value: &str) { + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + style.border_model.styles = crate::Sides::all(crate::BorderStyle::None); + sync_used_border(style); + return; + } + let tokens = split_ws_paren(value); + let Some(values) = tokens + .iter() + .map(|token| border_style(token)) + .collect::>>() + .and_then(|values| crate::border::expand_sides(&values)) + else { + return; + }; + style.border_model.styles = values; + sync_used_border(style); +} + +fn set_border_style(style: &mut LayoutStyle, side: Side, value: &str) { + let line_style = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + Some(crate::BorderStyle::None) + } else { + border_style(value) + }; + if let Some(line_style) = line_style { + *side_styles_mut(&mut style.border_model, side) = line_style; + sync_used_border(style); + } +} + +fn parse_border_colors( + value: &str, + dark_scheme: bool, +) -> Option>> { + let tokens = split_ws_paren(value); + let values = tokens + .iter() + .map(|token| border_color(token, dark_scheme)) + .collect::>>()?; + crate::border::expand_sides(&values) +} + +fn apply_border_colors(style: &mut LayoutStyle, value: &str) { + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + style.border_model.colors = crate::Sides::all(None); + style.border_color = None; + } else if let Some(colors) = parse_border_colors(value, style.color_scheme_dark) { + style.border_model.colors = colors; + style.border_color = (colors.top == colors.right + && colors.right == colors.bottom + && colors.bottom == colors.left) + .then_some(colors.top) + .flatten(); + } +} + +fn set_border_color(style: &mut LayoutStyle, side: Side, value: &str) { + let color = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + Some(None) + } else { + border_color(value, style.color_scheme_dark) + }; + if let Some(color) = color { + *side_colors_mut(&mut style.border_model, side) = color; + let colors = style.border_model.colors; + style.border_color = (colors.top == colors.right + && colors.right == colors.bottom + && colors.bottom == colors.left) + .then_some(colors.top) + .flatten(); + } +} + +#[derive(Clone, Copy)] +struct BorderShorthand { + width: f32, + style: crate::BorderStyle, + color: Option<[u8; 4]>, +} + +fn parse_border_shorthand(value: &str, dark_scheme: bool) -> Option { + let lower = value.trim().to_ascii_lowercase(); + if matches!(lower.as_str(), "initial" | "unset" | "revert" | "revert-layer") { + return Some(BorderShorthand { + width: crate::border::MEDIUM_BORDER_WIDTH, + style: crate::BorderStyle::None, + color: None, + }); + } + if value.trim().is_empty() || lower == "inherit" { + return None; + } + let mut width = None; + let mut style = None; + let mut color = None; + let mut saw_color = false; + for token in split_ws_paren(value) { + if width.is_none() { + if let Some(parsed) = border_width(token) { + width = Some(parsed); + continue; + } + } + if style.is_none() { + if let Some(parsed) = border_style(token) { + style = Some(parsed); + continue; + } + } + if !saw_color { + if let Some(parsed) = border_color(token, dark_scheme) { + color = parsed; + saw_color = true; + continue; + } + } + return None; + } + Some(BorderShorthand { + width: width.unwrap_or(crate::border::MEDIUM_BORDER_WIDTH), + style: style.unwrap_or(crate::BorderStyle::None), + color, + }) +} + +fn apply_border_shorthand(style: &mut LayoutStyle, side: Option, value: &str) { + let Some(parsed) = parse_border_shorthand(value, style.color_scheme_dark) else { + return; + }; + if let Some(side) = side { + *side_widths_mut(&mut style.border_model, side) = parsed.width; + *side_styles_mut(&mut style.border_model, side) = parsed.style; + *side_colors_mut(&mut style.border_model, side) = parsed.color; + } else { + style.border_model.specified_widths = crate::Sides::all(parsed.width); + style.border_model.styles = crate::Sides::all(parsed.style); + style.border_model.colors = crate::Sides::all(parsed.color); + style.border_color = parsed.color; + } + let colors = style.border_model.colors; + style.border_color = (colors.top == colors.right + && colors.right == colors.bottom + && colors.bottom == colors.left) + .then_some(colors.top) + .flatten(); + sync_used_border(style); +} + +fn radius_value(value: &str) -> Option { + let value = value.trim(); + if let Some(number) = value.strip_suffix('%') { + let percentage = number.trim().parse::().ok()? / 100.0; + return (percentage.is_finite() && percentage >= 0.0) + .then_some(crate::RadiusValue::percentage(percentage)); + } + let length = strict_border_length(value)?; + (length.is_finite() && length >= 0.0).then_some(crate::RadiusValue::pixels(length)) +} + +fn parsed_border_radii(value: &str) -> Option { + let axes = split_top_level(value, '/'); + if axes.is_empty() || axes.len() > 2 { + return None; + } + let horizontal_tokens = split_ws_paren(axes[0]); + let horizontal = horizontal_tokens + .iter() + .map(|token| radius_value(token)) + .collect::>>() + .and_then(|values| crate::border::expand_sides(&values))?; + let vertical = if axes.len() == 2 { + let vertical_tokens = split_ws_paren(axes[1]); + vertical_tokens + .iter() + .map(|token| radius_value(token)) + .collect::>>() + .and_then(|values| crate::border::expand_sides(&values))? + } else { + horizontal + }; + Some(crate::BorderRadii { + top_left: crate::CornerRadius { x: horizontal.top, y: vertical.top }, + top_right: crate::CornerRadius { x: horizontal.right, y: vertical.right }, + bottom_right: crate::CornerRadius { x: horizontal.bottom, y: vertical.bottom }, + bottom_left: crate::CornerRadius { x: horizontal.left, y: vertical.left }, + }) +} + +fn apply_border_radius_shorthand(style: &mut LayoutStyle, value: &str) { + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + style.border_model.radii = crate::BorderRadii::default(); + } else if let Some(radii) = parsed_border_radii(value) { + style.border_model.radii = radii; + } +} + +fn set_corner_radius(style: &mut LayoutStyle, corner: usize, value: &str) { + let parsed = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + Some(crate::CornerRadius::default()) + } else { + let tokens = split_ws_paren(value); + match tokens.as_slice() { + [x] => radius_value(x).map(crate::CornerRadius::circular), + [x, y] => radius_value(x).zip(radius_value(y)).map(|(x, y)| { + crate::CornerRadius { x, y } + }), + _ => None, + } + }; + let Some(parsed) = parsed else { return }; + match corner { + 0 => style.border_model.radii.top_left = parsed, + 1 => style.border_model.radii.top_right = parsed, + 2 => style.border_model.radii.bottom_right = parsed, + 3 => style.border_model.radii.bottom_left = parsed, + _ => {} + } +} + +fn parse_outline_shorthand(value: &str, dark_scheme: bool) -> Option { + let lower = value.trim().to_ascii_lowercase(); + if matches!(lower.as_str(), "initial" | "unset" | "revert" | "revert-layer") { + return Some(crate::OutlineModel::default()); + } + if value.trim().is_empty() || lower == "inherit" { + return None; + } + let mut outline = crate::OutlineModel::default(); + let mut saw_width = false; + let mut saw_style = false; + let mut saw_color = false; + for token in split_ws_paren(value) { + if !saw_width { + if let Some(width) = border_width(token) { + outline.specified_width = width; + saw_width = true; + continue; + } + } + if !saw_style { + if let Some(style) = outline_style(token) { + outline.style = style; + saw_style = true; + continue; + } + } + if !saw_color { + if let Some(color) = border_color(token, dark_scheme) { + outline.color = color; + saw_color = true; + continue; + } + } + return None; + } + Some(outline) +} + +fn apply_outline_shorthand(style: &mut LayoutStyle, value: &str) { + if let Some(outline) = parse_outline_shorthand(value, style.color_scheme_dark) { + style.outline = outline; + } +} + +fn set_outline_width(style: &mut LayoutStyle, value: &str) { + let width = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + Some(crate::border::MEDIUM_BORDER_WIDTH) + } else { + border_width(value) + }; + if let Some(width) = width { + style.outline.specified_width = width; + } +} + +fn set_outline_style(style: &mut LayoutStyle, value: &str) { + let line_style = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + Some(crate::BorderStyle::None) + } else { + outline_style(value) + }; + if let Some(line_style) = line_style { + style.outline.style = line_style; + } +} + +fn set_outline_color(style: &mut LayoutStyle, value: &str) { + let color = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + Some(None) + } else { + border_color(value, style.color_scheme_dark) + }; + if let Some(color) = color { + style.outline.color = color; + } +} + +fn set_outline_offset(style: &mut LayoutStyle, value: &str) { + let offset = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + Some(0.0) + } else { + strict_border_length(value).filter(|value| value.is_finite()) + }; + if let Some(offset) = offset { + style.outline.offset = offset; } } @@ -4888,21 +5369,6 @@ fn parse_clip_path_polygon(value: &str) -> Option { Some(crate::ClipPathPolygon { fill_rule, points }) } -/// Parse every length token in a value as px (for box shorthands). -fn edges(value: &str) -> Option { - // Keep functional lengths intact while tokenizing. Design systems commonly - // expose border widths through custom properties such as - // `--stroke:calc(1*1px)`; after var() substitution that calc is still one - // valid border-width token. Splitting on every internal space and calling - // px_value() dropped it, leaving an otherwise fully-authored control with - // no painted border. - let dims: Vec = split_ws_paren(value) - .into_iter() - .map(px) - .collect::>>()?; - edges_from(dims) -} - /// Split a 1-or-2 value shorthand into (start, end); a single value applies to /// both. Used by the logical-property axes (`margin-inline`, `padding-block`). fn two(value: &str) -> (&str, &str) { @@ -5900,18 +6366,6 @@ fn split_ws_paren(s: &str) -> Vec<&str> { out } -/// CSS box shorthand: 1 value applies to all sides; 2 values are (v, h); -/// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left). -fn edges_from(dims: Vec) -> Option { - match dims.len() { - 0 => None, - 1 => Some(Edges { top: dims[0], right: dims[0], bottom: dims[0], left: dims[0] }), - 2 => Some(Edges { top: dims[0], right: dims[1], bottom: dims[0], left: dims[1] }), - 3 => Some(Edges { top: dims[0], right: dims[1], bottom: dims[2], left: dims[1] }), - _ => Some(Edges { top: dims[0], right: dims[1], bottom: dims[2], left: dims[3] }), - } -} - #[cfg(test)] mod tests { use super::*; @@ -6159,6 +6613,10 @@ mod tests { fn border_none_clears_native_and_per_side_widths() { let input = compute_style("input", Some("border:none")); assert_eq!(input.border, Edges::default()); + assert_eq!( + input.border_model.specified_widths, + crate::Sides::all(crate::border::MEDIUM_BORDER_WIDTH) + ); let side = compute_style( "div", @@ -6171,15 +6629,59 @@ mod tests { } #[test] - fn uniform_border_radius_retains_percentage_until_box_resolution() { + fn border_radius_expands_ellipses_and_scales_overlaps() { let percentage = compute_style("div", Some("border-radius:50%")); - assert_eq!(percentage.border_radius.resolve(80.0, 40.0), (40.0, 20.0)); + assert_eq!( + percentage.border_model.radii.resolve(80.0, 40.0).top_left, + (40.0, 20.0) + ); - let pixels = compute_style("div", Some("border-radius:20px")); - assert_eq!(pixels.border_radius.resolve(80.0, 40.0), (20.0, 20.0)); + let elliptical = compute_style( + "div", + Some("border-radius:80px 60px 40px 20px/50px 40px 30px 10px"), + ); + let resolved = elliptical.border_model.radii.resolve(100.0, 50.0); + assert!((resolved.top_left.0 - 80.0 * 5.0 / 7.0).abs() < 0.001); + assert!((resolved.bottom_right.1 - 30.0 * 5.0 / 7.0).abs() < 0.001); - let reset = compute_style("div", Some("border-radius:50%;border-radius:initial")); - assert!(reset.border_radius.is_zero()); + let reset = compute_style("div", Some("border-radius:50%;border-radius:revert")); + assert!(reset.border_model.radii.is_zero()); + } + + #[test] + fn invalid_border_declarations_retain_the_previous_cascade_value() { + let style = compute_style( + "div", + Some( + "border:4px dashed red;\ + border-width:10%;border-style:solid nonsense;\ + border-color:red green blue purple orange;\ + border-radius:12px;border-radius:10px/", + ), + ); + assert_eq!(style.border, Edges { top: 4.0, right: 4.0, bottom: 4.0, left: 4.0 }); + assert_eq!(style.border_model.styles, crate::Sides::all(crate::BorderStyle::Dashed)); + assert_eq!(style.border_model.colors, crate::Sides::all(Some([255, 0, 0, 255]))); + assert_eq!(style.border_model.radii.top_left.x, crate::RadiusValue::pixels(12.0)); + assert!(!supports_declaration("border-width", "10%")); + assert!(!supports_declaration("border-width", "4")); + } + + #[test] + fn border_and_outline_shorthands_reset_omitted_longhands() { + let style = compute_style( + "div", + Some( + "border:10px dashed red;border:solid;\ + outline:8px dotted blue;outline:green", + ), + ); + assert_eq!(style.border, Edges { top: 3.0, right: 3.0, bottom: 3.0, left: 3.0 }); + assert_eq!(style.border_model.styles, crate::Sides::all(crate::BorderStyle::Solid)); + assert_eq!(style.border_model.colors, crate::Sides::all(None)); + assert_eq!(style.outline.specified_width, 3.0); + assert_eq!(style.outline.style, crate::BorderStyle::None); + assert_eq!(style.outline.color, Some([0, 128, 0, 255])); } #[test] @@ -6656,11 +7158,11 @@ mod tests { left: 1.0, } ); - assert_eq!(style.border_color, Some([208, 217, 251, 102])); + assert_eq!(style.border_model.colors, crate::Sides::all(Some([208, 217, 251, 102]))); let asymmetric = compute_style( "div", - Some("border-width:calc(1px * 2) 3px calc(2px + 2px) 5px"), + Some("border-style:solid;border-width:calc(1px * 2) 3px calc(2px + 2px) 5px"), ); assert_eq!( asymmetric.border, @@ -7167,7 +7669,7 @@ mod tests { ); 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_eq!(style.border_model.colors, crate::Sides::all(Some([255, 0, 0, 255]))); assert!(supports_declaration( "color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"