diff --git a/crates/typst/src/export/pdf/gradient.rs b/crates/typst/src/export/pdf/gradient.rs index 842c7666..18b4de36 100644 --- a/crates/typst/src/export/pdf/gradient.rs +++ b/crates/typst/src/export/pdf/gradient.rs @@ -7,7 +7,8 @@ use super::color::{ColorSpaceExt, PaintEncode}; use super::page::{PageContext, Transforms}; use super::{AbsExt, PdfContext}; use crate::geom::{ - Abs, Color, ColorSpace, Gradient, Numeric, Quadrant, Ratio, Relative, Transform, + Abs, Angle, Color, ColorSpace, Gradient, Numeric, Quadrant, Ratio, Relative, + Transform, }; /// A unique-transform-aspect-ratio combination that will be encoded into the @@ -54,6 +55,32 @@ pub fn write_gradients(ctx: &mut PdfContext) { shading.finish(); + shading_pattern + } + Gradient::Radial(radial) => { + let shading_function = shading_function(ctx, &gradient); + let mut shading_pattern = ctx.pdf.shading_pattern(shading); + let mut shading = shading_pattern.function_shading(); + shading.shading_type(FunctionShadingType::Radial); + + ctx.colors + .write(gradient.space(), shading.color_space(), &mut ctx.alloc); + + shading + .anti_alias(gradient.anti_alias()) + .function(shading_function) + .coords([ + radial.focal_center.x.get() as f32, + radial.focal_center.y.get() as f32, + radial.focal_radius.get() as f32, + radial.center.x.get() as f32, + radial.center.y.get() as f32, + radial.radius.get() as f32, + ]) + .extend([true; 2]); + + shading.finish(); + shading_pattern } }; @@ -231,12 +258,13 @@ fn register_gradient( Relative::Parent => transforms.container_size, }; - let (offset_x, offset_y) = match gradient.angle().quadrant() { - Quadrant::First => (Abs::zero(), Abs::zero()), - Quadrant::Second => (size.x, Abs::zero()), - Quadrant::Third => (size.x, size.y), - Quadrant::Fourth => (Abs::zero(), size.y), - }; + let (offset_x, offset_y) = + match gradient.angle().unwrap_or_else(Angle::zero).quadrant() { + Quadrant::First => (Abs::zero(), Abs::zero()), + Quadrant::Second => (size.x, Abs::zero()), + Quadrant::Third => (size.x, size.y), + Quadrant::Fourth => (Abs::zero(), size.y), + }; let transform = match gradient.unwrap_relative(false) { Relative::Self_ => transforms.transform, @@ -252,7 +280,7 @@ fn register_gradient( Ratio::new(size.y.to_pt()), )) .pre_concat(Transform::rotate(Gradient::correct_aspect_ratio( - gradient.angle(), + gradient.angle().unwrap_or_else(Angle::zero), size.aspect_ratio(), ))), gradient: gradient.clone(), diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs index 8544c5a6..0b26045e 100644 --- a/crates/typst/src/export/render.rs +++ b/crates/typst/src/export/render.rs @@ -14,7 +14,7 @@ use usvg::{NodeExt, TreeParsing}; use crate::doc::{Frame, FrameItem, FrameKind, GroupItem, Meta, TextItem}; use crate::font::Font; use crate::geom::{ - self, Abs, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, Paint, + self, Abs, Axes, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, Paint, PathItem, Point, Ratio, Relative, Shape, Size, Transform, }; use crate::image::{Image, ImageKind, RasterFormat}; @@ -136,8 +136,11 @@ impl<'a> State<'a> { } /// Pre concat the container's transform. - fn pre_concat_container(self, container_transform: sk::Transform) -> Self { - Self { container_transform, ..self } + fn pre_concat_container(self, transform: sk::Transform) -> Self { + Self { + container_transform: self.container_transform.pre_concat(transform), + ..self + } } } @@ -378,7 +381,7 @@ fn render_outline_glyph( // TODO: Implement gradients on text. let mut pixmap = None; - let paint = to_sk_paint(&text.fill, state, Size::zero(), None, &mut pixmap); + let paint = to_sk_paint(&text.fill, state, Size::zero(), None, &mut pixmap, None); let rule = sk::FillRule::default(); @@ -512,7 +515,7 @@ fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option< if let Some(fill) = &shape.fill { let mut pixmap = None; let mut paint: sk::Paint = - to_sk_paint(fill, state, shape.geometry.bbox_size(), None, &mut pixmap); + to_sk_paint(fill, state, shape.geometry.bbox_size(), None, &mut pixmap, None); if matches!(shape.geometry, Geometry::Rect(_)) { paint.anti_alias = false; @@ -547,10 +550,42 @@ fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option< sk::StrokeDash::new(dash_array, pattern.phase.to_f32()) }); - let mut pixmap = None; - let paint = - to_sk_paint(paint, state, shape.geometry.bbox_size(), None, &mut pixmap); + let bbox = shape.geometry.bbox_size(); + let offset_bbox = (!matches!(shape.geometry, Geometry::Line(..))) + .then(|| offset_bounding_box(bbox, *thickness)) + .unwrap_or(bbox); + let fill_transform = + (!matches!(shape.geometry, Geometry::Line(..))).then(|| { + sk::Transform::from_translate( + -thickness.to_f32(), + -thickness.to_f32(), + ) + }); + + let gradient_map = + (!matches!(shape.geometry, Geometry::Line(..))).then(|| { + ( + Point::new( + -*thickness * state.pixel_per_pt as f64, + -*thickness * state.pixel_per_pt as f64, + ), + Axes::new( + Ratio::new(offset_bbox.x / bbox.x), + Ratio::new(offset_bbox.y / bbox.y), + ), + ) + }); + + let mut pixmap = None; + let paint = to_sk_paint( + paint, + state, + offset_bbox, + fill_transform, + &mut pixmap, + gradient_map, + ); let stroke = sk::Stroke { width, line_cap: line_cap.into(), @@ -700,23 +735,40 @@ impl From for Transform { } } -/// Transforms a [`Paint`] into a [`sk::Paint`]. +// Transforms a [`Paint`] into a [`sk::Paint`]. /// Applying the necessary transform, if the paint is a gradient. +/// +/// `gradient_map` is used to scale and move the gradient being sampled, +/// this is used to line up the stroke and the fill of a shape. fn to_sk_paint<'a>( paint: &Paint, state: State, item_size: Size, fill_transform: Option, pixmap: &'a mut Option>, + gradient_map: Option<(Point, Axes)>, ) -> sk::Paint<'a> { /// Actual sampling of the gradient, cached for performance. #[comemo::memoize] - fn cached(gradient: &Gradient, width: u32, height: u32) -> Arc { + fn cached( + gradient: &Gradient, + width: u32, + height: u32, + gradient_map: Option<(Point, Axes)>, + ) -> Arc { + let (offset, scale) = + gradient_map.unwrap_or_else(|| (Point::zero(), Axes::splat(Ratio::one()))); let mut pixmap = sk::Pixmap::new(width.max(1), height.max(1)).unwrap(); for x in 0..width { for y in 0..height { let color: sk::Color = gradient - .sample_at((x as f32, y as f32), (width as f32, height as f32)) + .sample_at( + ( + (x as f32 + offset.x.to_f32()) * scale.x.get() as f32, + (y as f32 + offset.y.to_f32()) * scale.y.get() as f32, + ), + (width as f32, height as f32), + ) .into(); pixmap.pixels_mut()[(y * width + x) as usize] = @@ -734,18 +786,18 @@ fn to_sk_paint<'a>( sk_paint.anti_alias = true; } Paint::Gradient(gradient) => { - let container_size = match gradient.unwrap_relative(false) { + let relative = gradient.unwrap_relative(false); + let container_size = match relative { Relative::Self_ => item_size, Relative::Parent => state.size, }; - let fill_transform = - fill_transform.unwrap_or_else(|| match gradient.unwrap_relative(false) { - Relative::Self_ => sk::Transform::identity(), - Relative::Parent => state - .container_transform - .post_concat(state.transform.invert().unwrap()), - }); + let fill_transform = match relative { + Relative::Self_ => fill_transform.unwrap_or_default(), + Relative::Parent => state + .container_transform + .post_concat(state.transform.invert().unwrap()), + }; let width = (container_size.x.to_f32() * state.pixel_per_pt).ceil() as u32; let height = (container_size.y.to_f32() * state.pixel_per_pt).ceil() as u32; @@ -753,6 +805,7 @@ fn to_sk_paint<'a>( gradient, width.max(state.pixel_per_pt.ceil() as u32), height.max(state.pixel_per_pt.ceil() as u32), + gradient_map, )); // We can use FilterQuality::Nearest here because we're @@ -860,3 +913,7 @@ fn alpha_mul(color: u32, scale: u32) -> u32 { let ag = ((color >> 8) & mask) * scale; (rb & mask) | (ag & !mask) } + +fn offset_bounding_box(bbox: Size, stroke_width: Abs) -> Size { + Size::new(bbox.x + stroke_width * 2.0, bbox.y + stroke_width * 2.0) +} diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs index 03e12181..1aa5141a 100644 --- a/crates/typst/src/export/svg.rs +++ b/crates/typst/src/export/svg.rs @@ -8,6 +8,7 @@ use ttf_parser::{GlyphId, OutlineBuilder}; use xmlwriter::XmlWriter; use crate::doc::{Frame, FrameItem, FrameKind, GroupItem, TextItem}; +use crate::eval::Repr; use crate::font::Font; use crate::geom::{ Abs, Angle, Axes, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, Paint, @@ -135,12 +136,15 @@ struct GradientRef { enum GradientKind { /// A linear gradient. Linear, + /// A radial gradient. + Radial, } impl From<&Gradient> for GradientKind { fn from(value: &Gradient) -> Self { match value { Gradient::Linear { .. } => GradientKind::Linear, + Gradient::Radial { .. } => GradientKind::Radial, } } } @@ -664,48 +668,59 @@ impl SVGRenderer { self.xml.write_attribute("y1", &y1); self.xml.write_attribute("x2", &x2); self.xml.write_attribute("y2", &y2); - - for window in linear.stops.windows(2) { - let (start_c, start_t) = window[0]; - let (end_c, end_t) = window[1]; - - self.xml.start_element("stop"); - self.xml - .write_attribute_fmt("offset", format_args!("{start_t:?}")); - self.xml.write_attribute("stop-color", &start_c.to_hex()); - self.xml.end_element(); - - // Generate (256 / len) stops between the two stops. - // This is a workaround for a bug in many readers: - // They tend to just ignore the color space of the gradient. - // The goal is to have smooth gradients but not to balloon the file size - // too much if there are already a lot of stops as in most presets. - let len = if gradient.anti_alias() { - (256 / linear.stops.len() as u32).max(2) - } else { - 2 - }; - - for i in 1..(len - 1) { - let t0 = i as f64 / (len - 1) as f64; - let t = start_t + (end_t - start_t) * t0; - let c = gradient.sample(RatioOrAngle::Ratio(t)); - - self.xml.start_element("stop"); - self.xml.write_attribute_fmt("offset", format_args!("{t:?}")); - self.xml.write_attribute("stop-color", &c.to_hex()); - self.xml.end_element(); - } - - self.xml.start_element("stop"); - self.xml.write_attribute_fmt("offset", format_args!("{end_t:?}")); - self.xml.write_attribute("stop-color", &end_c.to_hex()); - self.xml.end_element() - } - - self.xml.end_element(); + } + Gradient::Radial(radial) => { + self.xml.start_element("radialGradient"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("spreadMethod", "pad"); + self.xml.write_attribute("gradientUnits", "userSpaceOnUse"); + self.xml.write_attribute("cx", &radial.center.x.get()); + self.xml.write_attribute("cy", &radial.center.y.get()); + self.xml.write_attribute("r", &radial.radius.get()); + self.xml.write_attribute("fx", &radial.focal_center.x.get()); + self.xml.write_attribute("fy", &radial.focal_center.y.get()); + self.xml.write_attribute("fr", &radial.focal_radius.get()); } } + + for window in gradient.stops_ref().windows(2) { + let (start_c, start_t) = window[0]; + let (end_c, end_t) = window[1]; + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", &start_t.repr()); + self.xml.write_attribute("stop-color", &start_c.to_hex()); + self.xml.end_element(); + + // Generate (256 / len) stops between the two stops. + // This is a workaround for a bug in many readers: + // They tend to just ignore the color space of the gradient. + // The goal is to have smooth gradients but not to balloon the file size + // too much if there are already a lot of stops as in most presets. + let len = if gradient.anti_alias() { + (256 / gradient.stops_ref().len() as u32).max(2) + } else { + 2 + }; + + for i in 1..(len - 1) { + let t0 = i as f64 / (len - 1) as f64; + let t = start_t + (end_t - start_t) * t0; + let c = gradient.sample(RatioOrAngle::Ratio(t)); + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", &t.repr()); + self.xml.write_attribute("stop-color", &c.to_hex()); + self.xml.end_element(); + } + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", &end_t.repr()); + self.xml.write_attribute("stop-color", &end_c.to_hex()); + self.xml.end_element() + } + + self.xml.end_element(); } self.xml.end_element() @@ -727,6 +742,13 @@ impl SVGRenderer { &SvgMatrix(gradient_ref.transform), ); } + GradientKind::Radial => { + self.xml.start_element("radialGradient"); + self.xml.write_attribute( + "gradientTransform", + &SvgMatrix(gradient_ref.transform), + ); + } } self.xml.write_attribute("id", &id); diff --git a/crates/typst/src/geom/axes.rs b/crates/typst/src/geom/axes.rs index 14875553..2439182a 100644 --- a/crates/typst/src/geom/axes.rs +++ b/crates/typst/src/geom/axes.rs @@ -290,6 +290,18 @@ cast! { }, } +cast! { + Axes, + self => array![self.x, self.y].into_value(), + array: Array => { + let mut iter = array.into_iter(); + match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?), + _ => bail!("ratio array must contain exactly two entries"), + } + }, +} + impl Resolve for Axes { type Output = Axes; diff --git a/crates/typst/src/geom/gradient.rs b/crates/typst/src/geom/gradient.rs index 2d821268..33b8ca77 100644 --- a/crates/typst/src/geom/gradient.rs +++ b/crates/typst/src/geom/gradient.rs @@ -3,6 +3,8 @@ use std::f64::{EPSILON, NEG_INFINITY}; use std::hash::Hash; use std::sync::Arc; +use kurbo::Vec2; + use super::color::{Hsl, Hsv}; use super::*; use crate::diag::{bail, error, SourceResult}; @@ -13,15 +15,25 @@ use crate::syntax::{Span, Spanned}; /// A color gradient. /// /// Typst supports linear gradients through the -/// [`gradient.linear` function]($gradient.linear). Radial and conic gradients -/// will be available soon. +/// [`gradient.linear` function]($gradient.linear) and radial gradients through +/// the [`gradient.radial` function]($gradient.radial). Conic gradients will be +/// available soon. /// /// See the [tracking issue](https://github.com/typst/typst/issues/2282) for /// more details on the progress of gradient implementation. /// +/// ```example +/// #stack( +/// dir: ltr, +/// square(size: 50pt, fill: gradient.linear(..color.map.rainbow)), +/// square(size: 50pt, fill: gradient.radial(..color.map.rainbow)), +/// ) +/// ``` +/// /// # Stops /// A gradient is composed of a series of stops. Each of these stops has a color -/// and an offset. The offset is a [ratio]($ratio) between `{0%}` and `{100%}` +/// and an offset. The offset is a [ratio]($ratio) between `{0%}` and `{100%}` or +/// an angle between `{0deg}` and `{360deg}`. The offset is a relative position /// that determines how far along the gradient the stop is located. The stop's /// color is the color of the gradient at that position. You can choose to omit /// the offsets when defining a gradient. In this case, Typst will space all @@ -161,11 +173,21 @@ use crate::syntax::{Span, Spanned}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum Gradient { Linear(Arc), + Radial(Arc), } #[scope] +#[allow(clippy::too_many_arguments)] impl Gradient { /// Creates a new linear gradient. + /// + /// ```example + /// #rect( + /// width: 100%, + /// height: 20pt, + /// fill: gradient.linear(..color.map.viridis) + /// ) + /// ``` #[func(title = "Linear Gradient")] pub fn linear( /// The args of this function. @@ -226,6 +248,123 @@ impl Gradient { }))) } + /// Creates a new radial gradient. + /// + /// ```example + /// #circle( + /// radius: 20pt, + /// fill: gradient.radial(..color.map.viridis) + /// ) + /// ``` + /// + /// _Focal Point_ + /// The gradient is defined by two circles: the focal circle and the end circle. + /// The focal circle is a circle with center `focal-center` and radius `focal-radius`, + /// that defines the points at which the gradient starts and has the color of the + /// first stop. The end circle is a circle with center `center` and radius `radius`, + /// that defines the points at which the gradient ends and has the color of the last + /// stop. The gradient is then interpolated between these two circles. + /// + /// Using these four values, also called the focal point for the starting circle and + /// the center and radius for the end circle, we can define a gradient with more + /// interesting properties than a basic radial gradient: + /// + /// ```example + /// #circle( + /// radius: 20pt, + /// fill: gradient.radial(..color.map.viridis, focal-center: (10%, 40%), focal-radius: 5%) + /// ) + /// ``` + #[func] + fn radial( + /// The call site of this function. + span: Span, + /// The color [stops](#stops) of the gradient. + #[variadic] + stops: Vec>, + /// The color space in which to interpolate the gradient. + /// + /// Defaults to a perceptually uniform color space called + /// [Oklab]($color.oklab). + #[named] + #[default(ColorSpace::Oklab)] + space: ColorSpace, + /// The [relative placement](#relativeness) of the gradient. + /// + /// For an element placed at the root/top level of the document, the parent + /// is the page itself. For other elements, the parent is the innermost block, + /// box, column, grid, or stack that contains the element. + #[named] + #[default(Smart::Auto)] + relative: Smart, + /// The center of the last circle of the gradient. + /// + /// A value of `{(50%, 50%)}` means that the end circle is + /// centered inside of its container. + #[named] + #[default(Axes::splat(Ratio::new(0.5)))] + center: Axes, + /// The radius of the last circle of the gradient. + /// + /// By default, it is set to `{50%}`. The ending radius must be bigger + /// than the focal radius. + #[named] + #[default(Spanned::new(Ratio::new(0.5), Span::detached()))] + radius: Spanned, + /// The center of the focal circle of the gradient. + /// + /// The focal center must be inside of the end circle. + /// + /// A value of `{(50%, 50%)}` means that the focal circle is + /// centered inside of its container. + /// + /// By default it is set to the same as the center of the last circle. + #[named] + #[default(Smart::Auto)] + focal_center: Smart>, + /// The radius of the focal circle of the gradient. + /// + /// The focal center must be inside of the end circle. + /// + /// By default, it is set to `{0%}`. The focal radius must be smaller + /// than the ending radius`. + #[named] + #[default(Spanned::new(Ratio::new(0.0), Span::detached()))] + focal_radius: Spanned, + ) -> SourceResult { + if stops.len() < 2 { + bail!(error!(span, "a gradient must have at least two stops") + .with_hint("try filling the shape with a single color instead")); + } + + if focal_radius.v > radius.v { + bail!(error!( + focal_radius.span, + "the focal radius must be smaller than the end radius" + ) + .with_hint("try using a focal radius of `0%` instead")); + } + + let focal_center = focal_center.unwrap_or(center); + let d_center_sqr = (focal_center.x - center.x).get().powi(2) + + (focal_center.y - center.y).get().powi(2); + if d_center_sqr.sqrt() >= (radius.v - focal_radius.v).get() { + bail!(error!(span, "the focal circle must be inside of the end circle") + .with_hint("try using a focal center of `auto` instead")); + } + + Ok(Gradient::Radial(Arc::new(RadialGradient { + stops: process_stops(&stops)?, + center: center.map(From::from), + radius: radius.v, + focal_center, + focal_radius: focal_radius.v, + space, + relative, + anti_alias: true, + }))) + } + /// Returns the stops of this gradient. #[func] pub fn stops(&self) -> Vec { @@ -235,6 +374,11 @@ impl Gradient { .iter() .map(|(color, offset)| Stop { color: *color, offset: Some(*offset) }) .collect(), + Self::Radial(radial) => radial + .stops + .iter() + .map(|(color, offset)| Stop { color: *color, offset: Some(*offset) }) + .collect(), } } @@ -243,6 +387,7 @@ impl Gradient { pub fn space(&self) -> ColorSpace { match self { Self::Linear(linear) => linear.space, + Self::Radial(radial) => radial.space, } } @@ -251,14 +396,16 @@ impl Gradient { pub fn relative(&self) -> Smart { match self { Self::Linear(linear) => linear.relative, + Self::Radial(radial) => radial.relative, } } /// Returns the angle of this gradient. #[func] - pub fn angle(&self) -> Angle { + pub fn angle(&self) -> Option { match self { - Self::Linear(linear) => linear.angle, + Self::Linear(linear) => Some(linear.angle), + Self::Radial(_) => None, } } @@ -267,6 +414,7 @@ impl Gradient { pub fn kind(&self) -> Func { match self { Self::Linear(_) => Self::linear_data().into(), + Self::Radial(_) => Self::radial_data().into(), } } @@ -287,6 +435,7 @@ impl Gradient { match self { Self::Linear(linear) => sample_stops(&linear.stops, linear.space, value), + Self::Radial(radial) => sample_stops(&radial.stops, radial.space, value), } } @@ -381,6 +530,16 @@ impl Gradient { relative: linear.relative, anti_alias: false, })), + Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient { + stops, + center: radial.center, + radius: radial.radius, + focal_center: radial.focal_center, + focal_radius: radial.focal_radius, + space: radial.space, + relative: radial.relative, + anti_alias: false, + })), }) } @@ -429,12 +588,22 @@ impl Gradient { stops.dedup(); Ok(match self { - Self::Linear(grad) => Self::Linear(Arc::new(LinearGradient { + Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient { stops, - angle: grad.angle, - space: grad.space, - relative: grad.relative, - anti_alias: grad.anti_alias, + angle: linear.angle, + space: linear.space, + relative: linear.relative, + anti_alias: linear.anti_alias, + })), + Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient { + stops, + center: radial.center, + radius: radial.radius, + focal_center: radial.focal_center, + focal_radius: radial.focal_radius, + space: radial.space, + relative: radial.relative, + anti_alias: radial.anti_alias, })), }) } @@ -445,17 +614,17 @@ impl Gradient { pub fn stops_ref(&self) -> &[(Color, Ratio)] { match self { Gradient::Linear(linear) => &linear.stops, + Gradient::Radial(radial) => &radial.stops, } } /// Samples the gradient at a given position, in the given container. /// Handles the aspect ratio and angle directly. pub fn sample_at(&self, (x, y): (f32, f32), (width, height): (f32, f32)) -> Color { + // Normalize the coordinates. + let (mut x, mut y) = (x / width, y / height); let t = match self { Self::Linear(linear) => { - // Normalize the coordinates. - let (mut x, mut y) = (x / width, y / height); - // Handle the direction of the gradient. let angle = linear.angle.to_rad().rem_euclid(TAU); @@ -481,15 +650,38 @@ impl Gradient { (x as f64 * cos.abs() + y as f64 * sin.abs()) / length } + Self::Radial(radial) => { + // Source: @Enivex - https://typst.app/project/pYLeS0QyCCe8mf0pdnwoAI + let cr = radial.radius.get(); + let fr = radial.focal_radius.get(); + let z = Vec2::new(x as f64, y as f64); + let p = Vec2::new(radial.center.x.get(), radial.center.y.get()); + let q = + Vec2::new(radial.focal_center.x.get(), radial.focal_center.y.get()); + + if (z - q).hypot() < fr { + 0.0 + } else if (z - p).hypot() > cr { + 1.0 + } else { + let uz = (z - q).normalize(); + let az = (q - p).dot(uz); + let rho = cr.powi(2) - (q - p).hypot().powi(2); + let bz = (az.powi(2) + rho).sqrt() - az; + + ((z - q).hypot() - fr) / (bz - fr) + } + } }; - self.sample(RatioOrAngle::Ratio(Ratio::new(t))) + self.sample(RatioOrAngle::Ratio(Ratio::new(t.clamp(0.0, 1.0)))) } /// Does this gradient need to be anti-aliased? pub fn anti_alias(&self) -> bool { match self { Self::Linear(linear) => linear.anti_alias, + Self::Radial(radial) => radial.anti_alias, } } @@ -523,6 +715,7 @@ impl Gradient { impl Repr for Gradient { fn repr(&self) -> EcoString { match self { + Self::Radial(radial) => radial.repr(), Self::Linear(linear) => linear.repr(), } } @@ -590,6 +783,87 @@ impl Repr for LinearGradient { } } +/// A gradient that interpolates between two colors along a circle. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct RadialGradient { + /// The color stops of this gradient. + pub stops: Vec<(Color, Ratio)>, + /// The center of last circle of this gradient. + pub center: Axes, + /// The radius of last circle of this gradient. + pub radius: Ratio, + /// The center of first circle of this gradient. + pub focal_center: Axes, + /// The radius of first circle of this gradient. + pub focal_radius: Ratio, + /// The color space in which to interpolate the gradient. + pub space: ColorSpace, + /// The relative placement of the gradient. + pub relative: Smart, + /// Whether to anti-alias the gradient (used for sharp gradients). + pub anti_alias: bool, +} + +impl Repr for RadialGradient { + fn repr(&self) -> EcoString { + let mut r = EcoString::from("gradient.radial("); + + if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) { + r.push_str("space: ("); + r.push_str(&self.center.x.repr()); + r.push_str(", "); + r.push_str(&self.center.y.repr()); + r.push_str("), "); + } + + if self.radius != Ratio::new(0.5) { + r.push_str("radius: "); + r.push_str(&self.radius.repr()); + r.push_str(", "); + } + + if self.focal_center != self.center { + r.push_str("focal-center: ("); + r.push_str(&self.focal_center.x.repr()); + r.push_str(", "); + r.push_str(&self.focal_center.y.repr()); + r.push_str("), "); + } + + if self.focal_radius != Ratio::zero() { + r.push_str("focal-radius: "); + r.push_str(&self.focal_radius.repr()); + r.push_str(", "); + } + + if self.space != ColorSpace::Oklab { + r.push_str("space: "); + r.push_str(&self.space.into_value().repr()); + r.push_str(", "); + } + + if self.relative.is_custom() { + r.push_str("relative: "); + r.push_str(&self.relative.into_value().repr()); + r.push_str(", "); + } + + for (i, (color, offset)) in self.stops.iter().enumerate() { + r.push('('); + r.push_str(&color.repr()); + r.push_str(", "); + r.push_str(&Angle::deg(offset.get() * 360.0).repr()); + r.push(')'); + if i != self.stops.len() - 1 { + r.push_str(", "); + } + } + + r.push(')'); + r + } +} + /// What is the gradient relative to. #[derive(Cast, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Relative { diff --git a/tests/ref/visualize/gradient-radial.png b/tests/ref/visualize/gradient-radial.png new file mode 100644 index 00000000..2e8e9af3 Binary files /dev/null and b/tests/ref/visualize/gradient-radial.png differ diff --git a/tests/ref/visualize/gradient-relative.png b/tests/ref/visualize/gradient-relative-linear.png similarity index 100% rename from tests/ref/visualize/gradient-relative.png rename to tests/ref/visualize/gradient-relative-linear.png diff --git a/tests/ref/visualize/gradient-relative-radial.png b/tests/ref/visualize/gradient-relative-radial.png new file mode 100644 index 00000000..210ea7b0 Binary files /dev/null and b/tests/ref/visualize/gradient-relative-radial.png differ diff --git a/tests/ref/visualize/gradient-sharp.png b/tests/ref/visualize/gradient-sharp.png index 30e6fb66..a01cf08f 100644 Binary files a/tests/ref/visualize/gradient-sharp.png and b/tests/ref/visualize/gradient-sharp.png differ diff --git a/tests/ref/visualize/gradient-stroke.png b/tests/ref/visualize/gradient-stroke.png index 75f37c51..c7bc765b 100644 Binary files a/tests/ref/visualize/gradient-stroke.png and b/tests/ref/visualize/gradient-stroke.png differ diff --git a/tests/typ/visualize/gradient-radial.typ b/tests/typ/visualize/gradient-radial.typ new file mode 100644 index 00000000..5d83e71f --- /dev/null +++ b/tests/typ/visualize/gradient-radial.typ @@ -0,0 +1,49 @@ +// Test the different radial gradient features. +--- + +#square( + size: 100pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl), +) +--- + +#grid( + columns: 2, + square( + size: 50pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (0%, 0%)), + ), + square( + size: 50pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (0%, 100%)), + ), + square( + size: 50pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (100%, 0%)), + ), + square( + size: 50pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (100%, 100%)), + ), +) + +--- + +#square( + size: 50pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl, radius: 10%), +) +#square( + size: 50pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl, radius: 72%), +) + +--- +#circle( + radius: 25pt, + fill: gradient.radial(white, rgb("#8fbc8f"), focal-center: (35%, 35%), focal-radius: 5%), +) +#circle( + radius: 25pt, + fill: gradient.radial(white, rgb("#8fbc8f"), focal-center: (75%, 35%), focal-radius: 5%), +) \ No newline at end of file diff --git a/tests/typ/visualize/gradient-relative.typ b/tests/typ/visualize/gradient-relative-linear.typ similarity index 91% rename from tests/typ/visualize/gradient-relative.typ rename to tests/typ/visualize/gradient-relative-linear.typ index 549a05b4..8e1d04dc 100644 --- a/tests/typ/visualize/gradient-relative.typ +++ b/tests/typ/visualize/gradient-relative-linear.typ @@ -1,5 +1,4 @@ -// Test whether `relative: "parent"` works correctly. - +// Test whether `relative: "parent"` works correctly on linear gradients. --- // The image should look as if there is a single gradient that is being used for diff --git a/tests/typ/visualize/gradient-relative-radial.typ b/tests/typ/visualize/gradient-relative-radial.typ new file mode 100644 index 00000000..87686896 --- /dev/null +++ b/tests/typ/visualize/gradient-relative-radial.typ @@ -0,0 +1,29 @@ +// Test whether `relative: "parent"` works correctly on radial gradients. + +--- +// The image should look as if there is a single gradient that is being used for +// both the page and the rectangles. +#let grad = gradient.radial(red, blue, green, purple, relative: "parent"); +#let my-rect = rect(width: 50%, height: 50%, fill: grad) +#set page( + height: 200pt, + width: 200pt, + fill: grad, + background: place(top + left, my-rect), +) +#place(top + right, my-rect) +#place(bottom + center, rotate(45deg, my-rect)) + +--- +// The image should look as if there are multiple gradients, one for each +// rectangle. +#let grad = gradient.radial(red, blue, green, purple, relative: "self"); +#let my-rect = rect(width: 50%, height: 50%, fill: grad) +#set page( + height: 200pt, + width: 200pt, + fill: grad, + background: place(top + left, my-rect), +) +#place(top + right, my-rect) +#place(bottom + center, rotate(45deg, my-rect)) diff --git a/tests/typ/visualize/gradient-sharp.typ b/tests/typ/visualize/gradient-sharp.typ index 424beb8b..1f090f7e 100644 --- a/tests/typ/visualize/gradient-sharp.typ +++ b/tests/typ/visualize/gradient-sharp.typ @@ -5,9 +5,17 @@ size: 100pt, fill: gradient.linear(..color.map.rainbow, space: color.hsl).sharp(10), ) +#square( + size: 100pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10), +) --- #square( size: 100pt, fill: gradient.linear(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%), ) +#square( + size: 100pt, + fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%), +) diff --git a/tests/typ/visualize/gradient-stroke.typ b/tests/typ/visualize/gradient-stroke.typ index a156dde7..01616fe3 100644 --- a/tests/typ/visualize/gradient-stroke.typ +++ b/tests/typ/visualize/gradient-stroke.typ @@ -1,8 +1,16 @@ // Test gradients on strokes. --- -#set page(width: 100pt, height: 100pt) -#align(center + horizon, square(size: 50pt, fill: black, stroke: 5pt + gradient.linear(red, blue))) +#set page(width: 100pt, height: auto, margin: 10pt) +#align(center + top, square(size: 50pt, fill: black, stroke: 5pt + gradient.linear(red, blue))) +#align( + center + bottom, + square( + size: 50pt, + fill: gradient.radial(red, blue, radius: 70.7%, focal-center: (10%, 10%)), + stroke: 10pt + gradient.radial(red, blue, radius: 70.7%, focal-center: (10%, 10%)) + ) +) --- // Test gradient on lines