summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/visualize/gradient.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/visualize/gradient.rs')
-rw-r--r--crates/typst-library/src/visualize/gradient.rs1260
1 files changed, 1260 insertions, 0 deletions
diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs
new file mode 100644
index 00000000..2be7e370
--- /dev/null
+++ b/crates/typst-library/src/visualize/gradient.rs
@@ -0,0 +1,1260 @@
+use std::f64::consts::{FRAC_PI_2, PI, TAU};
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+use std::sync::Arc;
+
+use ecow::EcoString;
+use kurbo::Vec2;
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, SourceResult};
+use crate::foundations::{
+ array, cast, func, scope, ty, Args, Array, Cast, Func, IntoValue, Repr, Smart,
+};
+use crate::layout::{Angle, Axes, Dir, Quadrant, Ratio};
+use crate::visualize::{Color, ColorSpace, WeightedColor};
+
+/// A color gradient.
+///
+/// Typst supports linear gradients through the
+/// [`gradient.linear` function]($gradient.linear), radial gradients through
+/// the [`gradient.radial` function]($gradient.radial), and conic gradients
+/// through the [`gradient.conic` function]($gradient.conic).
+///
+/// A gradient can be used for the following purposes:
+/// - As a fill to paint the interior of a shape:
+/// `{rect(fill: gradient.linear(..))}`
+/// - As a stroke to paint the outline of a shape:
+/// `{rect(stroke: 1pt + gradient.linear(..))}`
+/// - As the fill of text:
+/// `{set text(fill: gradient.linear(..))}`
+/// - As a color map you can [sample]($gradient.sample) from:
+/// `{gradient.linear(..).sample(50%)}`
+///
+/// # Examples
+/// ```example
+/// >>> #set square(size: 50pt)
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// square(fill: gradient.linear(..color.map.rainbow)),
+/// square(fill: gradient.radial(..color.map.rainbow)),
+/// square(fill: gradient.conic(..color.map.rainbow)),
+/// )
+/// ```
+///
+/// Gradients are also supported on text, but only when setting the
+/// [relativeness]($gradient.relative) to either `{auto}` (the default value) or
+/// `{"parent"}`. To create word-by-word or glyph-by-glyph gradients, you can
+/// wrap the words or characters of your text in [boxes]($box) manually or
+/// through a [show rule]($styling/#show-rules).
+///
+/// ```example
+/// >>> #set page(width: auto, height: auto, margin: 12pt)
+/// >>> #set text(size: 12pt)
+/// #set text(fill: gradient.linear(red, blue))
+/// #let rainbow(content) = {
+/// set text(fill: gradient.linear(..color.map.rainbow))
+/// box(content)
+/// }
+///
+/// This is a gradient on text, but with a #rainbow[twist]!
+/// ```
+///
+/// # 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%}` 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
+/// stops evenly.
+///
+/// # Relativeness
+/// The location of the `{0%}` and `{100%}` stops depends on the dimensions
+/// of a container. This container can either be the shape that it is being
+/// painted on, or the closest surrounding container. This is controlled by the
+/// `relative` argument of a gradient constructor. By default, gradients are
+/// relative to the shape they are being painted on, unless the gradient is
+/// applied on text, in which case they are relative to the closest ancestor
+/// container.
+///
+/// Typst determines the ancestor container as follows:
+/// - For shapes that are placed at the root/top level of the document, the
+/// closest ancestor is the page itself.
+/// - For other shapes, the ancestor is the innermost [`block`] or [`box`] that
+/// contains the shape. This includes the boxes and blocks that are implicitly
+/// created by show rules and elements. For example, a [`rotate`] will not
+/// affect the parent of a gradient, but a [`grid`] will.
+///
+/// # Color spaces and interpolation
+/// Gradients can be interpolated in any color space. By default, gradients are
+/// interpolated in the [Oklab]($color.oklab) color space, which is a
+/// [perceptually uniform](https://programmingdesignsystems.com/color/perceptually-uniform-color-spaces/index.html)
+/// color space. This means that the gradient will be perceived as having a
+/// smooth progression of colors. This is particularly useful for data
+/// visualization.
+///
+/// However, you can choose to interpolate the gradient in any supported color
+/// space you want, but beware that some color spaces are not suitable for
+/// perceptually interpolating between colors. Consult the table below when
+/// choosing an interpolation space.
+///
+/// | Color space | Perceptually uniform? |
+/// | ------------------------------- |-----------------------|
+/// | [Oklab]($color.oklab) | *Yes* |
+/// | [Oklch]($color.oklch) | *Yes* |
+/// | [sRGB]($color.rgb) | *No* |
+/// | [linear-RGB]($color.linear-rgb) | *Yes* |
+/// | [CMYK]($color.cmyk) | *No* |
+/// | [Grayscale]($color.luma) | *Yes* |
+/// | [HSL]($color.hsl) | *No* |
+/// | [HSV]($color.hsv) | *No* |
+///
+/// ```preview
+/// >>> #set text(fill: white, font: "IBM Plex Sans", 8pt)
+/// >>> #set block(spacing: 0pt)
+/// #let spaces = (
+/// ("Oklab", color.oklab),
+/// ("Oklch", color.oklch),
+/// ("linear-RGB", color.linear-rgb),
+/// ("sRGB", color.rgb),
+/// ("CMYK", color.cmyk),
+/// ("HSL", color.hsl),
+/// ("HSV", color.hsv),
+/// ("Grayscale", color.luma),
+/// )
+///
+/// #for (name, space) in spaces {
+/// block(
+/// width: 100%,
+/// inset: 4pt,
+/// fill: gradient.linear(
+/// red,
+/// blue,
+/// space: space,
+/// ),
+/// strong(upper(name)),
+/// )
+/// }
+/// ```
+///
+/// # Direction
+/// Some gradients are sensitive to direction. For example, a linear gradient
+/// has an angle that determines its direction. Typst uses a clockwise angle,
+/// with 0° being from left to right, 90° from top to bottom, 180° from right to
+/// left, and 270° from bottom to top.
+///
+/// ```example
+/// >>> #set square(size: 50pt)
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// square(fill: gradient.linear(red, blue, angle: 0deg)),
+/// square(fill: gradient.linear(red, blue, angle: 90deg)),
+/// square(fill: gradient.linear(red, blue, angle: 180deg)),
+/// square(fill: gradient.linear(red, blue, angle: 270deg)),
+/// )
+/// ```
+///
+/// # Presets
+/// Typst predefines color maps that you can use with your gradients. See the
+/// [`color`]($color/#predefined-color-maps) documentation for more details.
+///
+/// # Note on file sizes
+///
+/// Gradients can be quite large, especially if they have many stops. This is
+/// because gradients are stored as a list of colors and offsets, which can
+/// take up a lot of space. If you are concerned about file sizes, you should
+/// consider the following:
+/// - SVG gradients are currently inefficiently encoded. This will be improved
+/// in the future.
+/// - PDF gradients in the [`color.oklab`]($color.oklab), [`color.hsv`]($color.hsv),
+/// [`color.hsl`]($color.hsl), and [`color.oklch`]($color.oklch) color spaces
+/// are stored as a list of [`color.rgb`]($color.rgb) colors with extra stops
+/// in between. This avoids needing to encode these color spaces in your PDF
+/// file, but it does add extra stops to your gradient, which can increase
+/// the file size.
+#[ty(scope, cast)]
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub enum Gradient {
+ Linear(Arc<LinearGradient>),
+ Radial(Arc<RadialGradient>),
+ Conic(Arc<ConicGradient>),
+}
+
+#[scope]
+#[allow(clippy::too_many_arguments)]
+impl Gradient {
+ /// Creates a new linear gradient, in which colors transition along a
+ /// straight line.
+ ///
+ /// ```example
+ /// #rect(
+ /// width: 100%,
+ /// height: 20pt,
+ /// fill: gradient.linear(
+ /// ..color.map.viridis,
+ /// ),
+ /// )
+ /// ```
+ #[func(title = "Linear Gradient")]
+ pub fn linear(
+ /// The args of this function.
+ args: &mut Args,
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// 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<RelativeTo>,
+ /// The direction of the gradient.
+ #[external]
+ #[default(Dir::LTR)]
+ dir: Dir,
+ /// The angle of the gradient.
+ #[external]
+ angle: Angle,
+ ) -> SourceResult<Gradient> {
+ let angle = if let Some(angle) = args.named::<Angle>("angle")? {
+ angle
+ } else if let Some(dir) = args.named::<Dir>("dir")? {
+ match dir {
+ Dir::LTR => Angle::rad(0.0),
+ Dir::RTL => Angle::rad(PI),
+ Dir::TTB => Angle::rad(FRAC_PI_2),
+ Dir::BTT => Angle::rad(3.0 * FRAC_PI_2),
+ }
+ } else {
+ Angle::rad(0.0)
+ };
+
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ Ok(Self::Linear(Arc::new(LinearGradient {
+ stops: process_stops(&stops)?,
+ angle,
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a new radial gradient, in which colors radiate away from an
+ /// origin.
+ ///
+ /// 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
+ /// >>> #set circle(radius: 30pt)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// circle(fill: gradient.radial(
+ /// ..color.map.viridis,
+ /// )),
+ /// circle(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<Spanned<GradientStop>>,
+ /// 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<RelativeTo>,
+ /// The center of the end 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<Ratio>,
+ /// The radius of the end 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<Ratio>,
+ /// 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<Axes<Ratio>>,
+ /// 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<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ if focal_radius.v > radius.v {
+ bail!(
+ focal_radius.span,
+ "the focal radius must be smaller than the end radius";
+ 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!(
+ span,
+ "the focal circle must be inside of the end circle";
+ 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,
+ })))
+ }
+
+ /// Creates a new conic gradient, in which colors change radially around a
+ /// center point.
+ ///
+ /// You can control the center point of the gradient by using the `center`
+ /// argument. By default, the center point is the center of the shape.
+ ///
+ /// ```example
+ /// >>> #set circle(radius: 30pt)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// circle(fill: gradient.conic(
+ /// ..color.map.viridis,
+ /// )),
+ /// circle(fill: gradient.conic(
+ /// ..color.map.viridis,
+ /// center: (20%, 30%),
+ /// )),
+ /// )
+ /// ```
+ #[func]
+ pub fn conic(
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// The angle of the gradient.
+ #[named]
+ #[default(Angle::zero())]
+ angle: Angle,
+ /// 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<RelativeTo>,
+ /// 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<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ Ok(Gradient::Conic(Arc::new(ConicGradient {
+ stops: process_stops(&stops)?,
+ angle,
+ center: center.map(From::from),
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a sharp version of this gradient.
+ ///
+ /// Sharp gradients have discrete jumps between colors, instead of a
+ /// smooth transition. They are particularly useful for creating color
+ /// lists for a preset gradient.
+ ///
+ /// ```example
+ /// #set rect(width: 100%, height: 20pt)
+ /// #let grad = gradient.linear(..color.map.rainbow)
+ /// #rect(fill: grad)
+ /// #rect(fill: grad.sharp(5))
+ /// #rect(fill: grad.sharp(5, smoothness: 20%))
+ /// ```
+ #[func]
+ pub fn sharp(
+ &self,
+ /// The number of stops in the gradient.
+ steps: Spanned<usize>,
+ /// How much to smooth the gradient.
+ #[named]
+ #[default(Spanned::new(Ratio::zero(), Span::detached()))]
+ smoothness: Spanned<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if steps.v < 2 {
+ bail!(steps.span, "sharp gradients must have at least two stops");
+ }
+
+ if smoothness.v.get() < 0.0 || smoothness.v.get() > 1.0 {
+ bail!(smoothness.span, "smoothness must be between 0 and 1");
+ }
+
+ let n = steps.v;
+ let smoothness = smoothness.v.get();
+ let colors = (0..n)
+ .flat_map(|i| {
+ let c = self
+ .sample(RatioOrAngle::Ratio(Ratio::new(i as f64 / (n - 1) as f64)));
+
+ [c, c]
+ })
+ .collect::<Vec<_>>();
+
+ let mut positions = Vec::with_capacity(n * 2);
+ let index_to_progress = |i| i as f64 * 1.0 / n as f64;
+
+ let progress = smoothness * 1.0 / (4.0 * n as f64);
+ for i in 0..n {
+ let mut j = 2 * i;
+ positions.push(index_to_progress(i));
+ if j > 0 {
+ positions[j] += progress;
+ }
+
+ j += 1;
+ positions.push(index_to_progress(i + 1));
+ if j < colors.len() - 1 {
+ positions[j] -= progress;
+ }
+ }
+
+ let mut stops = colors
+ .into_iter()
+ .zip(positions)
+ .map(|(c, p)| (c, Ratio::new(p)))
+ .collect::<Vec<_>>();
+
+ stops.dedup();
+
+ Ok(match self {
+ Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
+ stops,
+ angle: linear.angle,
+ space: linear.space,
+ 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,
+ })),
+ Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
+ stops,
+ angle: conic.angle,
+ center: conic.center,
+ space: conic.space,
+ relative: conic.relative,
+ anti_alias: false,
+ })),
+ })
+ }
+
+ /// Repeats this gradient a given number of times, optionally mirroring it
+ /// at each repetition.
+ ///
+ /// ```example
+ /// #circle(
+ /// radius: 40pt,
+ /// fill: gradient
+ /// .radial(aqua, white)
+ /// .repeat(4),
+ /// )
+ /// ```
+ #[func]
+ pub fn repeat(
+ &self,
+ /// The number of times to repeat the gradient.
+ repetitions: Spanned<usize>,
+ /// Whether to mirror the gradient at each repetition.
+ #[named]
+ #[default(false)]
+ mirror: bool,
+ ) -> SourceResult<Gradient> {
+ if repetitions.v == 0 {
+ bail!(repetitions.span, "must repeat at least once");
+ }
+
+ let n = repetitions.v;
+ let mut stops = std::iter::repeat(self.stops_ref())
+ .take(n)
+ .enumerate()
+ .flat_map(|(i, stops)| {
+ let mut stops = stops
+ .iter()
+ .map(move |&(color, offset)| {
+ let t = i as f64 / n as f64;
+ let r = offset.get();
+ if i % 2 == 1 && mirror {
+ (color, Ratio::new(t + (1.0 - r) / n as f64))
+ } else {
+ (color, Ratio::new(t + r / n as f64))
+ }
+ })
+ .collect::<Vec<_>>();
+
+ if i % 2 == 1 && mirror {
+ stops.reverse();
+ }
+
+ stops
+ })
+ .collect::<Vec<_>>();
+
+ stops.dedup();
+
+ Ok(match self {
+ Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
+ stops,
+ 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,
+ })),
+ Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
+ stops,
+ angle: conic.angle,
+ center: conic.center,
+ space: conic.space,
+ relative: conic.relative,
+ anti_alias: conic.anti_alias,
+ })),
+ })
+ }
+
+ /// Returns the kind of this gradient.
+ #[func]
+ pub fn kind(&self) -> Func {
+ match self {
+ Self::Linear(_) => Self::linear_data().into(),
+ Self::Radial(_) => Self::radial_data().into(),
+ Self::Conic(_) => Self::conic_data().into(),
+ }
+ }
+
+ /// Returns the stops of this gradient.
+ #[func]
+ pub fn stops(&self) -> Vec<GradientStop> {
+ match self {
+ Self::Linear(linear) => linear
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ Self::Radial(radial) => radial
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ Self::Conic(conic) => conic
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ }
+ }
+
+ /// Returns the mixing space of this gradient.
+ #[func]
+ pub fn space(&self) -> ColorSpace {
+ match self {
+ Self::Linear(linear) => linear.space,
+ Self::Radial(radial) => radial.space,
+ Self::Conic(conic) => conic.space,
+ }
+ }
+
+ /// Returns the relative placement of this gradient.
+ #[func]
+ pub fn relative(&self) -> Smart<RelativeTo> {
+ match self {
+ Self::Linear(linear) => linear.relative,
+ Self::Radial(radial) => radial.relative,
+ Self::Conic(conic) => conic.relative,
+ }
+ }
+
+ /// Returns the angle of this gradient.
+ #[func]
+ pub fn angle(&self) -> Option<Angle> {
+ match self {
+ Self::Linear(linear) => Some(linear.angle),
+ Self::Radial(_) => None,
+ Self::Conic(conic) => Some(conic.angle),
+ }
+ }
+
+ /// Sample the gradient at a given position.
+ ///
+ /// The position is either a position along the gradient (a [ratio] between
+ /// `{0%}` and `{100%}`) or an [angle]. Any value outside of this range will
+ /// be clamped.
+ #[func]
+ pub fn sample(
+ &self,
+ /// The position at which to sample the gradient.
+ t: RatioOrAngle,
+ ) -> Color {
+ let value: f64 = t.to_ratio().get();
+
+ match self {
+ Self::Linear(linear) => sample_stops(&linear.stops, linear.space, value),
+ Self::Radial(radial) => sample_stops(&radial.stops, radial.space, value),
+ Self::Conic(conic) => sample_stops(&conic.stops, conic.space, value),
+ }
+ }
+
+ /// Samples the gradient at multiple positions at once and returns the
+ /// results as an array.
+ #[func]
+ pub fn samples(
+ &self,
+ /// The positions at which to sample the gradient.
+ #[variadic]
+ ts: Vec<RatioOrAngle>,
+ ) -> Array {
+ ts.into_iter().map(|t| self.sample(t).into_value()).collect()
+ }
+}
+
+impl Gradient {
+ /// Clones this gradient, but with a different relative placement.
+ pub fn with_relative(mut self, relative: RelativeTo) -> Self {
+ match &mut self {
+ Self::Linear(linear) => {
+ Arc::make_mut(linear).relative = Smart::Custom(relative);
+ }
+ Self::Radial(radial) => {
+ Arc::make_mut(radial).relative = Smart::Custom(relative);
+ }
+ Self::Conic(conic) => {
+ Arc::make_mut(conic).relative = Smart::Custom(relative);
+ }
+ }
+
+ self
+ }
+ /// Returns a reference to the stops of this gradient.
+ pub fn stops_ref(&self) -> &[(Color, Ratio)] {
+ match self {
+ Gradient::Linear(linear) => &linear.stops,
+ Gradient::Radial(radial) => &radial.stops,
+ Gradient::Conic(conic) => &conic.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) => {
+ // Aspect ratio correction.
+ let angle = Gradient::correct_aspect_ratio(
+ linear.angle,
+ Ratio::new((width / height) as f64),
+ )
+ .to_rad();
+ let (sin, cos) = angle.sin_cos();
+
+ let length = sin.abs() + cos.abs();
+ if angle > FRAC_PI_2 && angle < 3.0 * FRAC_PI_2 {
+ x = 1.0 - x;
+ }
+
+ if angle > PI {
+ y = 1.0 - y;
+ }
+
+ (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::Conic(conic) => {
+ let (x, y) =
+ (x as f64 - conic.center.x.get(), y as f64 - conic.center.y.get());
+ let angle = Gradient::correct_aspect_ratio(
+ conic.angle,
+ Ratio::new((width / height) as f64),
+ );
+ ((-y.atan2(x) + PI + angle.to_rad()) % TAU) / TAU
+ }
+ };
+
+ 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,
+ Self::Conic(conic) => conic.anti_alias,
+ }
+ }
+
+ /// Returns the relative placement of this gradient, handling
+ /// the special case of `auto`.
+ pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo {
+ self.relative().unwrap_or_else(|| {
+ if on_text {
+ RelativeTo::Parent
+ } else {
+ RelativeTo::Self_
+ }
+ })
+ }
+
+ /// Corrects this angle for the aspect ratio of a gradient.
+ ///
+ /// This is used specifically for gradients.
+ pub fn correct_aspect_ratio(angle: Angle, aspect_ratio: Ratio) -> Angle {
+ let rad = (angle.to_rad().rem_euclid(TAU).tan() / aspect_ratio.get()).atan();
+ let rad = match angle.quadrant() {
+ Quadrant::First => rad,
+ Quadrant::Second => rad + PI,
+ Quadrant::Third => rad + PI,
+ Quadrant::Fourth => rad + TAU,
+ };
+ Angle::rad(rad.rem_euclid(TAU))
+ }
+}
+
+impl Debug for Gradient {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Linear(v) => v.fmt(f),
+ Self::Radial(v) => v.fmt(f),
+ Self::Conic(v) => v.fmt(f),
+ }
+ }
+}
+
+impl Repr for Gradient {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Radial(radial) => radial.repr(),
+ Self::Linear(linear) => linear.repr(),
+ Self::Conic(conic) => conic.repr(),
+ }
+ }
+}
+
+/// A gradient that interpolates between two colors along an axis.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct LinearGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The direction of this gradient.
+ pub angle: Angle,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for LinearGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.linear(");
+
+ let angle = self.angle.to_rad().rem_euclid(TAU);
+ if angle.abs() < f64::EPSILON {
+ // Default value, do nothing
+ } else if (angle - FRAC_PI_2).abs() < f64::EPSILON {
+ r.push_str("dir: rtl, ");
+ } else if (angle - PI).abs() < f64::EPSILON {
+ r.push_str("dir: ttb, ");
+ } else if (angle - 3.0 * FRAC_PI_2).abs() < f64::EPSILON {
+ r.push_str("dir: btt, ");
+ } else {
+ r.push_str("angle: ");
+ r.push_str(&self.angle.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(&offset.repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// 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<Ratio>,
+ /// The radius of last circle of this gradient.
+ pub radius: Ratio,
+ /// The center of first circle of this gradient.
+ pub focal_center: Axes<Ratio>,
+ /// 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<RelativeTo>,
+ /// 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("center: (");
+ 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(&offset.repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// A gradient that interpolates between two colors radially
+/// around a center point.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct ConicGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The direction of this gradient.
+ pub angle: Angle,
+ /// The center of last circle of this gradient.
+ pub center: Axes<Ratio>,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for ConicGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.conic(");
+
+ let angle = self.angle.to_rad().rem_euclid(TAU);
+ if angle.abs() > f64::EPSILON {
+ r.push_str("angle: ");
+ r.push_str(&self.angle.repr());
+ r.push_str(", ");
+ }
+
+ if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) {
+ r.push_str("center: (");
+ r.push_str(&self.center.x.repr());
+ r.push_str(", ");
+ r.push_str(&self.center.y.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 RelativeTo {
+ /// The gradient is relative to itself (its own bounding box).
+ Self_,
+ /// The gradient is relative to its parent (the parent's bounding box).
+ Parent,
+}
+
+/// A color stop.
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub struct GradientStop {
+ /// The color for this stop.
+ pub color: Color,
+ /// The offset of the stop along the gradient.
+ pub offset: Option<Ratio>,
+}
+
+impl GradientStop {
+ /// Create a new stop from a `color` and an `offset`.
+ pub fn new(color: Color, offset: Ratio) -> Self {
+ Self { color, offset: Some(offset) }
+ }
+}
+
+cast! {
+ GradientStop,
+ self => if let Some(offset) = self.offset {
+ array![self.color.into_value(), offset].into_value()
+ } else {
+ self.color.into_value()
+ },
+ color: Color => Self { color, offset: None },
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Self {
+ color: a.cast()?,
+ offset: Some(b.cast()?)
+ },
+ _ => Err("a color stop must contain exactly two entries")?,
+ }
+ }
+}
+
+/// A ratio or an angle.
+#[derive(Copy, Clone, PartialEq, Eq, Hash)]
+pub enum RatioOrAngle {
+ Ratio(Ratio),
+ Angle(Angle),
+}
+
+impl RatioOrAngle {
+ pub fn to_ratio(self) -> Ratio {
+ match self {
+ Self::Ratio(ratio) => ratio,
+ Self::Angle(angle) => Ratio::new(angle.to_rad().rem_euclid(TAU) / TAU),
+ }
+ .clamp(Ratio::zero(), Ratio::one())
+ }
+}
+
+cast! {
+ RatioOrAngle,
+ self => match self {
+ Self::Ratio(ratio) => ratio.into_value(),
+ Self::Angle(angle) => angle.into_value(),
+ },
+ ratio: Ratio => Self::Ratio(ratio),
+ angle: Angle => Self::Angle(angle),
+}
+
+/// Pre-processes the stops, checking that they are valid and computing the
+/// offsets if necessary.
+///
+/// Returns an error if the stops are invalid.
+///
+/// This is split into its own function because it is used by all of the
+/// different gradient types.
+#[comemo::memoize]
+fn process_stops(stops: &[Spanned<GradientStop>]) -> SourceResult<Vec<(Color, Ratio)>> {
+ let has_offset = stops.iter().any(|stop| stop.v.offset.is_some());
+ if has_offset {
+ let mut last_stop = f64::NEG_INFINITY;
+ for Spanned { v: stop, span } in stops.iter() {
+ let Some(stop) = stop.offset else {
+ bail!(
+ *span, "either all stops must have an offset or none of them can";
+ hint: "try adding an offset to all stops"
+ );
+ };
+
+ if stop.get() < last_stop {
+ bail!(*span, "offsets must be in strictly monotonic order");
+ }
+
+ last_stop = stop.get();
+ }
+
+ let out = stops
+ .iter()
+ .map(|Spanned { v: GradientStop { color, offset }, span }| {
+ if offset.unwrap().get() > 1.0 || offset.unwrap().get() < 0.0 {
+ bail!(*span, "offset must be between 0 and 1");
+ }
+ Ok((*color, offset.unwrap()))
+ })
+ .collect::<SourceResult<Vec<_>>>()?;
+
+ if out[0].1 != Ratio::zero() {
+ bail!(
+ stops[0].span,
+ "first stop must have an offset of 0";
+ hint: "try setting this stop to `0%`"
+ );
+ }
+
+ if out[out.len() - 1].1 != Ratio::one() {
+ bail!(
+ stops[out.len() - 1].span,
+ "last stop must have an offset of 100%";
+ hint: "try setting this stop to `100%`"
+ );
+ }
+
+ return Ok(out);
+ }
+
+ Ok(stops
+ .iter()
+ .enumerate()
+ .map(|(i, stop)| {
+ let offset = i as f64 / (stops.len() - 1) as f64;
+ (stop.v.color, Ratio::new(offset))
+ })
+ .collect())
+}
+
+/// Sample the stops at a given position.
+fn sample_stops(stops: &[(Color, Ratio)], mixing_space: ColorSpace, t: f64) -> Color {
+ let t = t.clamp(0.0, 1.0);
+ let mut low = 0;
+ let mut high = stops.len();
+
+ while low < high {
+ let mid = (low + high) / 2;
+ if stops[mid].1.get() < t {
+ low = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+
+ if low == 0 {
+ low = 1;
+ }
+
+ let (col_0, pos_0) = stops[low - 1];
+ let (col_1, pos_1) = stops[low];
+ let t = (t - pos_0.get()) / (pos_1.get() - pos_0.get());
+
+ Color::mix_iter(
+ [WeightedColor::new(col_0, 1.0 - t), WeightedColor::new(col_1, t)],
+ mixing_space,
+ )
+ .unwrap()
+}