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