Skip to main content

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