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}