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