typst_library/visualize/
gradient.rs

1use std::f64::consts::{FRAC_PI_2, PI, TAU};
2use std::fmt::{self, Debug, Formatter};
3use std::hash::Hash;
4use std::sync::Arc;
5
6use ecow::EcoString;
7use kurbo::Vec2;
8use typst_syntax::{Span, Spanned};
9
10use crate::diag::{bail, SourceResult};
11use crate::foundations::{
12    array, cast, func, scope, ty, Args, Array, Cast, Func, IntoValue, Repr, Smart,
13};
14use crate::layout::{Angle, Axes, Dir, Quadrant, Ratio};
15use crate::visualize::{Color, ColorSpace, WeightedColor};
16
17/// A color gradient.
18///
19/// Typst supports linear gradients through the
20/// [`gradient.linear` function]($gradient.linear), radial gradients through
21/// the [`gradient.radial` function]($gradient.radial), and conic gradients
22/// through the [`gradient.conic` function]($gradient.conic).
23///
24/// A gradient can be used for the following purposes:
25/// - As a fill to paint the interior of a shape:
26///   `{rect(fill: gradient.linear(..))}`
27/// - As a stroke to paint the outline of a shape:
28///   `{rect(stroke: 1pt + gradient.linear(..))}`
29/// - As the fill of text:
30///   `{set text(fill: gradient.linear(..))}`
31/// - As a color map you can [sample]($gradient.sample) from:
32///   `{gradient.linear(..).sample(50%)}`
33///
34/// # Examples
35/// ```example
36/// >>> #set square(size: 50pt)
37/// #stack(
38///   dir: ltr,
39///   spacing: 1fr,
40///   square(fill: gradient.linear(..color.map.rainbow)),
41///   square(fill: gradient.radial(..color.map.rainbow)),
42///   square(fill: gradient.conic(..color.map.rainbow)),
43/// )
44/// ```
45///
46/// Gradients are also supported on text, but only when setting the
47/// [relativeness]($gradient.relative) to either `{auto}` (the default value) or
48/// `{"parent"}`. To create word-by-word or glyph-by-glyph gradients, you can
49/// wrap the words or characters of your text in [boxes]($box) manually or
50/// through a [show rule]($styling/#show-rules).
51///
52/// ```example
53/// >>> #set page(width: auto, height: auto, margin: 12pt)
54/// >>> #set text(size: 12pt)
55/// #set text(fill: gradient.linear(red, blue))
56/// #let rainbow(content) = {
57///   set text(fill: gradient.linear(..color.map.rainbow))
58///   box(content)
59/// }
60///
61/// This is a gradient on text, but with a #rainbow[twist]!
62/// ```
63///
64/// # Stops
65/// A gradient is composed of a series of stops. Each of these stops has a color
66/// and an offset. The offset is a [ratio]($ratio) between `{0%}` and `{100%}` or
67/// an angle between `{0deg}` and `{360deg}`. The offset is a relative position
68/// that determines how far along the gradient the stop is located. The stop's
69/// color is the color of the gradient at that position. You can choose to omit
70/// the offsets when defining a gradient. In this case, Typst will space all
71/// stops evenly.
72///
73/// # Relativeness
74/// The location of the `{0%}` and `{100%}` stops depends on the dimensions
75/// of a container. This container can either be the shape that it is being
76/// painted on, or the closest surrounding container. This is controlled by the
77/// `relative` argument of a gradient constructor. By default, gradients are
78/// relative to the shape they are being painted on, unless the gradient is
79/// applied on text, in which case they are relative to the closest ancestor
80/// container.
81///
82/// Typst determines the ancestor container as follows:
83/// - For shapes that are placed at the root/top level of the document, the
84///   closest ancestor is the page itself.
85/// - For other shapes, the ancestor is the innermost [`block`] or [`box`] that
86///   contains the shape. This includes the boxes and blocks that are implicitly
87///   created by show rules and elements. For example, a [`rotate`] will not
88///   affect the parent of a gradient, but a [`grid`] will.
89///
90/// # Color spaces and interpolation
91/// Gradients can be interpolated in any color space. By default, gradients are
92/// interpolated in the [Oklab]($color.oklab) color space, which is a
93/// [perceptually uniform](https://programmingdesignsystems.com/color/perceptually-uniform-color-spaces/index.html)
94/// color space. This means that the gradient will be perceived as having a
95/// smooth progression of colors. This is particularly useful for data
96/// visualization.
97///
98/// However, you can choose to interpolate the gradient in any supported color
99/// space you want, but beware that some color spaces are not suitable for
100/// perceptually interpolating between colors. Consult the table below when
101/// choosing an interpolation space.
102///
103/// |           Color space           | Perceptually uniform? |
104/// | ------------------------------- |-----------------------|
105/// | [Oklab]($color.oklab)           | *Yes*                 |
106/// | [Oklch]($color.oklch)           | *Yes*                 |
107/// | [sRGB]($color.rgb)              | *No*                  |
108/// | [linear-RGB]($color.linear-rgb) | *Yes*                 |
109/// | [CMYK]($color.cmyk)             | *No*                  |
110/// | [Grayscale]($color.luma)        | *Yes*                 |
111/// | [HSL]($color.hsl)               | *No*                  |
112/// | [HSV]($color.hsv)               | *No*                  |
113///
114/// ```preview
115/// >>> #set text(fill: white, font: "IBM Plex Sans", 8pt)
116/// >>> #set block(spacing: 0pt)
117/// #let spaces = (
118///   ("Oklab", color.oklab),
119///   ("Oklch", color.oklch),
120///   ("linear-RGB", color.linear-rgb),
121///   ("sRGB", color.rgb),
122///   ("CMYK", color.cmyk),
123///   ("HSL", color.hsl),
124///   ("HSV", color.hsv),
125///   ("Grayscale", color.luma),
126/// )
127///
128/// #for (name, space) in spaces {
129///   block(
130///     width: 100%,
131///     inset: 4pt,
132///     fill: gradient.linear(
133///       red,
134///       blue,
135///       space: space,
136///     ),
137///     strong(upper(name)),
138///   )
139/// }
140/// ```
141///
142/// # Direction
143/// Some gradients are sensitive to direction. For example, a linear gradient
144/// has an angle that determines its direction. Typst uses a clockwise angle,
145/// with 0° being from left to right, 90° from top to bottom, 180° from right to
146/// left, and 270° from bottom to top.
147///
148/// ```example
149/// >>> #set square(size: 50pt)
150/// #stack(
151///   dir: ltr,
152///   spacing: 1fr,
153///   square(fill: gradient.linear(red, blue, angle: 0deg)),
154///   square(fill: gradient.linear(red, blue, angle: 90deg)),
155///   square(fill: gradient.linear(red, blue, angle: 180deg)),
156///   square(fill: gradient.linear(red, blue, angle: 270deg)),
157/// )
158/// ```
159///
160/// # Presets
161/// Typst predefines color maps that you can use with your gradients. See the
162/// [`color`]($color/#predefined-color-maps) documentation for more details.
163///
164/// # Note on file sizes
165///
166/// Gradients can be quite large, especially if they have many stops. This is
167/// because gradients are stored as a list of colors and offsets, which can
168/// take up a lot of space. If you are concerned about file sizes, you should
169/// consider the following:
170/// - SVG gradients are currently inefficiently encoded. This will be improved
171///   in the future.
172/// - PDF gradients in the [`color.oklab`]($color.oklab), [`color.hsv`]($color.hsv),
173///   [`color.hsl`]($color.hsl), and [`color.oklch`]($color.oklch) color spaces
174///   are stored as a list of [`color.rgb`]($color.rgb) colors with extra stops
175///   in between. This avoids needing to encode these color spaces in your PDF
176///   file, but it does add extra stops to your gradient, which can increase
177///   the file size.
178#[ty(scope, cast)]
179#[derive(Clone, PartialEq, Eq, Hash)]
180pub enum Gradient {
181    Linear(Arc<LinearGradient>),
182    Radial(Arc<RadialGradient>),
183    Conic(Arc<ConicGradient>),
184}
185
186#[scope]
187#[allow(clippy::too_many_arguments)]
188impl Gradient {
189    /// Creates a new linear gradient, in which colors transition along a
190    /// straight line.
191    ///
192    /// ```example
193    /// #rect(
194    ///   width: 100%,
195    ///   height: 20pt,
196    ///   fill: gradient.linear(
197    ///     ..color.map.viridis,
198    ///   ),
199    /// )
200    /// ```
201    #[func(title = "Linear Gradient")]
202    pub fn linear(
203        args: &mut Args,
204        span: Span,
205        /// The color [stops](#stops) of the gradient.
206        #[variadic]
207        stops: Vec<Spanned<GradientStop>>,
208        /// The color space in which to interpolate the gradient.
209        ///
210        /// Defaults to a perceptually uniform color space called
211        /// [Oklab]($color.oklab).
212        #[named]
213        #[default(ColorSpace::Oklab)]
214        space: ColorSpace,
215        /// The [relative placement](#relativeness) of the gradient.
216        ///
217        /// For an element placed at the root/top level of the document, the
218        /// parent is the page itself. For other elements, the parent is the
219        /// innermost block, box, column, grid, or stack that contains the
220        /// element.
221        #[named]
222        #[default(Smart::Auto)]
223        relative: Smart<RelativeTo>,
224        /// The direction of the gradient.
225        #[external]
226        #[default(Dir::LTR)]
227        dir: Dir,
228        /// The angle of the gradient.
229        #[external]
230        angle: Angle,
231    ) -> SourceResult<Gradient> {
232        let angle = if let Some(angle) = args.named::<Angle>("angle")? {
233            angle
234        } else if let Some(dir) = args.named::<Dir>("dir")? {
235            match dir {
236                Dir::LTR => Angle::rad(0.0),
237                Dir::RTL => Angle::rad(PI),
238                Dir::TTB => Angle::rad(FRAC_PI_2),
239                Dir::BTT => Angle::rad(3.0 * FRAC_PI_2),
240            }
241        } else {
242            Angle::rad(0.0)
243        };
244
245        if stops.len() < 2 {
246            bail!(
247                span, "a gradient must have at least two stops";
248                hint: "try filling the shape with a single color instead"
249            );
250        }
251
252        Ok(Self::Linear(Arc::new(LinearGradient {
253            stops: process_stops(&stops)?,
254            angle,
255            space,
256            relative,
257            anti_alias: true,
258        })))
259    }
260
261    /// Creates a new radial gradient, in which colors radiate away from an
262    /// origin.
263    ///
264    /// The gradient is defined by two circles: the focal circle and the end
265    /// circle. The focal circle is a circle with center `focal-center` and
266    /// radius `focal-radius`, that defines the points at which the gradient
267    /// starts and has the color of the first stop. The end circle is a circle
268    /// with center `center` and radius `radius`, that defines the points at
269    /// which the gradient ends and has the color of the last stop. The gradient
270    /// is then interpolated between these two circles.
271    ///
272    /// Using these four values, also called the focal point for the starting
273    /// circle and the center and radius for the end circle, we can define a
274    /// gradient with more interesting properties than a basic radial gradient.
275    ///
276    /// ```example
277    /// >>> #set circle(radius: 30pt)
278    /// #stack(
279    ///   dir: ltr,
280    ///   spacing: 1fr,
281    ///   circle(fill: gradient.radial(
282    ///     ..color.map.viridis,
283    ///   )),
284    ///   circle(fill: gradient.radial(
285    ///     ..color.map.viridis,
286    ///     focal-center: (10%, 40%),
287    ///     focal-radius: 5%,
288    ///   )),
289    /// )
290    /// ```
291    #[func]
292    fn radial(
293        span: Span,
294        /// The color [stops](#stops) of the gradient.
295        #[variadic]
296        stops: Vec<Spanned<GradientStop>>,
297        /// The color space in which to interpolate the gradient.
298        ///
299        /// Defaults to a perceptually uniform color space called
300        /// [Oklab]($color.oklab).
301        #[named]
302        #[default(ColorSpace::Oklab)]
303        space: ColorSpace,
304        /// The [relative placement](#relativeness) of the gradient.
305        ///
306        /// For an element placed at the root/top level of the document, the parent
307        /// is the page itself. For other elements, the parent is the innermost block,
308        /// box, column, grid, or stack that contains the element.
309        #[named]
310        #[default(Smart::Auto)]
311        relative: Smart<RelativeTo>,
312        /// The center of the end circle of the gradient.
313        ///
314        /// A value of `{(50%, 50%)}` means that the end circle is
315        /// centered inside of its container.
316        #[named]
317        #[default(Axes::splat(Ratio::new(0.5)))]
318        center: Axes<Ratio>,
319        /// The radius of the end circle of the gradient.
320        ///
321        /// By default, it is set to `{50%}`. The ending radius must be bigger
322        /// than the focal radius.
323        #[named]
324        #[default(Spanned::new(Ratio::new(0.5), Span::detached()))]
325        radius: Spanned<Ratio>,
326        /// The center of the focal circle of the gradient.
327        ///
328        /// The focal center must be inside of the end circle.
329        ///
330        /// A value of `{(50%, 50%)}` means that the focal circle is
331        /// centered inside of its container.
332        ///
333        /// By default it is set to the same as the center of the last circle.
334        #[named]
335        #[default(Smart::Auto)]
336        focal_center: Smart<Axes<Ratio>>,
337        /// The radius of the focal circle of the gradient.
338        ///
339        /// The focal center must be inside of the end circle.
340        ///
341        /// By default, it is set to `{0%}`. The focal radius must be smaller
342        /// than the ending radius`.
343        #[named]
344        #[default(Spanned::new(Ratio::new(0.0), Span::detached()))]
345        focal_radius: Spanned<Ratio>,
346    ) -> SourceResult<Gradient> {
347        if stops.len() < 2 {
348            bail!(
349                span, "a gradient must have at least two stops";
350                hint: "try filling the shape with a single color instead"
351            );
352        }
353
354        if focal_radius.v > radius.v {
355            bail!(
356                focal_radius.span,
357                "the focal radius must be smaller than the end radius";
358                hint: "try using a focal radius of `0%` instead"
359            );
360        }
361
362        let focal_center = focal_center.unwrap_or(center);
363        let d_center_sqr = (focal_center.x - center.x).get().powi(2)
364            + (focal_center.y - center.y).get().powi(2);
365        if d_center_sqr.sqrt() >= (radius.v - focal_radius.v).get() {
366            bail!(
367                span,
368                "the focal circle must be inside of the end circle";
369                hint: "try using a focal center of `auto` instead"
370            );
371        }
372
373        Ok(Gradient::Radial(Arc::new(RadialGradient {
374            stops: process_stops(&stops)?,
375            center: center.map(From::from),
376            radius: radius.v,
377            focal_center,
378            focal_radius: focal_radius.v,
379            space,
380            relative,
381            anti_alias: true,
382        })))
383    }
384
385    /// Creates a new conic gradient, in which colors change radially around a
386    /// center point.
387    ///
388    /// You can control the center point of the gradient by using the `center`
389    /// argument. By default, the center point is the center of the shape.
390    ///
391    /// ```example
392    /// >>> #set circle(radius: 30pt)
393    /// #stack(
394    ///   dir: ltr,
395    ///   spacing: 1fr,
396    ///   circle(fill: gradient.conic(
397    ///     ..color.map.viridis,
398    ///   )),
399    ///   circle(fill: gradient.conic(
400    ///     ..color.map.viridis,
401    ///     center: (20%, 30%),
402    ///   )),
403    /// )
404    /// ```
405    #[func]
406    pub fn conic(
407        span: Span,
408        /// The color [stops](#stops) of the gradient.
409        #[variadic]
410        stops: Vec<Spanned<GradientStop>>,
411        /// The angle of the gradient.
412        #[named]
413        #[default(Angle::zero())]
414        angle: Angle,
415        /// The color space in which to interpolate the gradient.
416        ///
417        /// Defaults to a perceptually uniform color space called
418        /// [Oklab]($color.oklab).
419        #[named]
420        #[default(ColorSpace::Oklab)]
421        space: ColorSpace,
422        /// The [relative placement](#relativeness) of the gradient.
423        ///
424        /// For an element placed at the root/top level of the document, the parent
425        /// is the page itself. For other elements, the parent is the innermost block,
426        /// box, column, grid, or stack that contains the element.
427        #[named]
428        #[default(Smart::Auto)]
429        relative: Smart<RelativeTo>,
430        /// The center of the last circle of the gradient.
431        ///
432        /// A value of `{(50%, 50%)}` means that the end circle is
433        /// centered inside of its container.
434        #[named]
435        #[default(Axes::splat(Ratio::new(0.5)))]
436        center: Axes<Ratio>,
437    ) -> SourceResult<Gradient> {
438        if stops.len() < 2 {
439            bail!(
440                span, "a gradient must have at least two stops";
441                hint: "try filling the shape with a single color instead"
442            );
443        }
444
445        Ok(Gradient::Conic(Arc::new(ConicGradient {
446            stops: process_stops(&stops)?,
447            angle,
448            center: center.map(From::from),
449            space,
450            relative,
451            anti_alias: true,
452        })))
453    }
454
455    /// Creates a sharp version of this gradient.
456    ///
457    /// Sharp gradients have discrete jumps between colors, instead of a
458    /// smooth transition. They are particularly useful for creating color
459    /// lists for a preset gradient.
460    ///
461    /// ```example
462    /// #set rect(width: 100%, height: 20pt)
463    /// #let grad = gradient.linear(..color.map.rainbow)
464    /// #rect(fill: grad)
465    /// #rect(fill: grad.sharp(5))
466    /// #rect(fill: grad.sharp(5, smoothness: 20%))
467    /// ```
468    #[func]
469    pub fn sharp(
470        &self,
471        /// The number of stops in the gradient.
472        steps: Spanned<usize>,
473        /// How much to smooth the gradient.
474        #[named]
475        #[default(Spanned::new(Ratio::zero(), Span::detached()))]
476        smoothness: Spanned<Ratio>,
477    ) -> SourceResult<Gradient> {
478        if steps.v < 2 {
479            bail!(steps.span, "sharp gradients must have at least two stops");
480        }
481
482        if smoothness.v.get() < 0.0 || smoothness.v.get() > 1.0 {
483            bail!(smoothness.span, "smoothness must be between 0 and 1");
484        }
485
486        let n = steps.v;
487        let smoothness = smoothness.v.get();
488        let colors = (0..n)
489            .flat_map(|i| {
490                let c = self
491                    .sample(RatioOrAngle::Ratio(Ratio::new(i as f64 / (n - 1) as f64)));
492
493                [c, c]
494            })
495            .collect::<Vec<_>>();
496
497        let mut positions = Vec::with_capacity(n * 2);
498        let index_to_progress = |i| i as f64 * 1.0 / n as f64;
499
500        let progress = smoothness * 1.0 / (4.0 * n as f64);
501        for i in 0..n {
502            let mut j = 2 * i;
503            positions.push(index_to_progress(i));
504            if j > 0 {
505                positions[j] += progress;
506            }
507
508            j += 1;
509            positions.push(index_to_progress(i + 1));
510            if j < colors.len() - 1 {
511                positions[j] -= progress;
512            }
513        }
514
515        let mut stops = colors
516            .into_iter()
517            .zip(positions)
518            .map(|(c, p)| (c, Ratio::new(p)))
519            .collect::<Vec<_>>();
520
521        stops.dedup();
522
523        Ok(match self {
524            Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
525                stops,
526                angle: linear.angle,
527                space: linear.space,
528                relative: linear.relative,
529                anti_alias: false,
530            })),
531            Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient {
532                stops,
533                center: radial.center,
534                radius: radial.radius,
535                focal_center: radial.focal_center,
536                focal_radius: radial.focal_radius,
537                space: radial.space,
538                relative: radial.relative,
539                anti_alias: false,
540            })),
541            Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
542                stops,
543                angle: conic.angle,
544                center: conic.center,
545                space: conic.space,
546                relative: conic.relative,
547                anti_alias: false,
548            })),
549        })
550    }
551
552    /// Repeats this gradient a given number of times, optionally mirroring it
553    /// at each repetition.
554    ///
555    /// ```example
556    /// #circle(
557    ///   radius: 40pt,
558    ///   fill: gradient
559    ///     .radial(aqua, white)
560    ///     .repeat(4),
561    /// )
562    /// ```
563    #[func]
564    pub fn repeat(
565        &self,
566        /// The number of times to repeat the gradient.
567        repetitions: Spanned<usize>,
568        /// Whether to mirror the gradient at each repetition.
569        #[named]
570        #[default(false)]
571        mirror: bool,
572    ) -> SourceResult<Gradient> {
573        if repetitions.v == 0 {
574            bail!(repetitions.span, "must repeat at least once");
575        }
576
577        let n = repetitions.v;
578        let mut stops = std::iter::repeat(self.stops_ref())
579            .take(n)
580            .enumerate()
581            .flat_map(|(i, stops)| {
582                let mut stops = stops
583                    .iter()
584                    .map(move |&(color, offset)| {
585                        let r = offset.get();
586                        if i % 2 == 1 && mirror {
587                            (color, Ratio::new((i as f64 + 1.0 - r) / n as f64))
588                        } else {
589                            (color, Ratio::new((i as f64 + r) / n as f64))
590                        }
591                    })
592                    .collect::<Vec<_>>();
593
594                if i % 2 == 1 && mirror {
595                    stops.reverse();
596                }
597
598                stops
599            })
600            .collect::<Vec<_>>();
601
602        stops.dedup();
603
604        Ok(match self {
605            Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
606                stops,
607                angle: linear.angle,
608                space: linear.space,
609                relative: linear.relative,
610                anti_alias: linear.anti_alias,
611            })),
612            Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient {
613                stops,
614                center: radial.center,
615                radius: radial.radius,
616                focal_center: radial.focal_center,
617                focal_radius: radial.focal_radius,
618                space: radial.space,
619                relative: radial.relative,
620                anti_alias: radial.anti_alias,
621            })),
622            Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
623                stops,
624                angle: conic.angle,
625                center: conic.center,
626                space: conic.space,
627                relative: conic.relative,
628                anti_alias: conic.anti_alias,
629            })),
630        })
631    }
632
633    /// Returns the kind of this gradient.
634    #[func]
635    pub fn kind(&self) -> Func {
636        match self {
637            Self::Linear(_) => Self::linear_data().into(),
638            Self::Radial(_) => Self::radial_data().into(),
639            Self::Conic(_) => Self::conic_data().into(),
640        }
641    }
642
643    /// Returns the stops of this gradient.
644    #[func]
645    pub fn stops(&self) -> Vec<GradientStop> {
646        match self {
647            Self::Linear(linear) => linear
648                .stops
649                .iter()
650                .map(|(color, offset)| GradientStop {
651                    color: *color,
652                    offset: Some(*offset),
653                })
654                .collect(),
655            Self::Radial(radial) => radial
656                .stops
657                .iter()
658                .map(|(color, offset)| GradientStop {
659                    color: *color,
660                    offset: Some(*offset),
661                })
662                .collect(),
663            Self::Conic(conic) => conic
664                .stops
665                .iter()
666                .map(|(color, offset)| GradientStop {
667                    color: *color,
668                    offset: Some(*offset),
669                })
670                .collect(),
671        }
672    }
673
674    /// Returns the mixing space of this gradient.
675    #[func]
676    pub fn space(&self) -> ColorSpace {
677        match self {
678            Self::Linear(linear) => linear.space,
679            Self::Radial(radial) => radial.space,
680            Self::Conic(conic) => conic.space,
681        }
682    }
683
684    /// Returns the relative placement of this gradient.
685    #[func]
686    pub fn relative(&self) -> Smart<RelativeTo> {
687        match self {
688            Self::Linear(linear) => linear.relative,
689            Self::Radial(radial) => radial.relative,
690            Self::Conic(conic) => conic.relative,
691        }
692    }
693
694    /// Returns the angle of this gradient.
695    ///
696    /// Returns `{none}` if the gradient is neither linear nor conic.
697    #[func]
698    pub fn angle(&self) -> Option<Angle> {
699        match self {
700            Self::Linear(linear) => Some(linear.angle),
701            Self::Radial(_) => None,
702            Self::Conic(conic) => Some(conic.angle),
703        }
704    }
705
706    /// Returns the center of this gradient.
707    ///
708    /// Returns `{none}` if the gradient is neither radial nor conic.
709    #[func]
710    pub fn center(&self) -> Option<Axes<Ratio>> {
711        match self {
712            Self::Linear(_) => None,
713            Self::Radial(radial) => Some(radial.center),
714            Self::Conic(conic) => Some(conic.center),
715        }
716    }
717
718    /// Returns the radius of this gradient.
719    ///
720    /// Returns `{none}` if the gradient is not radial.
721    #[func]
722    pub fn radius(&self) -> Option<Ratio> {
723        match self {
724            Self::Linear(_) => None,
725            Self::Radial(radial) => Some(radial.radius),
726            Self::Conic(_) => None,
727        }
728    }
729
730    /// Returns the focal-center of this gradient.
731    ///
732    /// Returns `{none}` if the gradient is not radial.
733    #[func]
734    pub fn focal_center(&self) -> Option<Axes<Ratio>> {
735        match self {
736            Self::Linear(_) => None,
737            Self::Radial(radial) => Some(radial.focal_center),
738            Self::Conic(_) => None,
739        }
740    }
741
742    /// Returns the focal-radius of this gradient.
743    ///
744    /// Returns `{none}` if the gradient is not radial.
745    #[func]
746    pub fn focal_radius(&self) -> Option<Ratio> {
747        match self {
748            Self::Linear(_) => None,
749            Self::Radial(radial) => Some(radial.focal_radius),
750            Self::Conic(_) => None,
751        }
752    }
753
754    /// Sample the gradient at a given position.
755    ///
756    /// The position is either a position along the gradient (a [ratio] between
757    /// `{0%}` and `{100%}`) or an [angle]. Any value outside of this range will
758    /// be clamped.
759    #[func]
760    pub fn sample(
761        &self,
762        /// The position at which to sample the gradient.
763        t: RatioOrAngle,
764    ) -> Color {
765        let value: f64 = t.to_ratio().get();
766
767        match self {
768            Self::Linear(linear) => sample_stops(&linear.stops, linear.space, value),
769            Self::Radial(radial) => sample_stops(&radial.stops, radial.space, value),
770            Self::Conic(conic) => sample_stops(&conic.stops, conic.space, value),
771        }
772    }
773
774    /// Samples the gradient at multiple positions at once and returns the
775    /// results as an array.
776    #[func]
777    pub fn samples(
778        &self,
779        /// The positions at which to sample the gradient.
780        #[variadic]
781        ts: Vec<RatioOrAngle>,
782    ) -> Array {
783        ts.into_iter().map(|t| self.sample(t).into_value()).collect()
784    }
785}
786
787impl Gradient {
788    /// Clones this gradient, but with a different relative placement.
789    pub fn with_relative(mut self, relative: RelativeTo) -> Self {
790        match &mut self {
791            Self::Linear(linear) => {
792                Arc::make_mut(linear).relative = Smart::Custom(relative);
793            }
794            Self::Radial(radial) => {
795                Arc::make_mut(radial).relative = Smart::Custom(relative);
796            }
797            Self::Conic(conic) => {
798                Arc::make_mut(conic).relative = Smart::Custom(relative);
799            }
800        }
801
802        self
803    }
804    /// Returns a reference to the stops of this gradient.
805    pub fn stops_ref(&self) -> &[(Color, Ratio)] {
806        match self {
807            Gradient::Linear(linear) => &linear.stops,
808            Gradient::Radial(radial) => &radial.stops,
809            Gradient::Conic(conic) => &conic.stops,
810        }
811    }
812
813    /// Samples the gradient at a given position, in the given container.
814    /// Handles the aspect ratio and angle directly.
815    pub fn sample_at(&self, (x, y): (f32, f32), (width, height): (f32, f32)) -> Color {
816        // Normalize the coordinates.
817        let (mut x, mut y) = (x / width, y / height);
818        let t = match self {
819            Self::Linear(linear) => {
820                // Aspect ratio correction.
821                let angle = Gradient::correct_aspect_ratio(
822                    linear.angle,
823                    Ratio::new((width / height) as f64),
824                )
825                .to_rad();
826                let (sin, cos) = angle.sin_cos();
827
828                let length = sin.abs() + cos.abs();
829                if angle > FRAC_PI_2 && angle < 3.0 * FRAC_PI_2 {
830                    x = 1.0 - x;
831                }
832
833                if angle > PI {
834                    y = 1.0 - y;
835                }
836
837                (x as f64 * cos.abs() + y as f64 * sin.abs()) / length
838            }
839            Self::Radial(radial) => {
840                // Source: @Enivex - https://typst.app/project/pYLeS0QyCCe8mf0pdnwoAI
841                let cr = radial.radius.get();
842                let fr = radial.focal_radius.get();
843                let z = Vec2::new(x as f64, y as f64);
844                let p = Vec2::new(radial.center.x.get(), radial.center.y.get());
845                let q =
846                    Vec2::new(radial.focal_center.x.get(), radial.focal_center.y.get());
847
848                if (z - q).hypot() < fr {
849                    0.0
850                } else if (z - p).hypot() > cr {
851                    1.0
852                } else {
853                    let uz = (z - q).normalize();
854                    let az = (q - p).dot(uz);
855                    let rho = cr.powi(2) - (q - p).hypot().powi(2);
856                    let bz = (az.powi(2) + rho).sqrt() - az;
857
858                    ((z - q).hypot() - fr) / (bz - fr)
859                }
860            }
861            Self::Conic(conic) => {
862                let (x, y) =
863                    (x as f64 - conic.center.x.get(), y as f64 - conic.center.y.get());
864                let angle = Gradient::correct_aspect_ratio(
865                    conic.angle,
866                    Ratio::new((width / height) as f64),
867                );
868                ((-y.atan2(x) + PI + angle.to_rad()) % TAU) / TAU
869            }
870        };
871
872        self.sample(RatioOrAngle::Ratio(Ratio::new(t.clamp(0.0, 1.0))))
873    }
874
875    /// Does this gradient need to be anti-aliased?
876    pub fn anti_alias(&self) -> bool {
877        match self {
878            Self::Linear(linear) => linear.anti_alias,
879            Self::Radial(radial) => radial.anti_alias,
880            Self::Conic(conic) => conic.anti_alias,
881        }
882    }
883
884    /// Returns the relative placement of this gradient, handling
885    /// the special case of `auto`.
886    pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo {
887        self.relative().unwrap_or_else(|| {
888            if on_text {
889                RelativeTo::Parent
890            } else {
891                RelativeTo::Self_
892            }
893        })
894    }
895
896    /// Corrects this angle for the aspect ratio of a gradient.
897    ///
898    /// This is used specifically for gradients.
899    pub fn correct_aspect_ratio(angle: Angle, aspect_ratio: Ratio) -> Angle {
900        let rad = (angle.to_rad().rem_euclid(TAU).tan() / aspect_ratio.get()).atan();
901        let rad = match angle.quadrant() {
902            Quadrant::First => rad,
903            Quadrant::Second => rad + PI,
904            Quadrant::Third => rad + PI,
905            Quadrant::Fourth => rad + TAU,
906        };
907        Angle::rad(rad.rem_euclid(TAU))
908    }
909}
910
911impl Debug for Gradient {
912    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
913        match self {
914            Self::Linear(v) => v.fmt(f),
915            Self::Radial(v) => v.fmt(f),
916            Self::Conic(v) => v.fmt(f),
917        }
918    }
919}
920
921impl Repr for Gradient {
922    fn repr(&self) -> EcoString {
923        match self {
924            Self::Radial(radial) => radial.repr(),
925            Self::Linear(linear) => linear.repr(),
926            Self::Conic(conic) => conic.repr(),
927        }
928    }
929}
930
931/// A gradient that interpolates between two colors along an axis.
932#[derive(Debug, Clone, Eq, PartialEq, Hash)]
933pub struct LinearGradient {
934    /// The color stops of this gradient.
935    pub stops: Vec<(Color, Ratio)>,
936    /// The direction of this gradient.
937    pub angle: Angle,
938    /// The color space in which to interpolate the gradient.
939    pub space: ColorSpace,
940    /// The relative placement of the gradient.
941    pub relative: Smart<RelativeTo>,
942    /// Whether to anti-alias the gradient (used for sharp gradients).
943    pub anti_alias: bool,
944}
945
946impl Repr for LinearGradient {
947    fn repr(&self) -> EcoString {
948        let mut r = EcoString::from("gradient.linear(");
949
950        let angle = self.angle.to_rad().rem_euclid(TAU);
951        if angle.abs() < f64::EPSILON {
952            // Default value, do nothing
953        } else if (angle - FRAC_PI_2).abs() < f64::EPSILON {
954            r.push_str("dir: rtl, ");
955        } else if (angle - PI).abs() < f64::EPSILON {
956            r.push_str("dir: ttb, ");
957        } else if (angle - 3.0 * FRAC_PI_2).abs() < f64::EPSILON {
958            r.push_str("dir: btt, ");
959        } else {
960            r.push_str("angle: ");
961            r.push_str(&self.angle.repr());
962            r.push_str(", ");
963        }
964
965        if self.space != ColorSpace::Oklab {
966            r.push_str("space: ");
967            r.push_str(&self.space.into_value().repr());
968            r.push_str(", ");
969        }
970
971        if self.relative.is_custom() {
972            r.push_str("relative: ");
973            r.push_str(&self.relative.into_value().repr());
974            r.push_str(", ");
975        }
976
977        for (i, (color, offset)) in self.stops.iter().enumerate() {
978            r.push('(');
979            r.push_str(&color.repr());
980            r.push_str(", ");
981            r.push_str(&offset.repr());
982            r.push(')');
983            if i != self.stops.len() - 1 {
984                r.push_str(", ");
985            }
986        }
987
988        r.push(')');
989        r
990    }
991}
992
993/// A gradient that interpolates between two colors along a circle.
994#[derive(Debug, Clone, Eq, PartialEq, Hash)]
995pub struct RadialGradient {
996    /// The color stops of this gradient.
997    pub stops: Vec<(Color, Ratio)>,
998    /// The center of last circle of this gradient.
999    pub center: Axes<Ratio>,
1000    /// The radius of last circle of this gradient.
1001    pub radius: Ratio,
1002    /// The center of first circle of this gradient.
1003    pub focal_center: Axes<Ratio>,
1004    /// The radius of first circle of this gradient.
1005    pub focal_radius: Ratio,
1006    /// The color space in which to interpolate the gradient.
1007    pub space: ColorSpace,
1008    /// The relative placement of the gradient.
1009    pub relative: Smart<RelativeTo>,
1010    /// Whether to anti-alias the gradient (used for sharp gradients).
1011    pub anti_alias: bool,
1012}
1013
1014impl Repr for RadialGradient {
1015    fn repr(&self) -> EcoString {
1016        let mut r = EcoString::from("gradient.radial(");
1017
1018        if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) {
1019            r.push_str("center: (");
1020            r.push_str(&self.center.x.repr());
1021            r.push_str(", ");
1022            r.push_str(&self.center.y.repr());
1023            r.push_str("), ");
1024        }
1025
1026        if self.radius != Ratio::new(0.5) {
1027            r.push_str("radius: ");
1028            r.push_str(&self.radius.repr());
1029            r.push_str(", ");
1030        }
1031
1032        if self.focal_center != self.center {
1033            r.push_str("focal-center: (");
1034            r.push_str(&self.focal_center.x.repr());
1035            r.push_str(", ");
1036            r.push_str(&self.focal_center.y.repr());
1037            r.push_str("), ");
1038        }
1039
1040        if self.focal_radius != Ratio::zero() {
1041            r.push_str("focal-radius: ");
1042            r.push_str(&self.focal_radius.repr());
1043            r.push_str(", ");
1044        }
1045
1046        if self.space != ColorSpace::Oklab {
1047            r.push_str("space: ");
1048            r.push_str(&self.space.into_value().repr());
1049            r.push_str(", ");
1050        }
1051
1052        if self.relative.is_custom() {
1053            r.push_str("relative: ");
1054            r.push_str(&self.relative.into_value().repr());
1055            r.push_str(", ");
1056        }
1057
1058        for (i, (color, offset)) in self.stops.iter().enumerate() {
1059            r.push('(');
1060            r.push_str(&color.repr());
1061            r.push_str(", ");
1062            r.push_str(&offset.repr());
1063            r.push(')');
1064            if i != self.stops.len() - 1 {
1065                r.push_str(", ");
1066            }
1067        }
1068
1069        r.push(')');
1070        r
1071    }
1072}
1073
1074/// A gradient that interpolates between two colors radially
1075/// around a center point.
1076#[derive(Debug, Clone, Eq, PartialEq, Hash)]
1077pub struct ConicGradient {
1078    /// The color stops of this gradient.
1079    pub stops: Vec<(Color, Ratio)>,
1080    /// The direction of this gradient.
1081    pub angle: Angle,
1082    /// The center of last circle of this gradient.
1083    pub center: Axes<Ratio>,
1084    /// The color space in which to interpolate the gradient.
1085    pub space: ColorSpace,
1086    /// The relative placement of the gradient.
1087    pub relative: Smart<RelativeTo>,
1088    /// Whether to anti-alias the gradient (used for sharp gradients).
1089    pub anti_alias: bool,
1090}
1091
1092impl Repr for ConicGradient {
1093    fn repr(&self) -> EcoString {
1094        let mut r = EcoString::from("gradient.conic(");
1095
1096        let angle = self.angle.to_rad().rem_euclid(TAU);
1097        if angle.abs() > f64::EPSILON {
1098            r.push_str("angle: ");
1099            r.push_str(&self.angle.repr());
1100            r.push_str(", ");
1101        }
1102
1103        if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) {
1104            r.push_str("center: (");
1105            r.push_str(&self.center.x.repr());
1106            r.push_str(", ");
1107            r.push_str(&self.center.y.repr());
1108            r.push_str("), ");
1109        }
1110
1111        if self.space != ColorSpace::Oklab {
1112            r.push_str("space: ");
1113            r.push_str(&self.space.into_value().repr());
1114            r.push_str(", ");
1115        }
1116
1117        if self.relative.is_custom() {
1118            r.push_str("relative: ");
1119            r.push_str(&self.relative.into_value().repr());
1120            r.push_str(", ");
1121        }
1122
1123        for (i, (color, offset)) in self.stops.iter().enumerate() {
1124            r.push('(');
1125            r.push_str(&color.repr());
1126            r.push_str(", ");
1127            r.push_str(&Angle::deg(offset.get() * 360.0).repr());
1128            r.push(')');
1129            if i != self.stops.len() - 1 {
1130                r.push_str(", ");
1131            }
1132        }
1133
1134        r.push(')');
1135        r
1136    }
1137}
1138
1139/// What is the gradient relative to.
1140#[derive(Cast, Debug, Clone, Copy, PartialEq, Eq, Hash)]
1141pub enum RelativeTo {
1142    /// The gradient is relative to itself (its own bounding box).
1143    Self_,
1144    /// The gradient is relative to its parent (the parent's bounding box).
1145    Parent,
1146}
1147
1148/// A color stop.
1149#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
1150pub struct GradientStop {
1151    /// The color for this stop.
1152    pub color: Color,
1153    /// The offset of the stop along the gradient.
1154    pub offset: Option<Ratio>,
1155}
1156
1157impl GradientStop {
1158    /// Create a new stop from a `color` and an `offset`.
1159    pub fn new(color: Color, offset: Ratio) -> Self {
1160        Self { color, offset: Some(offset) }
1161    }
1162}
1163
1164cast! {
1165    GradientStop,
1166    self => if let Some(offset) = self.offset {
1167        array![self.color.into_value(), offset].into_value()
1168    } else {
1169        self.color.into_value()
1170    },
1171    color: Color => Self { color, offset: None },
1172    array: Array => {
1173        let mut iter = array.into_iter();
1174        match (iter.next(), iter.next(), iter.next()) {
1175            (Some(a), Some(b), None) => Self {
1176                color: a.cast()?,
1177                offset: Some(b.cast()?)
1178            },
1179            _ => Err("a color stop must contain exactly two entries")?,
1180        }
1181    }
1182}
1183
1184/// A ratio or an angle.
1185#[derive(Copy, Clone, PartialEq, Eq, Hash)]
1186pub enum RatioOrAngle {
1187    Ratio(Ratio),
1188    Angle(Angle),
1189}
1190
1191impl RatioOrAngle {
1192    pub fn to_ratio(self) -> Ratio {
1193        match self {
1194            Self::Ratio(ratio) => ratio,
1195            Self::Angle(angle) => Ratio::new(angle.to_rad().rem_euclid(TAU) / TAU),
1196        }
1197        .clamp(Ratio::zero(), Ratio::one())
1198    }
1199}
1200
1201cast! {
1202    RatioOrAngle,
1203    self => match self {
1204        Self::Ratio(ratio) => ratio.into_value(),
1205        Self::Angle(angle) => angle.into_value(),
1206    },
1207    ratio: Ratio => Self::Ratio(ratio),
1208    angle: Angle => Self::Angle(angle),
1209}
1210
1211/// Pre-processes the stops, checking that they are valid and computing the
1212/// offsets if necessary.
1213///
1214/// Returns an error if the stops are invalid.
1215///
1216/// This is split into its own function because it is used by all of the
1217/// different gradient types.
1218#[comemo::memoize]
1219fn process_stops(stops: &[Spanned<GradientStop>]) -> SourceResult<Vec<(Color, Ratio)>> {
1220    let has_offset = stops.iter().any(|stop| stop.v.offset.is_some());
1221    if has_offset {
1222        let mut last_stop = f64::NEG_INFINITY;
1223        for Spanned { v: stop, span } in stops.iter() {
1224            let Some(stop) = stop.offset else {
1225                bail!(
1226                    *span, "either all stops must have an offset or none of them can";
1227                    hint: "try adding an offset to all stops"
1228                );
1229            };
1230
1231            if stop.get() < last_stop {
1232                bail!(*span, "offsets must be in monotonic order");
1233            }
1234
1235            last_stop = stop.get();
1236        }
1237
1238        let out = stops
1239            .iter()
1240            .map(|Spanned { v: GradientStop { color, offset }, span }| {
1241                if offset.unwrap().get() > 1.0 || offset.unwrap().get() < 0.0 {
1242                    bail!(*span, "offset must be between 0 and 1");
1243                }
1244                Ok((*color, offset.unwrap()))
1245            })
1246            .collect::<SourceResult<Vec<_>>>()?;
1247
1248        if out[0].1 != Ratio::zero() {
1249            bail!(
1250                stops[0].span,
1251                "first stop must have an offset of 0";
1252                hint: "try setting this stop to `0%`"
1253            );
1254        }
1255
1256        if out[out.len() - 1].1 != Ratio::one() {
1257            bail!(
1258                stops[out.len() - 1].span,
1259                "last stop must have an offset of 100%";
1260                hint: "try setting this stop to `100%`"
1261            );
1262        }
1263
1264        return Ok(out);
1265    }
1266
1267    Ok(stops
1268        .iter()
1269        .enumerate()
1270        .map(|(i, stop)| {
1271            let offset = i as f64 / (stops.len() - 1) as f64;
1272            (stop.v.color, Ratio::new(offset))
1273        })
1274        .collect())
1275}
1276
1277/// Sample the stops at a given position.
1278fn sample_stops(stops: &[(Color, Ratio)], mixing_space: ColorSpace, t: f64) -> Color {
1279    let t = t.clamp(0.0, 1.0);
1280    let mut low = 0;
1281    let mut high = stops.len();
1282
1283    while low < high {
1284        let mid = (low + high) / 2;
1285        if stops[mid].1.get() < t {
1286            low = mid + 1;
1287        } else {
1288            high = mid;
1289        }
1290    }
1291
1292    if low == 0 {
1293        low = 1;
1294    }
1295
1296    let (col_0, pos_0) = stops[low - 1];
1297    let (col_1, pos_1) = stops[low];
1298    let t = (t - pos_0.get()) / (pos_1.get() - pos_0.get());
1299
1300    Color::mix_iter(
1301        [WeightedColor::new(col_0, 1.0 - t), WeightedColor::new(col_1, t)],
1302        mixing_space,
1303    )
1304    .unwrap()
1305}