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}