summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/visualize
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2024-10-27 19:04:55 +0100
committerGitHub <noreply@github.com>2024-10-27 18:04:55 +0000
commitbe7cfc85d08c545abfac08098b7b33b4bd71f37e (patch)
treef4137fa2aaa57babae1f7603a9b2ed7e688f43d8 /crates/typst-library/src/visualize
parentb8034a343831e8609aec2ec81eb7eeda57aa5d81 (diff)
Split out four new crates (#5302)
Diffstat (limited to 'crates/typst-library/src/visualize')
-rw-r--r--crates/typst-library/src/visualize/color.rs2013
-rw-r--r--crates/typst-library/src/visualize/gradient.rs1260
-rw-r--r--crates/typst-library/src/visualize/image/mod.rs360
-rw-r--r--crates/typst-library/src/visualize/image/raster.rs286
-rw-r--r--crates/typst-library/src/visualize/image/svg.rs289
-rw-r--r--crates/typst-library/src/visualize/line.rs64
-rw-r--r--crates/typst-library/src/visualize/mod.rs50
-rw-r--r--crates/typst-library/src/visualize/paint.rs102
-rw-r--r--crates/typst-library/src/visualize/path.rs276
-rw-r--r--crates/typst-library/src/visualize/pattern.rs285
-rw-r--r--crates/typst-library/src/visualize/polygon.rs135
-rw-r--r--crates/typst-library/src/visualize/shape.rs448
-rw-r--r--crates/typst-library/src/visualize/stroke.rs617
13 files changed, 6185 insertions, 0 deletions
diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs
new file mode 100644
index 00000000..1a279fbb
--- /dev/null
+++ b/crates/typst-library/src/visualize/color.rs
@@ -0,0 +1,2013 @@
+use std::fmt::{self, Debug, Formatter};
+use std::hash::{Hash, Hasher};
+use std::str::FromStr;
+
+use ecow::{eco_format, EcoString, EcoVec};
+use once_cell::sync::Lazy;
+use palette::encoding::{self, Linear};
+use palette::{
+ Alpha, Darken, Desaturate, FromColor, Lighten, OklabHue, RgbHue, Saturate, ShiftHue,
+};
+use qcms::Profile;
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, At, SourceResult, StrResult};
+use crate::foundations::{
+ array, cast, func, repr, scope, ty, Args, Array, IntoValue, Module, Repr, Scope, Str,
+ Value,
+};
+use crate::layout::{Angle, Ratio};
+
+// Type aliases for `palette` internal types in f32.
+pub type Oklab = palette::oklab::Oklaba<f32>;
+pub type Oklch = palette::oklch::Oklcha<f32>;
+pub type LinearRgb = palette::rgb::Rgba<Linear<encoding::Srgb>, f32>;
+pub type Rgb = palette::rgb::Rgba<encoding::Srgb, f32>;
+pub type Hsl = palette::hsl::Hsla<encoding::Srgb, f32>;
+pub type Hsv = palette::hsv::Hsva<encoding::Srgb, f32>;
+pub type Luma = palette::luma::Lumaa<encoding::Srgb, f32>;
+
+/// The ICC profile used to convert from CMYK to RGB.
+///
+/// This is a minimal CMYK profile that only contains the necessary information
+/// to convert from CMYK to RGB. It is based on the CGATS TR 001-1995
+/// specification. See
+/// <https://github.com/saucecontrol/Compact-ICC-Profiles#cmyk>.
+static CMYK_TO_XYZ: Lazy<Box<Profile>> =
+ Lazy::new(|| Profile::new_from_slice(typst_assets::icc::CMYK_TO_XYZ, false).unwrap());
+
+/// The target sRGB profile.
+static SRGB_PROFILE: Lazy<Box<Profile>> = Lazy::new(|| {
+ let mut out = Profile::new_sRGB();
+ out.precache_output_transform();
+ out
+});
+
+static TO_SRGB: Lazy<qcms::Transform> = Lazy::new(|| {
+ qcms::Transform::new_to(
+ &CMYK_TO_XYZ,
+ &SRGB_PROFILE,
+ qcms::DataType::CMYK,
+ qcms::DataType::RGB8,
+ // Our input profile only supports perceptual intent.
+ qcms::Intent::Perceptual,
+ )
+ .unwrap()
+});
+
+/// A color in a specific color space.
+///
+/// Typst supports:
+/// - sRGB through the [`rgb` function]($color.rgb)
+/// - Device CMYK through [`cmyk` function]($color.cmyk)
+/// - D65 Gray through the [`luma` function]($color.luma)
+/// - Oklab through the [`oklab` function]($color.oklab)
+/// - Oklch through the [`oklch` function]($color.oklch)
+/// - Linear RGB through the [`color.linear-rgb` function]($color.linear-rgb)
+/// - HSL through the [`color.hsl` function]($color.hsl)
+/// - HSV through the [`color.hsv` function]($color.hsv)
+///
+///
+/// # Example
+///
+/// ```example
+/// #rect(fill: aqua)
+/// ```
+///
+/// # Predefined colors
+/// Typst defines the following built-in colors:
+///
+/// | Color | Definition |
+/// |-----------|:-------------------|
+/// | `black` | `{luma(0)}` |
+/// | `gray` | `{luma(170)}` |
+/// | `silver` | `{luma(221)}` |
+/// | `white` | `{luma(255)}` |
+/// | `navy` | `{rgb("#001f3f")}` |
+/// | `blue` | `{rgb("#0074d9")}` |
+/// | `aqua` | `{rgb("#7fdbff")}` |
+/// | `teal` | `{rgb("#39cccc")}` |
+/// | `eastern` | `{rgb("#239dad")}` |
+/// | `purple` | `{rgb("#b10dc9")}` |
+/// | `fuchsia` | `{rgb("#f012be")}` |
+/// | `maroon` | `{rgb("#85144b")}` |
+/// | `red` | `{rgb("#ff4136")}` |
+/// | `orange` | `{rgb("#ff851b")}` |
+/// | `yellow` | `{rgb("#ffdc00")}` |
+/// | `olive` | `{rgb("#3d9970")}` |
+/// | `green` | `{rgb("#2ecc40")}` |
+/// | `lime` | `{rgb("#01ff70")}` |
+///
+/// The predefined colors and the most important color constructors are
+/// available globally and also in the color type's scope, so you can write
+/// either `color.red` or just `red`.
+///
+/// ```preview
+/// #let colors = (
+/// "black", "gray", "silver", "white",
+/// "navy", "blue", "aqua", "teal",
+/// "eastern", "purple", "fuchsia",
+/// "maroon", "red", "orange", "yellow",
+/// "olive", "green", "lime",
+/// )
+///
+/// #set text(font: "PT Sans")
+/// #set page(width: auto)
+/// #grid(
+/// columns: 9,
+/// gutter: 10pt,
+/// ..colors.map(name => {
+/// let col = eval(name)
+/// let luminance = luma(col).components().first()
+/// set text(fill: white) if luminance < 50%
+/// set square(stroke: black) if col == white
+/// set align(center + horizon)
+/// square(size: 50pt, fill: col, name)
+/// })
+/// )
+/// ```
+///
+/// # Predefined color maps
+/// Typst also includes a number of preset color maps that can be used for
+/// [gradients]($gradient.linear). These are simply arrays of colors defined in
+/// the module `color.map`.
+///
+/// ```example
+/// #circle(fill: gradient.linear(..color.map.crest))
+/// ```
+///
+/// | Map | Details |
+/// |------------|:------------------------------------------------------------|
+/// | `turbo` | A perceptually uniform rainbow-like color map. Read [this blog post](https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html) for more details. |
+/// | `cividis` | A blue to gray to yellow color map. See [this blog post](https://bids.github.io/colormap/) for more details. |
+/// | `rainbow` | Cycles through the full color spectrum. This color map is best used by setting the interpolation color space to [HSL]($color.hsl). The rainbow gradient is **not suitable** for data visualization because it is not perceptually uniform, so the differences between values become unclear to your readers. It should only be used for decorative purposes. |
+/// | `spectral` | Red to yellow to blue color map. |
+/// | `viridis` | A purple to teal to yellow color map. |
+/// | `inferno` | A black to red to yellow color map. |
+/// | `magma` | A black to purple to yellow color map. |
+/// | `plasma` | A purple to pink to yellow color map. |
+/// | `rocket` | A black to red to white color map. |
+/// | `mako` | A black to teal to yellow color map. |
+/// | `vlag` | A light blue to white to red color map. |
+/// | `icefire` | A light teal to black to yellow color map. |
+/// | `flare` | A orange to purple color map that is perceptually uniform. |
+/// | `crest` | A blue to white to red color map. |
+///
+/// Some popular presets are not included because they are not available under a
+/// free licence. Others, like
+/// [Jet](https://jakevdp.github.io/blog/2014/10/16/how-bad-is-your-colormap/),
+/// are not included because they are not color blind friendly. Feel free to use
+/// or create a package with other presets that are useful to you!
+///
+/// ```preview
+/// #set page(width: auto, height: auto)
+/// #set text(font: "PT Sans", size: 8pt)
+///
+/// #let maps = (
+/// "turbo", "cividis", "rainbow", "spectral",
+/// "viridis", "inferno", "magma", "plasma",
+/// "rocket", "mako", "vlag", "icefire",
+/// "flare", "crest",
+/// )
+///
+/// #stack(dir: ltr, spacing: 3pt, ..maps.map((name) => {
+/// let map = eval("color.map." + name)
+/// stack(
+/// dir: ttb,
+/// block(
+/// width: 15pt,
+/// height: 100pt,
+/// fill: gradient.linear(..map, angle: 90deg),
+/// ),
+/// block(
+/// width: 15pt,
+/// height: 32pt,
+/// move(dy: 8pt, rotate(90deg, name)),
+/// ),
+/// )
+/// }))
+/// ```
+#[ty(scope, cast)]
+#[derive(Copy, Clone)]
+pub enum Color {
+ /// A 32-bit luma color.
+ Luma(Luma),
+ /// A 32-bit L\*a\*b\* color in the Oklab color space.
+ Oklab(Oklab),
+ /// A 32-bit LCh color in the Oklab color space.
+ Oklch(Oklch),
+ /// A 32-bit RGB color.
+ Rgb(Rgb),
+ /// A 32-bit linear RGB color.
+ LinearRgb(LinearRgb),
+ /// A 32-bit CMYK color.
+ Cmyk(Cmyk),
+ /// A 32-bit HSL color.
+ Hsl(Hsl),
+ /// A 32-bit HSV color.
+ Hsv(Hsv),
+}
+
+#[scope]
+impl Color {
+ /// The module of preset color maps.
+ pub const MAP: fn() -> Module = || typst_utils::singleton!(Module, map()).clone();
+
+ pub const BLACK: Self = Self::Luma(Luma::new(0.0, 1.0));
+ pub const GRAY: Self = Self::Luma(Luma::new(0.6666666, 1.0));
+ pub const WHITE: Self = Self::Luma(Luma::new(1.0, 1.0));
+ pub const SILVER: Self = Self::Luma(Luma::new(0.8666667, 1.0));
+ pub const NAVY: Self = Self::Rgb(Rgb::new(0.0, 0.121569, 0.247059, 1.0));
+ pub const BLUE: Self = Self::Rgb(Rgb::new(0.0, 0.454902, 0.85098, 1.0));
+ pub const AQUA: Self = Self::Rgb(Rgb::new(0.4980392, 0.858823, 1.0, 1.0));
+ pub const TEAL: Self = Self::Rgb(Rgb::new(0.223529, 0.8, 0.8, 1.0));
+ pub const EASTERN: Self = Self::Rgb(Rgb::new(0.13725, 0.615686, 0.678431, 1.0));
+ pub const PURPLE: Self = Self::Rgb(Rgb::new(0.694118, 0.050980, 0.788235, 1.0));
+ pub const FUCHSIA: Self = Self::Rgb(Rgb::new(0.941177, 0.070588, 0.745098, 1.0));
+ pub const MAROON: Self = Self::Rgb(Rgb::new(0.521569, 0.078431, 0.294118, 1.0));
+ pub const RED: Self = Self::Rgb(Rgb::new(1.0, 0.254902, 0.211765, 1.0));
+ pub const ORANGE: Self = Self::Rgb(Rgb::new(1.0, 0.521569, 0.105882, 1.0));
+ pub const YELLOW: Self = Self::Rgb(Rgb::new(1.0, 0.8627451, 0.0, 1.0));
+ pub const OLIVE: Self = Self::Rgb(Rgb::new(0.239216, 0.6, 0.4392157, 1.0));
+ pub const GREEN: Self = Self::Rgb(Rgb::new(0.1803922, 0.8, 0.2509804, 1.0));
+ pub const LIME: Self = Self::Rgb(Rgb::new(0.0039216, 1.0, 0.4392157, 1.0));
+
+ /// Create a grayscale color.
+ ///
+ /// A grayscale color is represented internally by a single `lightness`
+ /// component.
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #for x in range(250, step: 50) {
+ /// box(square(fill: luma(x)))
+ /// }
+ /// ```
+ #[func]
+ pub fn luma(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The lightness component.
+ #[external]
+ lightness: Component,
+ /// The alpha component.
+ #[external]
+ alpha: RatioComponent,
+ /// Alternatively: The color to convert to grayscale.
+ ///
+ /// If this is given, the `lightness` should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_luma()
+ } else {
+ let Component(gray) =
+ args.expect("gray component").unwrap_or(Component(Ratio::one()));
+ let RatioComponent(alpha) =
+ args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+ Self::Luma(Luma::new(gray.get() as f32, alpha.get() as f32))
+ })
+ }
+
+ /// Create an [Oklab](https://bottosson.github.io/posts/oklab/) color.
+ ///
+ /// This color space is well suited for the following use cases:
+ /// - Color manipulation such as saturating while keeping perceived hue
+ /// - Creating grayscale images with uniform perceived lightness
+ /// - Creating smooth and uniform color transition and gradients
+ ///
+ /// A linear Oklab color is represented internally by an array of four
+ /// components:
+ /// - lightness ([`ratio`])
+ /// - a ([`float`] or [`ratio`].
+ /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`)
+ /// - b ([`float`] or [`ratio`].
+ /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`)
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: oklab(27%, 20%, -3%, 50%)
+ /// )
+ /// ```
+ #[func]
+ pub fn oklab(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The lightness component.
+ #[external]
+ lightness: RatioComponent,
+ /// The a ("green/red") component.
+ #[external]
+ a: ChromaComponent,
+ /// The b ("blue/yellow") component.
+ #[external]
+ b: ChromaComponent,
+ /// The alpha component.
+ #[external]
+ alpha: RatioComponent,
+ /// Alternatively: The color to convert to Oklab.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_oklab()
+ } else {
+ let RatioComponent(l) = args.expect("lightness component")?;
+ let ChromaComponent(a) = args.expect("A component")?;
+ let ChromaComponent(b) = args.expect("B component")?;
+ let RatioComponent(alpha) =
+ args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+ Self::Oklab(Oklab::new(l.get() as f32, a, b, alpha.get() as f32))
+ })
+ }
+
+ /// Create an [Oklch](https://bottosson.github.io/posts/oklab/) color.
+ ///
+ /// This color space is well suited for the following use cases:
+ /// - Color manipulation involving lightness, chroma, and hue
+ /// - Creating grayscale images with uniform perceived lightness
+ /// - Creating smooth and uniform color transition and gradients
+ ///
+ /// A linear Oklch color is represented internally by an array of four
+ /// components:
+ /// - lightness ([`ratio`])
+ /// - chroma ([`float`] or [`ratio`].
+ /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`)
+ /// - hue ([`angle`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: oklch(40%, 0.2, 160deg, 50%)
+ /// )
+ /// ```
+ #[func]
+ pub fn oklch(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The lightness component.
+ #[external]
+ lightness: RatioComponent,
+ /// The chroma component.
+ #[external]
+ chroma: ChromaComponent,
+ /// The hue component.
+ #[external]
+ hue: Angle,
+ /// The alpha component.
+ #[external]
+ alpha: RatioComponent,
+ /// Alternatively: The color to convert to Oklch.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_oklch()
+ } else {
+ let RatioComponent(l) = args.expect("lightness component")?;
+ let ChromaComponent(c) = args.expect("chroma component")?;
+ let h: Angle = args.expect("hue component")?;
+ let RatioComponent(alpha) =
+ args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+ Self::Oklch(Oklch::new(
+ l.get() as f32,
+ c,
+ OklabHue::from_degrees(h.to_deg() as f32),
+ alpha.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an RGB(A) color with linear luma.
+ ///
+ /// This color space is similar to sRGB, but with the distinction that the
+ /// color component are not gamma corrected. This makes it easier to perform
+ /// color operations such as blending and interpolation. Although, you
+ /// should prefer to use the [`oklab` function]($color.oklab) for these.
+ ///
+ /// A linear RGB(A) color is represented internally by an array of four
+ /// components:
+ /// - red ([`ratio`])
+ /// - green ([`ratio`])
+ /// - blue ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(fill: color.linear-rgb(
+ /// 30%, 50%, 10%,
+ /// ))
+ /// ```
+ #[func(title = "Linear RGB")]
+ pub fn linear_rgb(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The red component.
+ #[external]
+ red: Component,
+ /// The green component.
+ #[external]
+ green: Component,
+ /// The blue component.
+ #[external]
+ blue: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color to convert to linear RGB(A).
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_linear_rgb()
+ } else {
+ let Component(r) = args.expect("red component")?;
+ let Component(g) = args.expect("green component")?;
+ let Component(b) = args.expect("blue component")?;
+ let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one()));
+ Self::LinearRgb(LinearRgb::new(
+ r.get() as f32,
+ g.get() as f32,
+ b.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an RGB(A) color.
+ ///
+ /// The color is specified in the sRGB color space.
+ ///
+ /// An RGB(A) color is represented internally by an array of four components:
+ /// - red ([`ratio`])
+ /// - green ([`ratio`])
+ /// - blue ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the [`components`]($color.components)
+ /// method.
+ ///
+ /// ```example
+ /// #square(fill: rgb("#b1f2eb"))
+ /// #square(fill: rgb(87, 127, 230))
+ /// #square(fill: rgb(25%, 13%, 65%))
+ /// ```
+ #[func(title = "RGB")]
+ pub fn rgb(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The red component.
+ #[external]
+ red: Component,
+ /// The green component.
+ #[external]
+ green: Component,
+ /// The blue component.
+ #[external]
+ blue: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color in hexadecimal notation.
+ ///
+ /// Accepts three, four, six or eight hexadecimal digits and optionally
+ /// a leading hash.
+ ///
+ /// If this is given, the individual components should not be given.
+ ///
+ /// ```example
+ /// #text(16pt, rgb("#239dad"))[
+ /// *Typst*
+ /// ]
+ /// ```
+ #[external]
+ hex: Str,
+ /// Alternatively: The color to convert to RGB(a).
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(string) = args.find::<Spanned<Str>>()? {
+ Self::from_str(&string.v).at(string.span)?
+ } else if let Some(color) = args.find::<Color>()? {
+ color.to_rgb()
+ } else {
+ let Component(r) = args.expect("red component")?;
+ let Component(g) = args.expect("green component")?;
+ let Component(b) = args.expect("blue component")?;
+ let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one()));
+ Self::Rgb(Rgb::new(
+ r.get() as f32,
+ g.get() as f32,
+ b.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Create a CMYK color.
+ ///
+ /// This is useful if you want to target a specific printer. The conversion
+ /// to RGB for display preview might differ from how your printer reproduces
+ /// the color.
+ ///
+ /// A CMYK color is represented internally by an array of four components:
+ /// - cyan ([`ratio`])
+ /// - magenta ([`ratio`])
+ /// - yellow ([`ratio`])
+ /// - key ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// Note that CMYK colors are not currently supported when PDF/A output is
+ /// enabled.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: cmyk(27%, 0%, 3%, 5%)
+ /// )
+ /// ```
+ #[func(title = "CMYK")]
+ pub fn cmyk(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The cyan component.
+ #[external]
+ cyan: RatioComponent,
+ /// The magenta component.
+ #[external]
+ magenta: RatioComponent,
+ /// The yellow component.
+ #[external]
+ yellow: RatioComponent,
+ /// The key component.
+ #[external]
+ key: RatioComponent,
+ /// Alternatively: The color to convert to CMYK.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_cmyk()
+ } else {
+ let RatioComponent(c) = args.expect("cyan component")?;
+ let RatioComponent(m) = args.expect("magenta component")?;
+ let RatioComponent(y) = args.expect("yellow component")?;
+ let RatioComponent(k) = args.expect("key/black component")?;
+ Self::Cmyk(Cmyk::new(
+ c.get() as f32,
+ m.get() as f32,
+ y.get() as f32,
+ k.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an HSL color.
+ ///
+ /// This color space is useful for specifying colors by hue, saturation and
+ /// lightness. It is also useful for color manipulation, such as saturating
+ /// while keeping perceived hue.
+ ///
+ /// An HSL color is represented internally by an array of four components:
+ /// - hue ([`angle`])
+ /// - saturation ([`ratio`])
+ /// - lightness ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: color.hsl(30deg, 50%, 60%)
+ /// )
+ /// ```
+ #[func(title = "HSL")]
+ pub fn hsl(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The hue angle.
+ #[external]
+ hue: Angle,
+ /// The saturation component.
+ #[external]
+ saturation: Component,
+ /// The lightness component.
+ #[external]
+ lightness: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color to convert to HSL.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_hsl()
+ } else {
+ let h: Angle = args.expect("hue component")?;
+ let Component(s) = args.expect("saturation component")?;
+ let Component(l) = args.expect("lightness component")?;
+ let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one()));
+ Self::Hsl(Hsl::new(
+ RgbHue::from_degrees(h.to_deg() as f32),
+ s.get() as f32,
+ l.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Create an HSV color.
+ ///
+ /// This color space is useful for specifying colors by hue, saturation and
+ /// value. It is also useful for color manipulation, such as saturating
+ /// while keeping perceived hue.
+ ///
+ /// An HSV color is represented internally by an array of four components:
+ /// - hue ([`angle`])
+ /// - saturation ([`ratio`])
+ /// - value ([`ratio`])
+ /// - alpha ([`ratio`])
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: color.hsv(30deg, 50%, 60%)
+ /// )
+ /// ```
+ #[func(title = "HSV")]
+ pub fn hsv(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The hue angle.
+ #[external]
+ hue: Angle,
+ /// The saturation component.
+ #[external]
+ saturation: Component,
+ /// The value component.
+ #[external]
+ value: Component,
+ /// The alpha component.
+ #[external]
+ alpha: Component,
+ /// Alternatively: The color to convert to HSL.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_hsv()
+ } else {
+ let h: Angle = args.expect("hue component")?;
+ let Component(s) = args.expect("saturation component")?;
+ let Component(v) = args.expect("value component")?;
+ let Component(a) = args.eat()?.unwrap_or(Component(Ratio::one()));
+ Self::Hsv(Hsv::new(
+ RgbHue::from_degrees(h.to_deg() as f32),
+ s.get() as f32,
+ v.get() as f32,
+ a.get() as f32,
+ ))
+ })
+ }
+
+ /// Extracts the components of this color.
+ ///
+ /// The size and values of this array depends on the color space. You can
+ /// obtain the color space using [`space`]($color.space). Below is a table
+ /// of the color spaces and their components:
+ ///
+ /// | Color space | C1 | C2 | C3 | C4 |
+ /// |-------------------------|-----------|------------|-----------|--------|
+ /// | [`luma`]($color.luma) | Lightness | | | |
+ /// | [`oklab`]($color.oklab) | Lightness | `a` | `b` | Alpha |
+ /// | [`oklch`]($color.oklch) | Lightness | Chroma | Hue | Alpha |
+ /// | [`linear-rgb`]($color.linear-rgb) | Red | Green | Blue | Alpha |
+ /// | [`rgb`]($color.rgb) | Red | Green | Blue | Alpha |
+ /// | [`cmyk`]($color.cmyk) | Cyan | Magenta | Yellow | Key |
+ /// | [`hsl`]($color.hsl) | Hue | Saturation | Lightness | Alpha |
+ /// | [`hsv`]($color.hsv) | Hue | Saturation | Value | Alpha |
+ ///
+ /// For the meaning and type of each individual value, see the documentation
+ /// of the corresponding color space. The alpha component is optional and
+ /// only included if the `alpha` argument is `true`. The length of the
+ /// returned array depends on the number of components and whether the alpha
+ /// component is included.
+ ///
+ /// ```example
+ /// // note that the alpha component is included by default
+ /// #rgb(40%, 60%, 80%).components()
+ /// ```
+ #[func]
+ pub fn components(
+ self,
+ /// Whether to include the alpha component.
+ #[named]
+ #[default(true)]
+ alpha: bool,
+ ) -> Array {
+ let mut components = match self {
+ Self::Luma(c) => {
+ array![Ratio::new(c.luma.into()), Ratio::new(c.alpha.into())]
+ }
+ Self::Oklab(c) => {
+ array![
+ Ratio::new(c.l.into()),
+ f64::from(c.a),
+ f64::from(c.b),
+ Ratio::new(c.alpha.into())
+ ]
+ }
+ Self::Oklch(c) => {
+ array![
+ Ratio::new(c.l.into()),
+ f64::from(c.chroma),
+ hue_angle(c.hue.into_degrees()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::LinearRgb(c) => {
+ array![
+ Ratio::new(c.red.into()),
+ Ratio::new(c.green.into()),
+ Ratio::new(c.blue.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::Rgb(c) => {
+ array![
+ Ratio::new(c.red.into()),
+ Ratio::new(c.green.into()),
+ Ratio::new(c.blue.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::Cmyk(c) => {
+ array![
+ Ratio::new(c.c.into()),
+ Ratio::new(c.m.into()),
+ Ratio::new(c.y.into()),
+ Ratio::new(c.k.into())
+ ]
+ }
+ Self::Hsl(c) => {
+ array![
+ hue_angle(c.hue.into_degrees()),
+ Ratio::new(c.saturation.into()),
+ Ratio::new(c.lightness.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ Self::Hsv(c) => {
+ array![
+ hue_angle(c.hue.into_degrees()),
+ Ratio::new(c.saturation.into()),
+ Ratio::new(c.value.into()),
+ Ratio::new(c.alpha.into()),
+ ]
+ }
+ };
+ // Remove the alpha component if the corresponding argument was set.
+ if !alpha && !matches!(self, Self::Cmyk(_)) {
+ let _ = components.pop();
+ }
+ components
+ }
+
+ /// Returns the constructor function for this color's space:
+ /// - [`luma`]($color.luma)
+ /// - [`oklab`]($color.oklab)
+ /// - [`oklch`]($color.oklch)
+ /// - [`linear-rgb`]($color.linear-rgb)
+ /// - [`rgb`]($color.rgb)
+ /// - [`cmyk`]($color.cmyk)
+ /// - [`hsl`]($color.hsl)
+ /// - [`hsv`]($color.hsv)
+ ///
+ /// ```example
+ /// #let color = cmyk(1%, 2%, 3%, 4%)
+ /// #(color.space() == cmyk)
+ /// ```
+ #[func]
+ pub fn space(self) -> ColorSpace {
+ match self {
+ Self::Luma(_) => ColorSpace::D65Gray,
+ Self::Oklab(_) => ColorSpace::Oklab,
+ Self::Oklch(_) => ColorSpace::Oklch,
+ Self::LinearRgb(_) => ColorSpace::LinearRgb,
+ Self::Rgb(_) => ColorSpace::Srgb,
+ Self::Cmyk(_) => ColorSpace::Cmyk,
+ Self::Hsl(_) => ColorSpace::Hsl,
+ Self::Hsv(_) => ColorSpace::Hsv,
+ }
+ }
+
+ /// Returns the color's RGB(A) hex representation (such as `#ffaa32` or
+ /// `#020304fe`). The alpha component (last two digits in `#020304fe`) is
+ /// omitted if it is equal to `ff` (255 / 100%).
+ #[func]
+ pub fn to_hex(self) -> EcoString {
+ let [r, g, b, a] = self.to_rgb().to_vec4_u8();
+ if a != 255 {
+ eco_format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
+ } else {
+ eco_format!("#{:02x}{:02x}{:02x}", r, g, b)
+ }
+ }
+
+ /// Lightens a color by a given factor.
+ #[func]
+ pub fn lighten(
+ self,
+ /// The factor to lighten the color by.
+ factor: Ratio,
+ ) -> Color {
+ let factor = factor.get() as f32;
+ match self {
+ Self::Luma(c) => Self::Luma(c.lighten(factor)),
+ Self::Oklab(c) => Self::Oklab(c.lighten(factor)),
+ Self::Oklch(c) => Self::Oklch(c.lighten(factor)),
+ Self::LinearRgb(c) => Self::LinearRgb(c.lighten(factor)),
+ Self::Rgb(c) => Self::Rgb(c.lighten(factor)),
+ Self::Cmyk(c) => Self::Cmyk(c.lighten(factor)),
+ Self::Hsl(c) => Self::Hsl(c.lighten(factor)),
+ Self::Hsv(c) => Self::Hsv(c.lighten(factor)),
+ }
+ }
+
+ /// Darkens a color by a given factor.
+ #[func]
+ pub fn darken(
+ self,
+ /// The factor to darken the color by.
+ factor: Ratio,
+ ) -> Color {
+ let factor = factor.get() as f32;
+ match self {
+ Self::Luma(c) => Self::Luma(c.darken(factor)),
+ Self::Oklab(c) => Self::Oklab(c.darken(factor)),
+ Self::Oklch(c) => Self::Oklch(c.darken(factor)),
+ Self::LinearRgb(c) => Self::LinearRgb(c.darken(factor)),
+ Self::Rgb(c) => Self::Rgb(c.darken(factor)),
+ Self::Cmyk(c) => Self::Cmyk(c.darken(factor)),
+ Self::Hsl(c) => Self::Hsl(c.darken(factor)),
+ Self::Hsv(c) => Self::Hsv(c.darken(factor)),
+ }
+ }
+
+ /// Increases the saturation of a color by a given factor.
+ #[func]
+ pub fn saturate(
+ self,
+ /// The call span
+ span: Span,
+ /// The factor to saturate the color by.
+ factor: Ratio,
+ ) -> SourceResult<Color> {
+ Ok(match self {
+ Self::Luma(_) => {
+ bail!(
+ span, "cannot saturate grayscale color";
+ hint: "try converting your color to RGB first"
+ );
+ }
+ Self::Oklab(_) => self.to_hsv().saturate(span, factor)?.to_oklab(),
+ Self::Oklch(_) => self.to_hsv().saturate(span, factor)?.to_oklch(),
+ Self::LinearRgb(_) => self.to_hsv().saturate(span, factor)?.to_linear_rgb(),
+ Self::Rgb(_) => self.to_hsv().saturate(span, factor)?.to_rgb(),
+ Self::Cmyk(_) => self.to_hsv().saturate(span, factor)?.to_cmyk(),
+ Self::Hsl(c) => Self::Hsl(c.saturate(factor.get() as f32)),
+ Self::Hsv(c) => Self::Hsv(c.saturate(factor.get() as f32)),
+ })
+ }
+
+ /// Decreases the saturation of a color by a given factor.
+ #[func]
+ pub fn desaturate(
+ self,
+ /// The call span
+ span: Span,
+ /// The factor to desaturate the color by.
+ factor: Ratio,
+ ) -> SourceResult<Color> {
+ Ok(match self {
+ Self::Luma(_) => {
+ bail!(
+ span, "cannot desaturate grayscale color";
+ hint: "try converting your color to RGB first"
+ );
+ }
+ Self::Oklab(_) => self.to_hsv().desaturate(span, factor)?.to_oklab(),
+ Self::Oklch(_) => self.to_hsv().desaturate(span, factor)?.to_oklch(),
+ Self::LinearRgb(_) => self.to_hsv().desaturate(span, factor)?.to_linear_rgb(),
+ Self::Rgb(_) => self.to_hsv().desaturate(span, factor)?.to_rgb(),
+ Self::Cmyk(_) => self.to_hsv().desaturate(span, factor)?.to_cmyk(),
+ Self::Hsl(c) => Self::Hsl(c.desaturate(factor.get() as f32)),
+ Self::Hsv(c) => Self::Hsv(c.desaturate(factor.get() as f32)),
+ })
+ }
+
+ /// Produces the complementary color using a provided color space.
+ /// You can think of it as the opposite side on a color wheel.
+ ///
+ /// ```example
+ /// #square(fill: yellow)
+ /// #square(fill: yellow.negate())
+ /// #square(fill: yellow.negate(space: rgb))
+ /// ```
+ #[func]
+ pub fn negate(
+ self,
+ /// The color space used for the transformation. By default, a perceptual color space is used.
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ ) -> Color {
+ let result = match self.to_space(space) {
+ Self::Luma(c) => Self::Luma(Luma::new(1.0 - c.luma, c.alpha)),
+ Self::Oklab(c) => Self::Oklab(Oklab::new(1.0 - c.l, -c.a, -c.b, c.alpha)),
+ Self::Oklch(c) => Self::Oklch(Oklch::new(
+ 1.0 - c.l,
+ c.chroma,
+ OklabHue::from_degrees(c.hue.into_degrees() + 180.0),
+ c.alpha,
+ )),
+ Self::LinearRgb(c) => Self::LinearRgb(LinearRgb::new(
+ 1.0 - c.red,
+ 1.0 - c.green,
+ 1.0 - c.blue,
+ c.alpha,
+ )),
+ Self::Rgb(c) => {
+ Self::Rgb(Rgb::new(1.0 - c.red, 1.0 - c.green, 1.0 - c.blue, c.alpha))
+ }
+ Self::Cmyk(c) => Self::Cmyk(Cmyk::new(1.0 - c.c, 1.0 - c.m, 1.0 - c.y, c.k)),
+ Self::Hsl(c) => Self::Hsl(Hsl::new(
+ RgbHue::from_degrees(c.hue.into_degrees() + 180.0),
+ c.saturation,
+ c.lightness,
+ c.alpha,
+ )),
+ Self::Hsv(c) => Self::Hsv(Hsv::new(
+ RgbHue::from_degrees(c.hue.into_degrees() + 180.0),
+ c.saturation,
+ c.value,
+ c.alpha,
+ )),
+ };
+ result.to_space(self.space())
+ }
+
+ /// Rotates the hue of the color by a given angle.
+ #[func]
+ pub fn rotate(
+ self,
+ /// The call span
+ span: Span,
+ /// The angle to rotate the hue by.
+ angle: Angle,
+ /// The color space used to rotate. By default, this happens in a perceptual
+ /// color space ([`oklch`]($color.oklch)).
+ #[named]
+ #[default(ColorSpace::Oklch)]
+ space: ColorSpace,
+ ) -> SourceResult<Color> {
+ Ok(match space {
+ ColorSpace::Oklch => {
+ let Self::Oklch(oklch) = self.to_oklch() else {
+ unreachable!();
+ };
+ let rotated = oklch.shift_hue(angle.to_deg() as f32);
+ Self::Oklch(rotated).to_space(self.space())
+ }
+ ColorSpace::Hsl => {
+ let Self::Hsl(hsl) = self.to_hsl() else {
+ unreachable!();
+ };
+ let rotated = hsl.shift_hue(angle.to_deg() as f32);
+ Self::Hsl(rotated).to_space(self.space())
+ }
+ ColorSpace::Hsv => {
+ let Self::Hsv(hsv) = self.to_hsv() else {
+ unreachable!();
+ };
+ let rotated = hsv.shift_hue(angle.to_deg() as f32);
+ Self::Hsv(rotated).to_space(self.space())
+ }
+ _ => bail!(span, "this colorspace does not support hue rotation"),
+ })
+ }
+
+ /// Create a color by mixing two or more colors.
+ ///
+ /// In color spaces with a hue component (hsl, hsv, oklch), only two colors
+ /// can be mixed at once. Mixing more than two colors in such a space will
+ /// result in an error!
+ ///
+ /// ```example
+ /// #set block(height: 20pt, width: 100%)
+ /// #block(fill: red.mix(blue))
+ /// #block(fill: red.mix(blue, space: rgb))
+ /// #block(fill: color.mix(red, blue, white))
+ /// #block(fill: color.mix((red, 70%), (blue, 30%)))
+ /// ```
+ #[func]
+ pub fn mix(
+ /// The colors, optionally with weights, specified as a pair (array of
+ /// length two) of color and weight (float or ratio).
+ ///
+ /// The weights do not need to add to `{100%}`, they are relative to the
+ /// sum of all weights.
+ #[variadic]
+ colors: Vec<WeightedColor>,
+ /// The color space to mix in. By default, this happens in a perceptual
+ /// color space ([`oklab`]($color.oklab)).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ ) -> StrResult<Color> {
+ Self::mix_iter(colors, space)
+ }
+
+ /// Makes a color more transparent by a given factor.
+ ///
+ /// This method is relative to the existing alpha value.
+ /// If the scale is positive, calculates `alpha - alpha * scale`.
+ /// Negative scales behave like `color.opacify(-scale)`.
+ ///
+ /// ```example
+ /// #block(fill: red)[opaque]
+ /// #block(fill: red.transparentize(50%))[half red]
+ /// #block(fill: red.transparentize(75%))[quarter red]
+ /// ```
+ #[func]
+ pub fn transparentize(
+ self,
+ /// The factor to change the alpha value by.
+ scale: Ratio,
+ ) -> StrResult<Color> {
+ self.scale_alpha(-scale)
+ }
+
+ /// Makes a color more opaque by a given scale.
+ ///
+ /// This method is relative to the existing alpha value.
+ /// If the scale is positive, calculates `alpha + scale - alpha * scale`.
+ /// Negative scales behave like `color.transparentize(-scale)`.
+ ///
+ /// ```example
+ /// #let half-red = red.transparentize(50%)
+ /// #block(fill: half-red.opacify(100%))[opaque]
+ /// #block(fill: half-red.opacify(50%))[three quarters red]
+ /// #block(fill: half-red.opacify(-50%))[one quarter red]
+ /// ```
+ #[func]
+ pub fn opacify(
+ self,
+ /// The scale to change the alpha value by.
+ scale: Ratio,
+ ) -> StrResult<Color> {
+ self.scale_alpha(scale)
+ }
+}
+
+impl Color {
+ /// Same as [`Color::mix`], but takes an iterator instead of a vector.
+ pub fn mix_iter(
+ colors: impl IntoIterator<
+ Item = WeightedColor,
+ IntoIter = impl ExactSizeIterator<Item = WeightedColor>,
+ >,
+ space: ColorSpace,
+ ) -> StrResult<Color> {
+ let mut colors = colors.into_iter();
+ if space.hue_index().is_some() && colors.len() > 2 {
+ bail!("cannot mix more than two colors in a hue-based space");
+ }
+
+ let m = if space.hue_index().is_some() && colors.len() == 2 {
+ let mut m = [0.0; 4];
+
+ let WeightedColor { color: c0, weight: w0 } = colors.next().unwrap();
+ let WeightedColor { color: c1, weight: w1 } = colors.next().unwrap();
+
+ let c0 = c0.to_space(space).to_vec4();
+ let c1 = c1.to_space(space).to_vec4();
+ let w0 = w0 as f32;
+ let w1 = w1 as f32;
+
+ if w0 + w1 <= 0.0 {
+ bail!("sum of weights must be positive");
+ }
+
+ for i in 0..4 {
+ m[i] = (w0 * c0[i] + w1 * c1[i]) / (w0 + w1);
+ }
+
+ // Ensure that the hue circle is traversed in the short direction.
+ if let Some(index) = space.hue_index() {
+ if (c0[index] - c1[index]).abs() > 180.0 {
+ let (h0, h1) = if c0[index] < c1[index] {
+ (c0[index] + 360.0, c1[index])
+ } else {
+ (c0[index], c1[index] + 360.0)
+ };
+ m[index] = (w0 * h0 + w1 * h1) / (w0 + w1);
+ }
+ }
+
+ m
+ } else {
+ let mut total = 0.0;
+ let mut acc = [0.0; 4];
+
+ for WeightedColor { color, weight } in colors {
+ let weight = weight as f32;
+ let v = color.to_space(space).to_vec4();
+ acc[0] += weight * v[0];
+ acc[1] += weight * v[1];
+ acc[2] += weight * v[2];
+ acc[3] += weight * v[3];
+ total += weight;
+ }
+
+ if total <= 0.0 {
+ bail!("sum of weights must be positive");
+ }
+
+ acc.map(|v| v / total)
+ };
+
+ Ok(match space {
+ ColorSpace::Oklab => Color::Oklab(Oklab::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::Oklch => Color::Oklch(Oklch::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::Srgb => Color::Rgb(Rgb::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::LinearRgb => {
+ Color::LinearRgb(LinearRgb::new(m[0], m[1], m[2], m[3]))
+ }
+ ColorSpace::Hsl => {
+ Color::Hsl(Hsl::new(RgbHue::from_degrees(m[0]), m[1], m[2], m[3]))
+ }
+ ColorSpace::Hsv => {
+ Color::Hsv(Hsv::new(RgbHue::from_degrees(m[0]), m[1], m[2], m[3]))
+ }
+ ColorSpace::Cmyk => Color::Cmyk(Cmyk::new(m[0], m[1], m[2], m[3])),
+ ColorSpace::D65Gray => Color::Luma(Luma::new(m[0], m[3])),
+ })
+ }
+
+ /// Construct a new RGBA color from 8-bit values.
+ pub fn from_u8(r: u8, g: u8, b: u8, a: u8) -> Self {
+ Self::Rgb(Rgb::new(
+ f32::from(r) / 255.0,
+ f32::from(g) / 255.0,
+ f32::from(b) / 255.0,
+ f32::from(a) / 255.0,
+ ))
+ }
+
+ /// Converts a 32-bit integer to an RGBA color.
+ pub fn from_u32(color: u32) -> Self {
+ Self::from_u8(
+ ((color >> 24) & 0xFF) as u8,
+ ((color >> 16) & 0xFF) as u8,
+ ((color >> 8) & 0xFF) as u8,
+ (color & 0xFF) as u8,
+ )
+ }
+
+ /// Returns the alpha channel of the color, if it has one.
+ pub fn alpha(&self) -> Option<f32> {
+ match self {
+ Color::Cmyk(_) => None,
+ Color::Luma(c) => Some(c.alpha),
+ Color::Oklab(c) => Some(c.alpha),
+ Color::Oklch(c) => Some(c.alpha),
+ Color::Rgb(c) => Some(c.alpha),
+ Color::LinearRgb(c) => Some(c.alpha),
+ Color::Hsl(c) => Some(c.alpha),
+ Color::Hsv(c) => Some(c.alpha),
+ }
+ }
+
+ /// Sets the alpha channel of the color, if it has one.
+ pub fn with_alpha(mut self, alpha: f32) -> Self {
+ match &mut self {
+ Color::Cmyk(_) => {}
+ Color::Luma(c) => c.alpha = alpha,
+ Color::Oklab(c) => c.alpha = alpha,
+ Color::Oklch(c) => c.alpha = alpha,
+ Color::Rgb(c) => c.alpha = alpha,
+ Color::LinearRgb(c) => c.alpha = alpha,
+ Color::Hsl(c) => c.alpha = alpha,
+ Color::Hsv(c) => c.alpha = alpha,
+ }
+
+ self
+ }
+
+ /// Scales the alpha value of a color by a given amount.
+ ///
+ /// For positive scales, computes `alpha + scale - alpha * scale`.
+ /// For non-positive scales, computes `alpha + alpha * scale`.
+ fn scale_alpha(self, scale: Ratio) -> StrResult<Color> {
+ #[inline]
+ fn transform<C>(mut color: Alpha<C, f32>, scale: Ratio) -> Alpha<C, f32> {
+ let scale = scale.get() as f32;
+ let factor = if scale > 0.0 { 1.0 - color.alpha } else { color.alpha };
+ color.alpha = (color.alpha + scale * factor).clamp(0.0, 1.0);
+ color
+ }
+
+ Ok(match self {
+ Color::Luma(c) => Color::Luma(transform(c, scale)),
+ Color::Oklab(c) => Color::Oklab(transform(c, scale)),
+ Color::Oklch(c) => Color::Oklch(transform(c, scale)),
+ Color::Rgb(c) => Color::Rgb(transform(c, scale)),
+ Color::LinearRgb(c) => Color::LinearRgb(transform(c, scale)),
+ Color::Cmyk(_) => bail!("CMYK does not have an alpha component"),
+ Color::Hsl(c) => Color::Hsl(transform(c, scale)),
+ Color::Hsv(c) => Color::Hsv(transform(c, scale)),
+ })
+ }
+
+ /// Converts the color to a vec of four floats.
+ pub fn to_vec4(&self) -> [f32; 4] {
+ match self {
+ Color::Luma(c) => [c.luma, c.luma, c.luma, c.alpha],
+ Color::Oklab(c) => [c.l, c.a, c.b, c.alpha],
+ Color::Oklch(c) => {
+ [c.l, c.chroma, c.hue.into_degrees().rem_euclid(360.0), c.alpha]
+ }
+ Color::Rgb(c) => [c.red, c.green, c.blue, c.alpha],
+ Color::LinearRgb(c) => [c.red, c.green, c.blue, c.alpha],
+ Color::Cmyk(c) => [c.c, c.m, c.y, c.k],
+ Color::Hsl(c) => [
+ c.hue.into_degrees().rem_euclid(360.0),
+ c.saturation,
+ c.lightness,
+ c.alpha,
+ ],
+ Color::Hsv(c) => {
+ [c.hue.into_degrees().rem_euclid(360.0), c.saturation, c.value, c.alpha]
+ }
+ }
+ }
+
+ /// Converts the color to a vec of four [`u8`]s.
+ pub fn to_vec4_u8(&self) -> [u8; 4] {
+ self.to_vec4().map(|x| (x * 255.0).round() as u8)
+ }
+
+ pub fn to_space(self, space: ColorSpace) -> Self {
+ match space {
+ ColorSpace::Oklab => self.to_oklab(),
+ ColorSpace::Oklch => self.to_oklch(),
+ ColorSpace::Srgb => self.to_rgb(),
+ ColorSpace::LinearRgb => self.to_linear_rgb(),
+ ColorSpace::Hsl => self.to_hsl(),
+ ColorSpace::Hsv => self.to_hsv(),
+ ColorSpace::Cmyk => self.to_cmyk(),
+ ColorSpace::D65Gray => self.to_luma(),
+ }
+ }
+
+ pub fn to_luma(self) -> Self {
+ Self::Luma(match self {
+ Self::Luma(c) => c,
+ Self::Oklab(c) => Luma::from_color(c),
+ Self::Oklch(c) => Luma::from_color(c),
+ Self::Rgb(c) => Luma::from_color(c),
+ Self::LinearRgb(c) => Luma::from_color(c),
+ Self::Cmyk(c) => Luma::from_color(c.to_rgba()),
+ Self::Hsl(c) => Luma::from_color(c),
+ Self::Hsv(c) => Luma::from_color(c),
+ })
+ }
+
+ pub fn to_oklab(self) -> Self {
+ Self::Oklab(match self {
+ Self::Luma(c) => Oklab::from_color(c),
+ Self::Oklab(c) => c,
+ Self::Oklch(c) => Oklab::from_color(c),
+ Self::Rgb(c) => Oklab::from_color(c),
+ Self::LinearRgb(c) => Oklab::from_color(c),
+ Self::Cmyk(c) => Oklab::from_color(c.to_rgba()),
+ Self::Hsl(c) => Oklab::from_color(c),
+ Self::Hsv(c) => Oklab::from_color(c),
+ })
+ }
+
+ pub fn to_oklch(self) -> Self {
+ Self::Oklch(match self {
+ Self::Luma(c) => Oklch::from_color(c),
+ Self::Oklab(c) => Oklch::from_color(c),
+ Self::Oklch(c) => c,
+ Self::Rgb(c) => Oklch::from_color(c),
+ Self::LinearRgb(c) => Oklch::from_color(c),
+ Self::Cmyk(c) => Oklch::from_color(c.to_rgba()),
+ Self::Hsl(c) => Oklch::from_color(c),
+ Self::Hsv(c) => Oklch::from_color(c),
+ })
+ }
+
+ pub fn to_rgb(self) -> Self {
+ Self::Rgb(match self {
+ Self::Luma(c) => Rgb::from_color(c),
+ Self::Oklab(c) => Rgb::from_color(c),
+ Self::Oklch(c) => Rgb::from_color(c),
+ Self::Rgb(c) => c,
+ Self::LinearRgb(c) => Rgb::from_linear(c),
+ Self::Cmyk(c) => Rgb::from_color(c.to_rgba()),
+ Self::Hsl(c) => Rgb::from_color(c),
+ Self::Hsv(c) => Rgb::from_color(c),
+ })
+ }
+
+ pub fn to_linear_rgb(self) -> Self {
+ Self::LinearRgb(match self {
+ Self::Luma(c) => LinearRgb::from_color(c),
+ Self::Oklab(c) => LinearRgb::from_color(c),
+ Self::Oklch(c) => LinearRgb::from_color(c),
+ Self::Rgb(c) => LinearRgb::from_color(c),
+ Self::LinearRgb(c) => c,
+ Self::Cmyk(c) => LinearRgb::from_color(c.to_rgba()),
+ Self::Hsl(c) => Rgb::from_color(c).into_linear(),
+ Self::Hsv(c) => Rgb::from_color(c).into_linear(),
+ })
+ }
+
+ pub fn to_cmyk(self) -> Self {
+ Self::Cmyk(match self {
+ Self::Luma(c) => Cmyk::from_luma(c),
+ Self::Oklab(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ Self::Oklch(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ Self::Rgb(c) => Cmyk::from_rgba(c),
+ Self::LinearRgb(c) => Cmyk::from_rgba(Rgb::from_linear(c)),
+ Self::Cmyk(c) => c,
+ Self::Hsl(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ Self::Hsv(c) => Cmyk::from_rgba(Rgb::from_color(c)),
+ })
+ }
+
+ pub fn to_hsl(self) -> Self {
+ Self::Hsl(match self {
+ Self::Luma(c) => Hsl::from_color(c),
+ Self::Oklab(c) => Hsl::from_color(c),
+ Self::Oklch(c) => Hsl::from_color(c),
+ Self::Rgb(c) => Hsl::from_color(c),
+ Self::LinearRgb(c) => Hsl::from_color(Rgb::from_linear(c)),
+ Self::Cmyk(c) => Hsl::from_color(c.to_rgba()),
+ Self::Hsl(c) => c,
+ Self::Hsv(c) => Hsl::from_color(c),
+ })
+ }
+
+ pub fn to_hsv(self) -> Self {
+ Self::Hsv(match self {
+ Self::Luma(c) => Hsv::from_color(c),
+ Self::Oklab(c) => Hsv::from_color(c),
+ Self::Oklch(c) => Hsv::from_color(c),
+ Self::Rgb(c) => Hsv::from_color(c),
+ Self::LinearRgb(c) => Hsv::from_color(Rgb::from_linear(c)),
+ Self::Cmyk(c) => Hsv::from_color(c.to_rgba()),
+ Self::Hsl(c) => Hsv::from_color(c),
+ Self::Hsv(c) => c,
+ })
+ }
+}
+
+impl Debug for Color {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Luma(v) => write!(f, "Luma({}, {})", v.luma, v.alpha),
+ Self::Oklab(v) => write!(f, "Oklab({}, {}, {}, {})", v.l, v.a, v.b, v.alpha),
+ Self::Oklch(v) => {
+ write!(
+ f,
+ "Oklch({}, {}, {:?}, {})",
+ v.l,
+ v.chroma,
+ hue_angle(v.hue.into_degrees()),
+ v.alpha
+ )
+ }
+ Self::Rgb(v) => {
+ write!(f, "Rgb({}, {}, {}, {})", v.red, v.green, v.blue, v.alpha)
+ }
+ Self::LinearRgb(v) => {
+ write!(f, "LinearRgb({}, {}, {}, {})", v.red, v.green, v.blue, v.alpha)
+ }
+ Self::Cmyk(v) => write!(f, "Cmyk({}, {}, {}, {})", v.c, v.m, v.y, v.k),
+ Self::Hsl(v) => write!(
+ f,
+ "Hsl({:?}, {}, {}, {})",
+ hue_angle(v.hue.into_degrees()),
+ v.saturation,
+ v.lightness,
+ v.alpha
+ ),
+ Self::Hsv(v) => write!(
+ f,
+ "Hsv({:?}, {}, {}, {})",
+ hue_angle(v.hue.into_degrees()),
+ v.saturation,
+ v.value,
+ v.alpha
+ ),
+ }
+ }
+}
+
+impl Repr for Color {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Luma(c) => {
+ if c.alpha == 1.0 {
+ eco_format!("luma({})", Ratio::new(c.luma.into()).repr())
+ } else {
+ eco_format!(
+ "luma({}, {})",
+ Ratio::new(c.luma.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Rgb(_) => eco_format!("rgb({})", self.to_hex().repr()),
+ Self::LinearRgb(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "color.linear-rgb({}, {}, {})",
+ Ratio::new(c.red.into()).repr(),
+ Ratio::new(c.green.into()).repr(),
+ Ratio::new(c.blue.into()).repr(),
+ )
+ } else {
+ eco_format!(
+ "color.linear-rgb({}, {}, {}, {})",
+ Ratio::new(c.red.into()).repr(),
+ Ratio::new(c.green.into()).repr(),
+ Ratio::new(c.blue.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Cmyk(c) => {
+ eco_format!(
+ "cmyk({}, {}, {}, {})",
+ Ratio::new(c.c.into()).repr(),
+ Ratio::new(c.m.into()).repr(),
+ Ratio::new(c.y.into()).repr(),
+ Ratio::new(c.k.into()).repr(),
+ )
+ }
+ Self::Oklab(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "oklab({}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.a.into()),
+ repr::format_float_component(c.b.into()),
+ )
+ } else {
+ eco_format!(
+ "oklab({}, {}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.a.into()),
+ repr::format_float_component(c.b.into()),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Oklch(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "oklch({}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.chroma.into()),
+ hue_angle(c.hue.into_degrees()).repr(),
+ )
+ } else {
+ eco_format!(
+ "oklch({}, {}, {}, {})",
+ Ratio::new(c.l.into()).repr(),
+ repr::format_float_component(c.chroma.into()),
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Hsl(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "color.hsl({}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.lightness.into()).repr(),
+ )
+ } else {
+ eco_format!(
+ "color.hsl({}, {}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.lightness.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ Self::Hsv(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "color.hsv({}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.value.into()).repr(),
+ )
+ } else {
+ eco_format!(
+ "color.hsv({}, {}, {}, {})",
+ hue_angle(c.hue.into_degrees()).repr(),
+ Ratio::new(c.saturation.into()).repr(),
+ Ratio::new(c.value.into()).repr(),
+ Ratio::new(c.alpha.into()).repr(),
+ )
+ }
+ }
+ }
+ }
+}
+
+fn hue_angle(degrees: f32) -> Angle {
+ Angle::deg(f64::from(degrees).rem_euclid(360.0))
+}
+
+impl PartialEq for Color {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ // Lower precision for comparison to avoid rounding errors.
+ // Keeps backward compatibility with previous versions of Typst.
+ (Self::Rgb(_), Self::Rgb(_)) => self.to_vec4_u8() == other.to_vec4_u8(),
+ (Self::Luma(a), Self::Luma(b)) => {
+ (a.luma * 255.0).round() as u8 == (b.luma * 255.0).round() as u8
+ }
+ (Self::Oklab(a), Self::Oklab(b)) => a == b,
+ (Self::Oklch(a), Self::Oklch(b)) => a == b,
+ (Self::LinearRgb(a), Self::LinearRgb(b)) => a == b,
+ (Self::Cmyk(a), Self::Cmyk(b)) => a == b,
+ (Self::Hsl(a), Self::Hsl(b)) => a == b,
+ (Self::Hsv(a), Self::Hsv(b)) => a == b,
+ _ => false,
+ }
+ }
+}
+
+impl Eq for Color {}
+
+impl Hash for Color {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ core::mem::discriminant(self).hash(state);
+ let [x, y, z, w] = self.to_vec4();
+ x.to_bits().hash(state);
+ y.to_bits().hash(state);
+ z.to_bits().hash(state);
+ w.to_bits().hash(state);
+ }
+}
+
+impl FromStr for Color {
+ type Err = &'static str;
+
+ /// Constructs a new color from hex strings like the following:
+ /// - `#aef` (shorthand, with leading hash),
+ /// - `7a03c2` (without alpha),
+ /// - `abcdefff` (with alpha).
+ ///
+ /// The hash is optional and both lower and upper case are fine.
+ fn from_str(hex_str: &str) -> Result<Self, Self::Err> {
+ let hex_str = hex_str.strip_prefix('#').unwrap_or(hex_str);
+ if hex_str.chars().any(|c| !c.is_ascii_hexdigit()) {
+ return Err("color string contains non-hexadecimal letters");
+ }
+
+ let len = hex_str.len();
+ let long = len == 6 || len == 8;
+ let short = len == 3 || len == 4;
+ let alpha = len == 4 || len == 8;
+ if !long && !short {
+ return Err("color string has wrong length");
+ }
+
+ let mut values: [u8; 4] = [u8::MAX; 4];
+ for elem in if alpha { 0..4 } else { 0..3 } {
+ let item_len = if long { 2 } else { 1 };
+ let pos = elem * item_len;
+
+ let item = &hex_str[pos..(pos + item_len)];
+ values[elem] = u8::from_str_radix(item, 16).unwrap();
+
+ if short {
+ // Duplicate number for shorthand notation, i.e. `a` -> `aa`
+ values[elem] += values[elem] * 16;
+ }
+ }
+
+ Ok(Self::from_u8(values[0], values[1], values[2], values[3]))
+ }
+}
+
+impl From<Luma> for Color {
+ fn from(c: Luma) -> Self {
+ Self::Luma(c)
+ }
+}
+
+impl From<Oklab> for Color {
+ fn from(c: Oklab) -> Self {
+ Self::Oklab(c)
+ }
+}
+
+impl From<Oklch> for Color {
+ fn from(c: Oklch) -> Self {
+ Self::Oklch(c)
+ }
+}
+
+impl From<Rgb> for Color {
+ fn from(c: Rgb) -> Self {
+ Self::Rgb(c)
+ }
+}
+
+impl From<LinearRgb> for Color {
+ fn from(c: LinearRgb) -> Self {
+ Self::LinearRgb(c)
+ }
+}
+
+impl From<Cmyk> for Color {
+ fn from(c: Cmyk) -> Self {
+ Self::Cmyk(c)
+ }
+}
+
+impl From<Hsl> for Color {
+ fn from(c: Hsl) -> Self {
+ Self::Hsl(c)
+ }
+}
+
+impl From<Hsv> for Color {
+ fn from(c: Hsv) -> Self {
+ Self::Hsv(c)
+ }
+}
+
+/// An 8-bit CMYK color.
+#[derive(Debug, Copy, Clone, PartialEq)]
+pub struct Cmyk {
+ /// The cyan component.
+ pub c: f32,
+ /// The magenta component.
+ pub m: f32,
+ /// The yellow component.
+ pub y: f32,
+ /// The key (black) component.
+ pub k: f32,
+}
+
+impl Cmyk {
+ fn new(c: f32, m: f32, y: f32, k: f32) -> Self {
+ Self { c, m, y, k }
+ }
+
+ fn from_luma(luma: Luma) -> Self {
+ let l = 1.0 - luma.luma;
+ Cmyk::new(l * 0.75, l * 0.68, l * 0.67, l * 0.90)
+ }
+
+ // This still uses naive conversion, because qcms does not support
+ // converting to CMYK yet.
+ fn from_rgba(rgba: Rgb) -> Self {
+ let r = rgba.red;
+ let g = rgba.green;
+ let b = rgba.blue;
+
+ let k = 1.0 - r.max(g).max(b);
+ if k == 1.0 {
+ return Cmyk::new(0.0, 0.0, 0.0, 1.0);
+ }
+
+ let c = (1.0 - r - k) / (1.0 - k);
+ let m = (1.0 - g - k) / (1.0 - k);
+ let y = (1.0 - b - k) / (1.0 - k);
+
+ Cmyk::new(c, m, y, k)
+ }
+
+ fn to_rgba(self) -> Rgb {
+ let mut dest: [u8; 3] = [0; 3];
+ TO_SRGB.convert(
+ &[
+ (self.c * 255.0).round() as u8,
+ (self.m * 255.0).round() as u8,
+ (self.y * 255.0).round() as u8,
+ (self.k * 255.0).round() as u8,
+ ],
+ &mut dest,
+ );
+
+ Rgb::new(
+ f32::from(dest[0]) / 255.0,
+ f32::from(dest[1]) / 255.0,
+ f32::from(dest[2]) / 255.0,
+ 1.0,
+ )
+ }
+
+ fn lighten(self, factor: f32) -> Self {
+ let lighten = |u: f32| (u - u * factor).clamp(0.0, 1.0);
+ Self::new(lighten(self.c), lighten(self.m), lighten(self.y), lighten(self.k))
+ }
+
+ fn darken(self, factor: f32) -> Self {
+ let darken = |u: f32| (u + (1.0 - u) * factor).clamp(0.0, 1.0);
+ Self::new(darken(self.c), darken(self.m), darken(self.y), darken(self.k))
+ }
+}
+
+/// A color with a weight.
+pub struct WeightedColor {
+ color: Color,
+ weight: f64,
+}
+
+impl WeightedColor {
+ /// Create a new weighted color.
+ pub const fn new(color: Color, weight: f64) -> Self {
+ Self { color, weight }
+ }
+}
+
+cast! {
+ WeightedColor,
+ self => array![self.color, Value::Float(self.weight)].into_value(),
+ color: Color => Self { color, weight: 1.0 },
+ v: Array => {
+ let mut iter = v.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(c), Some(w), None) => Self {
+ color: c.cast()?,
+ weight: w.cast::<Weight>()?.0,
+ },
+ _ => bail!("expected a color or color-weight pair"),
+ }
+ }
+}
+
+/// A weight for color mixing.
+struct Weight(f64);
+
+cast! {
+ Weight,
+ v: f64 => Self(v),
+ v: Ratio => Self(v.get()),
+}
+
+/// A color space for color manipulation.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ColorSpace {
+ /// The perceptual Oklab color space.
+ Oklab,
+ /// The perceptual Oklch color space.
+ Oklch,
+ /// The standard RGB color space.
+ Srgb,
+ /// The D65-gray color space.
+ D65Gray,
+ /// The linear RGB color space.
+ LinearRgb,
+ /// The HSL color space.
+ Hsl,
+ /// The HSV color space.
+ Hsv,
+ /// The CMYK color space.
+ Cmyk,
+}
+
+impl ColorSpace {
+ /// Returns the index of the hue component in this color space, if it has
+ /// one.
+ pub fn hue_index(&self) -> Option<usize> {
+ match self {
+ Self::Hsl | Self::Hsv => Some(0),
+ Self::Oklch => Some(2),
+ _ => None,
+ }
+ }
+}
+
+cast! {
+ ColorSpace,
+ self => match self {
+ Self::Oklab => Color::oklab_data(),
+ Self::Oklch => Color::oklch_data(),
+ Self::Srgb => Color::rgb_data(),
+ Self::D65Gray => Color::luma_data(),
+ Self::LinearRgb => Color::linear_rgb_data(),
+ Self::Hsl => Color::hsl_data(),
+ Self::Hsv => Color::hsv_data(),
+ Self::Cmyk => Color::cmyk_data(),
+ }.into_value(),
+ v: Value => {
+ let expected = "expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`";
+ let Value::Func(func) = v else {
+ bail!("{expected}, found {}", v.ty());
+ };
+
+ // Here comparing the function pointer since it's `Eq`
+ // whereas the `NativeFuncData` is not.
+ if func == Color::oklab_data() {
+ Self::Oklab
+ } else if func == Color::oklch_data() {
+ Self::Oklch
+ } else if func == Color::rgb_data() {
+ Self::Srgb
+ } else if func == Color::luma_data() {
+ Self::D65Gray
+ } else if func == Color::linear_rgb_data() {
+ Self::LinearRgb
+ } else if func == Color::hsl_data() {
+ Self::Hsl
+ } else if func == Color::hsv_data() {
+ Self::Hsv
+ } else if func == Color::cmyk_data() {
+ Self::Cmyk
+ } else {
+ bail!("{expected}");
+ }
+ },
+}
+
+/// A component that must be a ratio.
+pub struct RatioComponent(Ratio);
+
+cast! {
+ RatioComponent,
+ self => self.0.into_value(),
+ v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) {
+ Self(v)
+ } else {
+ bail!("ratio must be between 0% and 100%");
+ },
+}
+
+/// A chroma color component.
+///
+/// Must either be:
+/// - a ratio, in which case it is relative to 0.4.
+/// - a float, in which case it is taken literally.
+pub struct ChromaComponent(f32);
+
+cast! {
+ ChromaComponent,
+ v: f64 => Self(v as f32),
+ v: Ratio => Self((v.get() * 0.4) as f32),
+}
+
+/// An integer or ratio component.
+pub struct Component(Ratio);
+
+cast! {
+ Component,
+ self => self.0.into_value(),
+ v: i64 => match v {
+ 0 ..= 255 => Self(Ratio::new(v as f64 / 255.0)),
+ _ => bail!("number must be between 0 and 255"),
+ },
+ v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) {
+ Self(v)
+ } else {
+ bail!("ratio must be between 0% and 100%");
+ },
+}
+
+/// A module with all preset color maps.
+fn map() -> Module {
+ let mut scope = Scope::new();
+ scope.define("turbo", turbo());
+ scope.define("cividis", cividis());
+ scope.define("rainbow", rainbow());
+ scope.define("spectral", spectral());
+ scope.define("viridis", viridis());
+ scope.define("inferno", inferno());
+ scope.define("magma", magma());
+ scope.define("plasma", plasma());
+ scope.define("rocket", rocket());
+ scope.define("mako", mako());
+ scope.define("vlag", vlag());
+ scope.define("icefire", icefire());
+ scope.define("flare", flare());
+ scope.define("crest", crest());
+ Module::new("map", scope)
+}
+
+/// Defines a gradient preset as a series of colors expressed as u32s.
+macro_rules! preset {
+ ($name:ident; $($colors:literal),* $(,)*) => {
+ fn $name() -> Array {
+ Array::from(
+ [$(Color::from_u32($colors)),*]
+ .iter()
+ .map(|c| c.into_value())
+ .collect::<EcoVec<_>>()
+ )
+ }
+ };
+}
+
+preset!(turbo; 0x23171bff, 0x271a28ff, 0x2b1c33ff, 0x2f1e3fff, 0x32204aff, 0x362354ff, 0x39255fff, 0x3b2768ff, 0x3e2a72ff, 0x402c7bff, 0x422f83ff, 0x44318bff, 0x453493ff, 0x46369bff, 0x4839a2ff, 0x493ca8ff, 0x493eafff, 0x4a41b5ff, 0x4a44bbff, 0x4b46c0ff, 0x4b49c5ff, 0x4b4ccaff, 0x4b4ecfff, 0x4b51d3ff, 0x4a54d7ff, 0x4a56dbff, 0x4959deff, 0x495ce2ff, 0x485fe5ff, 0x4761e7ff, 0x4664eaff, 0x4567ecff, 0x446aeeff, 0x446df0ff, 0x426ff2ff, 0x4172f3ff, 0x4075f5ff, 0x3f78f6ff, 0x3e7af7ff, 0x3d7df7ff, 0x3c80f8ff, 0x3a83f9ff, 0x3985f9ff, 0x3888f9ff, 0x378bf9ff, 0x368df9ff, 0x3590f8ff, 0x3393f8ff, 0x3295f7ff, 0x3198f7ff, 0x309bf6ff, 0x2f9df5ff, 0x2ea0f4ff, 0x2da2f3ff, 0x2ca5f1ff, 0x2ba7f0ff, 0x2aaaefff, 0x2aacedff, 0x29afecff, 0x28b1eaff, 0x28b4e8ff, 0x27b6e6ff, 0x27b8e5ff, 0x26bbe3ff, 0x26bde1ff, 0x26bfdfff, 0x25c1dcff, 0x25c3daff, 0x25c6d8ff, 0x25c8d6ff, 0x25cad3ff, 0x25ccd1ff, 0x25cecfff, 0x26d0ccff, 0x26d2caff, 0x26d4c8ff, 0x27d6c5ff, 0x27d8c3ff, 0x28d9c0ff, 0x29dbbeff, 0x29ddbbff, 0x2adfb8ff, 0x2be0b6ff, 0x2ce2b3ff, 0x2de3b1ff, 0x2ee5aeff, 0x30e6acff, 0x31e8a9ff, 0x32e9a6ff, 0x34eba4ff, 0x35eca1ff, 0x37ed9fff, 0x39ef9cff, 0x3af09aff, 0x3cf197ff, 0x3ef295ff, 0x40f392ff, 0x42f490ff, 0x44f58dff, 0x46f68bff, 0x48f788ff, 0x4af786ff, 0x4df884ff, 0x4ff981ff, 0x51fa7fff, 0x54fa7dff, 0x56fb7aff, 0x59fb78ff, 0x5cfc76ff, 0x5efc74ff, 0x61fd71ff, 0x64fd6fff, 0x66fd6dff, 0x69fd6bff, 0x6cfd69ff, 0x6ffe67ff, 0x72fe65ff, 0x75fe63ff, 0x78fe61ff, 0x7bfe5fff, 0x7efd5dff, 0x81fd5cff, 0x84fd5aff, 0x87fd58ff, 0x8afc56ff, 0x8dfc55ff, 0x90fb53ff, 0x93fb51ff, 0x96fa50ff, 0x99fa4eff, 0x9cf94dff, 0x9ff84bff, 0xa2f84aff, 0xa6f748ff, 0xa9f647ff, 0xacf546ff, 0xaff444ff, 0xb2f343ff, 0xb5f242ff, 0xb8f141ff, 0xbbf03fff, 0xbeef3eff, 0xc1ed3dff, 0xc3ec3cff, 0xc6eb3bff, 0xc9e93aff, 0xcce839ff, 0xcfe738ff, 0xd1e537ff, 0xd4e336ff, 0xd7e235ff, 0xd9e034ff, 0xdcdf33ff, 0xdedd32ff, 0xe0db32ff, 0xe3d931ff, 0xe5d730ff, 0xe7d52fff, 0xe9d42fff, 0xecd22eff, 0xeed02dff, 0xf0ce2cff, 0xf1cb2cff, 0xf3c92bff, 0xf5c72bff, 0xf7c52aff, 0xf8c329ff, 0xfac029ff, 0xfbbe28ff, 0xfdbc28ff, 0xfeb927ff, 0xffb727ff, 0xffb526ff, 0xffb226ff, 0xffb025ff, 0xffad25ff, 0xffab24ff, 0xffa824ff, 0xffa623ff, 0xffa323ff, 0xffa022ff, 0xff9e22ff, 0xff9b21ff, 0xff9921ff, 0xff9621ff, 0xff9320ff, 0xff9020ff, 0xff8e1fff, 0xff8b1fff, 0xff881eff, 0xff851eff, 0xff831dff, 0xff801dff, 0xff7d1dff, 0xff7a1cff, 0xff781cff, 0xff751bff, 0xff721bff, 0xff6f1aff, 0xfd6c1aff, 0xfc6a19ff, 0xfa6719ff, 0xf96418ff, 0xf76118ff, 0xf65f18ff, 0xf45c17ff, 0xf25916ff, 0xf05716ff, 0xee5415ff, 0xec5115ff, 0xea4f14ff, 0xe84c14ff, 0xe64913ff, 0xe44713ff, 0xe24412ff, 0xdf4212ff, 0xdd3f11ff, 0xda3d10ff, 0xd83a10ff, 0xd5380fff, 0xd3360fff, 0xd0330eff, 0xce310dff, 0xcb2f0dff, 0xc92d0cff, 0xc62a0bff, 0xc3280bff, 0xc1260aff, 0xbe2409ff, 0xbb2309ff, 0xb92108ff, 0xb61f07ff, 0xb41d07ff, 0xb11b06ff, 0xaf1a05ff, 0xac1805ff, 0xaa1704ff, 0xa81604ff, 0xa51403ff, 0xa31302ff, 0xa11202ff, 0x9f1101ff, 0x9d1000ff, 0x9b0f00ff, 0x9a0e00ff, 0x980e00ff, 0x960d00ff, 0x950c00ff, 0x940c00ff, 0x930c00ff, 0x920c00ff, 0x910b00ff, 0x910c00ff, 0x900c00ff, 0x900c00ff, 0x900c00ff);
+preset!(cividis; 0x002051ff, 0x002153ff, 0x002255ff, 0x002356ff, 0x002358ff, 0x002459ff, 0x00255aff, 0x00255cff, 0x00265dff, 0x00275eff, 0x00275fff, 0x002860ff, 0x002961ff, 0x002962ff, 0x002a63ff, 0x002b64ff, 0x012b65ff, 0x022c65ff, 0x032d66ff, 0x042d67ff, 0x052e67ff, 0x052f68ff, 0x063069ff, 0x073069ff, 0x08316aff, 0x09326aff, 0x0b326aff, 0x0c336bff, 0x0d346bff, 0x0e346bff, 0x0f356cff, 0x10366cff, 0x12376cff, 0x13376dff, 0x14386dff, 0x15396dff, 0x17396dff, 0x183a6dff, 0x193b6dff, 0x1a3b6dff, 0x1c3c6eff, 0x1d3d6eff, 0x1e3e6eff, 0x203e6eff, 0x213f6eff, 0x23406eff, 0x24406eff, 0x25416eff, 0x27426eff, 0x28436eff, 0x29436eff, 0x2b446eff, 0x2c456eff, 0x2e456eff, 0x2f466eff, 0x30476eff, 0x32486eff, 0x33486eff, 0x34496eff, 0x364a6eff, 0x374a6eff, 0x394b6eff, 0x3a4c6eff, 0x3b4d6eff, 0x3d4d6eff, 0x3e4e6eff, 0x3f4f6eff, 0x414f6eff, 0x42506eff, 0x43516dff, 0x44526dff, 0x46526dff, 0x47536dff, 0x48546dff, 0x4a546dff, 0x4b556dff, 0x4c566dff, 0x4d576dff, 0x4e576eff, 0x50586eff, 0x51596eff, 0x52596eff, 0x535a6eff, 0x545b6eff, 0x565c6eff, 0x575c6eff, 0x585d6eff, 0x595e6eff, 0x5a5e6eff, 0x5b5f6eff, 0x5c606eff, 0x5d616eff, 0x5e616eff, 0x60626eff, 0x61636fff, 0x62646fff, 0x63646fff, 0x64656fff, 0x65666fff, 0x66666fff, 0x67676fff, 0x686870ff, 0x696970ff, 0x6a6970ff, 0x6b6a70ff, 0x6c6b70ff, 0x6d6c70ff, 0x6d6c71ff, 0x6e6d71ff, 0x6f6e71ff, 0x706f71ff, 0x716f71ff, 0x727071ff, 0x737172ff, 0x747172ff, 0x757272ff, 0x767372ff, 0x767472ff, 0x777473ff, 0x787573ff, 0x797673ff, 0x7a7773ff, 0x7b7774ff, 0x7b7874ff, 0x7c7974ff, 0x7d7a74ff, 0x7e7a74ff, 0x7f7b75ff, 0x807c75ff, 0x807d75ff, 0x817d75ff, 0x827e75ff, 0x837f76ff, 0x848076ff, 0x858076ff, 0x858176ff, 0x868276ff, 0x878376ff, 0x888477ff, 0x898477ff, 0x898577ff, 0x8a8677ff, 0x8b8777ff, 0x8c8777ff, 0x8d8877ff, 0x8e8978ff, 0x8e8a78ff, 0x8f8a78ff, 0x908b78ff, 0x918c78ff, 0x928d78ff, 0x938e78ff, 0x938e78ff, 0x948f78ff, 0x959078ff, 0x969178ff, 0x979278ff, 0x989278ff, 0x999378ff, 0x9a9478ff, 0x9b9578ff, 0x9b9678ff, 0x9c9678ff, 0x9d9778ff, 0x9e9878ff, 0x9f9978ff, 0xa09a78ff, 0xa19a78ff, 0xa29b78ff, 0xa39c78ff, 0xa49d78ff, 0xa59e77ff, 0xa69e77ff, 0xa79f77ff, 0xa8a077ff, 0xa9a177ff, 0xaaa276ff, 0xaba376ff, 0xaca376ff, 0xada476ff, 0xaea575ff, 0xafa675ff, 0xb0a775ff, 0xb2a874ff, 0xb3a874ff, 0xb4a974ff, 0xb5aa73ff, 0xb6ab73ff, 0xb7ac72ff, 0xb8ad72ff, 0xbaae72ff, 0xbbae71ff, 0xbcaf71ff, 0xbdb070ff, 0xbeb170ff, 0xbfb26fff, 0xc1b36fff, 0xc2b46eff, 0xc3b56dff, 0xc4b56dff, 0xc5b66cff, 0xc7b76cff, 0xc8b86bff, 0xc9b96aff, 0xcaba6aff, 0xccbb69ff, 0xcdbc68ff, 0xcebc68ff, 0xcfbd67ff, 0xd1be66ff, 0xd2bf66ff, 0xd3c065ff, 0xd4c164ff, 0xd6c263ff, 0xd7c363ff, 0xd8c462ff, 0xd9c561ff, 0xdbc660ff, 0xdcc660ff, 0xddc75fff, 0xdec85eff, 0xe0c95dff, 0xe1ca5cff, 0xe2cb5cff, 0xe3cc5bff, 0xe4cd5aff, 0xe6ce59ff, 0xe7cf58ff, 0xe8d058ff, 0xe9d157ff, 0xead256ff, 0xebd355ff, 0xecd454ff, 0xedd453ff, 0xeed553ff, 0xf0d652ff, 0xf1d751ff, 0xf1d850ff, 0xf2d950ff, 0xf3da4fff, 0xf4db4eff, 0xf5dc4dff, 0xf6dd4dff, 0xf7de4cff, 0xf8df4bff, 0xf8e04bff, 0xf9e14aff, 0xfae249ff, 0xfae349ff, 0xfbe448ff, 0xfbe548ff, 0xfce647ff, 0xfce746ff, 0xfde846ff, 0xfde946ff, 0xfdea45ff);
+preset!(rainbow; 0x7c4bbbff, 0x7f4bbcff, 0x824bbdff, 0x854abeff, 0x884abeff, 0x8b4abfff, 0x8e49bfff, 0x9149c0ff, 0x9449c0ff, 0x9748c0ff, 0x9a48c1ff, 0x9e48c1ff, 0xa148c1ff, 0xa447c1ff, 0xa747c1ff, 0xaa47c0ff, 0xad47c0ff, 0xb046c0ff, 0xb446bfff, 0xb746bfff, 0xba46beff, 0xbd46beff, 0xc046bdff, 0xc346bcff, 0xc646bbff, 0xc946baff, 0xcc46b9ff, 0xcf46b8ff, 0xd246b7ff, 0xd446b5ff, 0xd747b4ff, 0xda47b3ff, 0xdd47b1ff, 0xdf47b0ff, 0xe248aeff, 0xe448acff, 0xe748abff, 0xe949a9ff, 0xec49a7ff, 0xee4aa5ff, 0xf04ba3ff, 0xf34ba1ff, 0xf54c9fff, 0xf74c9dff, 0xf94d9bff, 0xfb4e98ff, 0xfd4f96ff, 0xfe5094ff, 0xff5191ff, 0xff528fff, 0xff538dff, 0xff548aff, 0xff5588ff, 0xff5685ff, 0xff5783ff, 0xff5880ff, 0xff5a7eff, 0xff5b7bff, 0xff5c79ff, 0xff5e76ff, 0xff5f74ff, 0xff6171ff, 0xff626fff, 0xff646cff, 0xff666aff, 0xff6767ff, 0xff6965ff, 0xff6b63ff, 0xff6d60ff, 0xff6e5eff, 0xff705cff, 0xff7259ff, 0xff7457ff, 0xff7655ff, 0xff7853ff, 0xff7a51ff, 0xff7c4fff, 0xff7f4dff, 0xff814bff, 0xff8349ff, 0xff8547ff, 0xff8745ff, 0xff8a44ff, 0xff8c42ff, 0xff8e40ff, 0xff913fff, 0xff933eff, 0xff953cff, 0xff983bff, 0xfd9a3aff, 0xfb9c39ff, 0xfa9f38ff, 0xf8a137ff, 0xf6a436ff, 0xf4a636ff, 0xf2a935ff, 0xf0ab35ff, 0xeeae34ff, 0xecb034ff, 0xeab234ff, 0xe8b534ff, 0xe6b734ff, 0xe4ba34ff, 0xe1bc34ff, 0xdfbf35ff, 0xddc135ff, 0xdbc336ff, 0xd9c636ff, 0xd6c837ff, 0xd4ca38ff, 0xd2cd39ff, 0xd0cf3aff, 0xcdd13bff, 0xcbd33dff, 0xc9d63eff, 0xc7d840ff, 0xc5da41ff, 0xc3dc43ff, 0xc1de45ff, 0xbfe047ff, 0xbde249ff, 0xbbe44bff, 0xb9e64dff, 0xb7e84fff, 0xb5ea52ff, 0xb3ec54ff, 0xb2ed57ff, 0xb0ef59ff, 0xadf05aff, 0xaaf15aff, 0xa6f159ff, 0xa2f259ff, 0x9ff259ff, 0x9bf358ff, 0x97f358ff, 0x94f459ff, 0x90f459ff, 0x8df559ff, 0x89f559ff, 0x85f65aff, 0x82f65bff, 0x7ff65bff, 0x7ef75cff, 0x7cf75dff, 0x7bf75eff, 0x7af75fff, 0x79f760ff, 0x78f762ff, 0x77f763ff, 0x76f764ff, 0x75f766ff, 0x74f768ff, 0x73f769ff, 0x72f76bff, 0x71f76dff, 0x70f76fff, 0x6ff671ff, 0x6ef673ff, 0x6df675ff, 0x6df577ff, 0x6cf579ff, 0x6bf47cff, 0x6af37eff, 0x69f380ff, 0x68f283ff, 0x67f185ff, 0x66f188ff, 0x66f08aff, 0x65ef8dff, 0x64ee8fff, 0x63ed92ff, 0x62ec94ff, 0x62eb97ff, 0x61ea9aff, 0x60e89cff, 0x5fe79fff, 0x5fe6a1ff, 0x5ee4a4ff, 0x5de3a7ff, 0x5ce2a9ff, 0x5ce0acff, 0x5bdfafff, 0x5addb1ff, 0x5adbb4ff, 0x59dab6ff, 0x58d8b9ff, 0x58d6bbff, 0x57d5beff, 0x56d3c0ff, 0x56d1c2ff, 0x55cfc5ff, 0x54cdc7ff, 0x54cbc9ff, 0x53c9cbff, 0x52c7cdff, 0x52c5cfff, 0x51c3d1ff, 0x51c1d3ff, 0x50bfd5ff, 0x50bdd7ff, 0x4fbbd9ff, 0x4eb9daff, 0x4eb6dcff, 0x4db4ddff, 0x4db2dfff, 0x4cb0e0ff, 0x4caee2ff, 0x4babe3ff, 0x4ba9e4ff, 0x4aa7e5ff, 0x4aa4e6ff, 0x49a2e7ff, 0x49a0e8ff, 0x489ee8ff, 0x489be9ff, 0x4799e9ff, 0x4797eaff, 0x4694eaff, 0x4692eaff, 0x4690ebff, 0x458eebff, 0x478bebff, 0x4889ebff, 0x4a87eaff, 0x4c85eaff, 0x4e82eaff, 0x5080e9ff, 0x527ee9ff, 0x537ce8ff, 0x557ae7ff, 0x5778e7ff, 0x5975e6ff, 0x5b73e5ff, 0x5c71e4ff, 0x5e6fe3ff, 0x606de1ff, 0x626be0ff, 0x6369dfff, 0x6567ddff, 0x6765dcff, 0x6864daff, 0x6a62d9ff, 0x6b60d7ff, 0x6d5ed5ff, 0x6e5cd3ff, 0x705bd1ff, 0x7159cfff, 0x7357cdff, 0x7456cbff, 0x7554c9ff, 0x7652c7ff, 0x7751c5ff, 0x794fc2ff, 0x7a4ec0ff, 0x7b4dbeff, 0x7c4bbbff);
+preset!(spectral; 0x9e0142ff, 0xd53e4fff, 0xf46d43ff, 0xfdae61ff, 0xfee08bff, 0xffffbfff, 0xe6f598ff, 0xabdda4ff, 0x66c2a5ff, 0x3288bdff, 0x5e4fa2ff);
+preset!(viridis; 0x440154ff, 0x482777ff, 0x3f4a8aff, 0x31678eff, 0x26838fff, 0x1f9d8aff, 0x6cce5aff, 0xb6de2bff, 0xfee825ff);
+preset!(inferno; 0x000004ff, 0x170b3aff, 0x420a68ff, 0x6b176eff, 0x932667ff, 0xbb3654ff, 0xdd513aff, 0xf3771aff, 0xfca50aff, 0xf6d644ff, 0xfcffa4ff);
+preset!(magma; 0x000004ff, 0x140e37ff, 0x3b0f70ff, 0x641a80ff, 0x8c2981ff, 0xb63679ff, 0xde4968ff, 0xf66f5cff, 0xfe9f6dff, 0xfece91ff, 0xfcfdbfff);
+preset!(plasma; 0x0d0887ff, 0x42039dff, 0x6a00a8ff, 0x900da3ff, 0xb12a90ff, 0xcb4678ff, 0xe16462ff, 0xf1834bff, 0xfca636ff, 0xfccd25ff, 0xf0f921ff);
+preset!(rocket; 0x3051aff, 0x4051aff, 0x5061bff, 0x6071cff, 0x7071dff, 0x8081eff, 0xa091fff, 0xb0920ff, 0xd0a21ff, 0xe0b22ff, 0x100b23ff, 0x110c24ff, 0x130d25ff, 0x140e26ff, 0x160e27ff, 0x170f28ff, 0x180f29ff, 0x1a102aff, 0x1b112bff, 0x1d112cff, 0x1e122dff, 0x20122eff, 0x211330ff, 0x221331ff, 0x241432ff, 0x251433ff, 0x271534ff, 0x281535ff, 0x2a1636ff, 0x2b1637ff, 0x2d1738ff, 0x2e1739ff, 0x30173aff, 0x31183bff, 0x33183cff, 0x34193dff, 0x35193eff, 0x37193fff, 0x381a40ff, 0x3a1a41ff, 0x3c1a42ff, 0x3d1a42ff, 0x3f1b43ff, 0x401b44ff, 0x421b45ff, 0x431c46ff, 0x451c47ff, 0x461c48ff, 0x481c48ff, 0x491d49ff, 0x4b1d4aff, 0x4c1d4bff, 0x4e1d4bff, 0x501d4cff, 0x511e4dff, 0x531e4dff, 0x541e4eff, 0x561e4fff, 0x581e4fff, 0x591e50ff, 0x5b1e51ff, 0x5c1e51ff, 0x5e1f52ff, 0x601f52ff, 0x611f53ff, 0x631f53ff, 0x641f54ff, 0x661f54ff, 0x681f55ff, 0x691f55ff, 0x6b1f56ff, 0x6d1f56ff, 0x6e1f57ff, 0x701f57ff, 0x711f57ff, 0x731f58ff, 0x751f58ff, 0x761f58ff, 0x781f59ff, 0x7a1f59ff, 0x7b1f59ff, 0x7d1f5aff, 0x7f1e5aff, 0x811e5aff, 0x821e5aff, 0x841e5aff, 0x861e5bff, 0x871e5bff, 0x891e5bff, 0x8b1d5bff, 0x8c1d5bff, 0x8e1d5bff, 0x901d5bff, 0x921c5bff, 0x931c5bff, 0x951c5bff, 0x971c5bff, 0x981b5bff, 0x9a1b5bff, 0x9c1b5bff, 0x9e1a5bff, 0x9f1a5bff, 0xa11a5bff, 0xa3195bff, 0xa4195bff, 0xa6195aff, 0xa8185aff, 0xaa185aff, 0xab185aff, 0xad1759ff, 0xaf1759ff, 0xb01759ff, 0xb21758ff, 0xb41658ff, 0xb51657ff, 0xb71657ff, 0xb91657ff, 0xba1656ff, 0xbc1656ff, 0xbd1655ff, 0xbf1654ff, 0xc11754ff, 0xc21753ff, 0xc41753ff, 0xc51852ff, 0xc71951ff, 0xc81951ff, 0xca1a50ff, 0xcb1b4fff, 0xcd1c4eff, 0xce1d4eff, 0xcf1e4dff, 0xd11f4cff, 0xd2204cff, 0xd3214bff, 0xd5224aff, 0xd62449ff, 0xd72549ff, 0xd82748ff, 0xd92847ff, 0xdb2946ff, 0xdc2b46ff, 0xdd2c45ff, 0xde2e44ff, 0xdf2f44ff, 0xe03143ff, 0xe13342ff, 0xe23442ff, 0xe33641ff, 0xe43841ff, 0xe53940ff, 0xe63b40ff, 0xe73d3fff, 0xe83f3fff, 0xe8403eff, 0xe9423eff, 0xea443eff, 0xeb463eff, 0xeb483eff, 0xec4a3eff, 0xec4c3eff, 0xed4e3eff, 0xed503eff, 0xee523fff, 0xee543fff, 0xef5640ff, 0xef5840ff, 0xef5a41ff, 0xf05c42ff, 0xf05e42ff, 0xf06043ff, 0xf16244ff, 0xf16445ff, 0xf16646ff, 0xf26747ff, 0xf26948ff, 0xf26b49ff, 0xf26d4bff, 0xf26f4cff, 0xf3714dff, 0xf3734eff, 0xf37450ff, 0xf37651ff, 0xf37852ff, 0xf47a54ff, 0xf47c55ff, 0xf47d57ff, 0xf47f58ff, 0xf4815aff, 0xf4835bff, 0xf4845dff, 0xf4865eff, 0xf58860ff, 0xf58a61ff, 0xf58b63ff, 0xf58d64ff, 0xf58f66ff, 0xf59067ff, 0xf59269ff, 0xf5946bff, 0xf5966cff, 0xf5976eff, 0xf59970ff, 0xf69b71ff, 0xf69c73ff, 0xf69e75ff, 0xf6a077ff, 0xf6a178ff, 0xf6a37aff, 0xf6a47cff, 0xf6a67eff, 0xf6a880ff, 0xf6a981ff, 0xf6ab83ff, 0xf6ad85ff, 0xf6ae87ff, 0xf6b089ff, 0xf6b18bff, 0xf6b38dff, 0xf6b48fff, 0xf6b691ff, 0xf6b893ff, 0xf6b995ff, 0xf6bb97ff, 0xf6bc99ff, 0xf6be9bff, 0xf6bf9dff, 0xf6c19fff, 0xf7c2a2ff, 0xf7c4a4ff, 0xf7c6a6ff, 0xf7c7a8ff, 0xf7c9aaff, 0xf7caacff, 0xf7ccafff, 0xf7cdb1ff, 0xf7cfb3ff, 0xf7d0b5ff, 0xf8d1b8ff, 0xf8d3baff, 0xf8d4bcff, 0xf8d6beff, 0xf8d7c0ff, 0xf8d9c3ff, 0xf8dac5ff, 0xf8dcc7ff, 0xf9ddc9ff, 0xf9dfcbff, 0xf9e0cdff, 0xf9e2d0ff, 0xf9e3d2ff, 0xf9e5d4ff, 0xfae6d6ff, 0xfae8d8ff, 0xfae9daff, 0xfaebddff);
+preset!(mako; 0xb0405ff, 0xd0406ff, 0xe0508ff, 0xf0609ff, 0x10060aff, 0x11070cff, 0x12080dff, 0x13090fff, 0x140910ff, 0x150a12ff, 0x160b13ff, 0x170c15ff, 0x180d16ff, 0x190e18ff, 0x1a0e19ff, 0x1b0f1aff, 0x1c101cff, 0x1d111dff, 0x1e111fff, 0x1f1220ff, 0x201322ff, 0x211423ff, 0x221425ff, 0x231526ff, 0x241628ff, 0x251729ff, 0x26172bff, 0x27182dff, 0x28192eff, 0x291930ff, 0x291a31ff, 0x2a1b33ff, 0x2b1c35ff, 0x2c1c36ff, 0x2d1d38ff, 0x2e1e39ff, 0x2e1e3bff, 0x2f1f3dff, 0x30203eff, 0x312140ff, 0x312142ff, 0x322243ff, 0x332345ff, 0x342447ff, 0x342548ff, 0x35254aff, 0x35264cff, 0x36274dff, 0x37284fff, 0x372851ff, 0x382953ff, 0x382a54ff, 0x392b56ff, 0x3a2c58ff, 0x3a2c59ff, 0x3b2d5bff, 0x3b2e5dff, 0x3b2f5fff, 0x3c3060ff, 0x3c3162ff, 0x3d3164ff, 0x3d3266ff, 0x3e3367ff, 0x3e3469ff, 0x3e356bff, 0x3f366dff, 0x3f366fff, 0x3f3770ff, 0x403872ff, 0x403974ff, 0x403a76ff, 0x403b78ff, 0x403c79ff, 0x413d7bff, 0x413e7dff, 0x413e7fff, 0x413f80ff, 0x414082ff, 0x414184ff, 0x414285ff, 0x414387ff, 0x414488ff, 0x40468aff, 0x40478bff, 0x40488dff, 0x40498eff, 0x3f4a8fff, 0x3f4b90ff, 0x3f4c92ff, 0x3e4d93ff, 0x3e4f94ff, 0x3e5095ff, 0x3d5195ff, 0x3d5296ff, 0x3c5397ff, 0x3c5598ff, 0x3b5698ff, 0x3b5799ff, 0x3b589aff, 0x3a599aff, 0x3a5b9bff, 0x3a5c9bff, 0x395d9cff, 0x395e9cff, 0x385f9cff, 0x38619dff, 0x38629dff, 0x38639dff, 0x37649eff, 0x37659eff, 0x37669eff, 0x37689fff, 0x36699fff, 0x366a9fff, 0x366b9fff, 0x366ca0ff, 0x366da0ff, 0x366fa0ff, 0x3670a0ff, 0x3671a0ff, 0x3572a1ff, 0x3573a1ff, 0x3574a1ff, 0x3575a1ff, 0x3576a2ff, 0x3578a2ff, 0x3579a2ff, 0x357aa2ff, 0x357ba3ff, 0x357ca3ff, 0x357da3ff, 0x357ea4ff, 0x347fa4ff, 0x3480a4ff, 0x3482a4ff, 0x3483a5ff, 0x3484a5ff, 0x3485a5ff, 0x3486a5ff, 0x3487a6ff, 0x3488a6ff, 0x3489a6ff, 0x348ba6ff, 0x348ca7ff, 0x348da7ff, 0x348ea7ff, 0x348fa7ff, 0x3490a8ff, 0x3491a8ff, 0x3492a8ff, 0x3493a8ff, 0x3495a9ff, 0x3496a9ff, 0x3497a9ff, 0x3498a9ff, 0x3499aaff, 0x349aaaff, 0x359baaff, 0x359caaff, 0x359eaaff, 0x359fabff, 0x35a0abff, 0x35a1abff, 0x36a2abff, 0x36a3abff, 0x36a4abff, 0x37a5acff, 0x37a6acff, 0x37a8acff, 0x38a9acff, 0x38aaacff, 0x39abacff, 0x39acacff, 0x3aadacff, 0x3aaeadff, 0x3bafadff, 0x3cb1adff, 0x3cb2adff, 0x3db3adff, 0x3eb4adff, 0x3fb5adff, 0x3fb6adff, 0x40b7adff, 0x41b8adff, 0x42b9adff, 0x43baadff, 0x44bcadff, 0x45bdadff, 0x46beadff, 0x47bfadff, 0x48c0adff, 0x49c1adff, 0x4bc2adff, 0x4cc3adff, 0x4dc4adff, 0x4fc5adff, 0x50c6adff, 0x52c7adff, 0x53c9adff, 0x55caadff, 0x57cbadff, 0x59ccadff, 0x5bcdadff, 0x5ecdadff, 0x60ceacff, 0x62cfacff, 0x65d0adff, 0x68d1adff, 0x6ad2adff, 0x6dd3adff, 0x70d4adff, 0x73d4adff, 0x76d5aeff, 0x79d6aeff, 0x7cd6afff, 0x7fd7afff, 0x82d8b0ff, 0x85d9b1ff, 0x88d9b1ff, 0x8bdab2ff, 0x8edbb3ff, 0x91dbb4ff, 0x94dcb5ff, 0x96ddb5ff, 0x99ddb6ff, 0x9cdeb7ff, 0x9edfb8ff, 0xa1dfb9ff, 0xa4e0bbff, 0xa6e1bcff, 0xa9e1bdff, 0xabe2beff, 0xaee3c0ff, 0xb0e4c1ff, 0xb2e4c2ff, 0xb5e5c4ff, 0xb7e6c5ff, 0xb9e6c7ff, 0xbbe7c8ff, 0xbee8caff, 0xc0e9ccff, 0xc2e9cdff, 0xc4eacfff, 0xc6ebd1ff, 0xc8ecd2ff, 0xcaedd4ff, 0xccedd6ff, 0xceeed7ff, 0xd0efd9ff, 0xd2f0dbff, 0xd4f1dcff, 0xd6f1deff, 0xd8f2e0ff, 0xdaf3e1ff, 0xdcf4e3ff, 0xdef5e5ff);
+preset!(vlag; 0x2369bdff, 0x266abdff, 0x296cbcff, 0x2c6dbcff, 0x2f6ebcff, 0x316fbcff, 0x3470bcff, 0x3671bcff, 0x3972bcff, 0x3b73bcff, 0x3d74bcff, 0x3f75bcff, 0x4276bcff, 0x4477bcff, 0x4678bcff, 0x4879bcff, 0x4a7bbcff, 0x4c7cbcff, 0x4e7dbcff, 0x507ebcff, 0x517fbcff, 0x5380bcff, 0x5581bcff, 0x5782bcff, 0x5983bdff, 0x5b84bdff, 0x5c85bdff, 0x5e86bdff, 0x6087bdff, 0x6288bdff, 0x6489beff, 0x658abeff, 0x678bbeff, 0x698cbeff, 0x6a8dbfff, 0x6c8ebfff, 0x6e90bfff, 0x6f91bfff, 0x7192c0ff, 0x7393c0ff, 0x7594c0ff, 0x7695c1ff, 0x7896c1ff, 0x7997c1ff, 0x7b98c2ff, 0x7d99c2ff, 0x7e9ac2ff, 0x809bc3ff, 0x829cc3ff, 0x839dc4ff, 0x859ec4ff, 0x87a0c4ff, 0x88a1c5ff, 0x8aa2c5ff, 0x8ba3c6ff, 0x8da4c6ff, 0x8fa5c7ff, 0x90a6c7ff, 0x92a7c8ff, 0x93a8c8ff, 0x95a9c8ff, 0x97abc9ff, 0x98acc9ff, 0x9aadcaff, 0x9baecbff, 0x9dafcbff, 0x9fb0ccff, 0xa0b1ccff, 0xa2b2cdff, 0xa3b4cdff, 0xa5b5ceff, 0xa7b6ceff, 0xa8b7cfff, 0xaab8d0ff, 0xabb9d0ff, 0xadbbd1ff, 0xafbcd1ff, 0xb0bdd2ff, 0xb2bed3ff, 0xb3bfd3ff, 0xb5c0d4ff, 0xb7c2d5ff, 0xb8c3d5ff, 0xbac4d6ff, 0xbbc5d7ff, 0xbdc6d7ff, 0xbfc8d8ff, 0xc0c9d9ff, 0xc2cadaff, 0xc3cbdaff, 0xc5cddbff, 0xc7cedcff, 0xc8cfddff, 0xcad0ddff, 0xcbd1deff, 0xcdd3dfff, 0xcfd4e0ff, 0xd0d5e0ff, 0xd2d7e1ff, 0xd4d8e2ff, 0xd5d9e3ff, 0xd7dae4ff, 0xd9dce5ff, 0xdadde5ff, 0xdcdee6ff, 0xdde0e7ff, 0xdfe1e8ff, 0xe1e2e9ff, 0xe2e3eaff, 0xe4e5ebff, 0xe6e6ecff, 0xe7e7ecff, 0xe9e9edff, 0xebeaeeff, 0xecebefff, 0xeeedf0ff, 0xefeef1ff, 0xf1eff2ff, 0xf2f0f2ff, 0xf3f1f3ff, 0xf5f2f4ff, 0xf6f3f4ff, 0xf7f4f4ff, 0xf8f4f5ff, 0xf9f5f5ff, 0xf9f5f5ff, 0xfaf5f5ff, 0xfaf5f5ff, 0xfaf5f4ff, 0xfaf5f4ff, 0xfaf4f3ff, 0xfaf3f3ff, 0xfaf3f2ff, 0xfaf2f1ff, 0xfaf0efff, 0xf9efeeff, 0xf9eeedff, 0xf8edebff, 0xf7ebeaff, 0xf7eae8ff, 0xf6e8e7ff, 0xf5e7e5ff, 0xf5e5e4ff, 0xf4e3e2ff, 0xf3e2e0ff, 0xf2e0dfff, 0xf2dfddff, 0xf1dddbff, 0xf0dbdaff, 0xefdad8ff, 0xefd8d6ff, 0xeed7d5ff, 0xedd5d3ff, 0xecd3d2ff, 0xecd2d0ff, 0xebd0ceff, 0xeacfcdff, 0xeacdcbff, 0xe9cbc9ff, 0xe8cac8ff, 0xe7c8c6ff, 0xe7c7c5ff, 0xe6c5c3ff, 0xe5c3c1ff, 0xe5c2c0ff, 0xe4c0beff, 0xe3bfbdff, 0xe3bdbbff, 0xe2bcb9ff, 0xe1bab8ff, 0xe1b9b6ff, 0xe0b7b5ff, 0xdfb5b3ff, 0xdfb4b2ff, 0xdeb2b0ff, 0xdeb1aeff, 0xddafadff, 0xdcaeabff, 0xdcacaaff, 0xdbaba8ff, 0xdaa9a7ff, 0xdaa8a5ff, 0xd9a6a4ff, 0xd9a5a2ff, 0xd8a3a0ff, 0xd7a29fff, 0xd7a09dff, 0xd69f9cff, 0xd59d9aff, 0xd59c99ff, 0xd49a97ff, 0xd49896ff, 0xd39794ff, 0xd29593ff, 0xd29491ff, 0xd19290ff, 0xd1918eff, 0xd08f8dff, 0xcf8e8bff, 0xcf8c8aff, 0xce8b88ff, 0xcd8987ff, 0xcd8885ff, 0xcc8784ff, 0xcc8582ff, 0xcb8481ff, 0xca827fff, 0xca817eff, 0xc97f7dff, 0xc87e7bff, 0xc87c7aff, 0xc77b78ff, 0xc77977ff, 0xc67875ff, 0xc57674ff, 0xc57572ff, 0xc47371ff, 0xc3726fff, 0xc3706eff, 0xc26f6dff, 0xc16d6bff, 0xc16c6aff, 0xc06a68ff, 0xc06967ff, 0xbf6765ff, 0xbe6664ff, 0xbe6463ff, 0xbd6361ff, 0xbc6160ff, 0xbc605eff, 0xbb5e5dff, 0xba5d5cff, 0xb95b5aff, 0xb95a59ff, 0xb85857ff, 0xb75756ff, 0xb75555ff, 0xb65453ff, 0xb55252ff, 0xb55151ff, 0xb44f4fff, 0xb34d4eff, 0xb24c4cff, 0xb24a4bff, 0xb1494aff, 0xb04748ff, 0xaf4647ff, 0xaf4446ff, 0xae4244ff, 0xad4143ff, 0xac3f42ff, 0xac3e40ff, 0xab3c3fff, 0xaa3a3eff, 0xa9393cff, 0xa9373bff);
+preset!(icefire; 0xbde7dbff, 0xbae5daff, 0xb7e3d9ff, 0xb4e1d9ff, 0xb2dfd8ff, 0xafddd7ff, 0xacdbd7ff, 0xa9d9d6ff, 0xa7d7d5ff, 0xa4d5d5ff, 0xa1d3d4ff, 0x9ed1d3ff, 0x9bcfd3ff, 0x98cdd2ff, 0x95cbd2ff, 0x93cad1ff, 0x90c8d1ff, 0x8dc6d0ff, 0x8ac4d0ff, 0x87c2cfff, 0x84c1cfff, 0x81bfcfff, 0x7ebdceff, 0x7bbbceff, 0x78b9ceff, 0x75b8ceff, 0x72b6ceff, 0x6eb4cdff, 0x6bb2cdff, 0x68b0cdff, 0x65afcdff, 0x63adcdff, 0x60abcdff, 0x5da9cdff, 0x5aa7cdff, 0x58a5cdff, 0x55a3cdff, 0x53a2cdff, 0x50a0cdff, 0x4e9ecdff, 0x4c9ccdff, 0x499aceff, 0x4798ceff, 0x4596ceff, 0x4394ceff, 0x4192ceff, 0x3f90ceff, 0x3e8ecfff, 0x3c8ccfff, 0x3a89cfff, 0x3987cfff, 0x3885d0ff, 0x3783d0ff, 0x3781d0ff, 0x377fd0ff, 0x377cd0ff, 0x377ad0ff, 0x3878cfff, 0x3975cfff, 0x3a73ceff, 0x3b71cdff, 0x3d6eccff, 0x3e6ccbff, 0x3f69c9ff, 0x4167c7ff, 0x4265c5ff, 0x4363c3ff, 0x4560c1ff, 0x465ebeff, 0x475cbcff, 0x475ab9ff, 0x4858b6ff, 0x4956b3ff, 0x4954b0ff, 0x4952adff, 0x4a50a9ff, 0x4a4fa5ff, 0x494da1ff, 0x494c9eff, 0x494a9aff, 0x484996ff, 0x474792ff, 0x47468eff, 0x46458aff, 0x454386ff, 0x444282ff, 0x43417fff, 0x42407bff, 0x413e77ff, 0x3f3d74ff, 0x3e3c70ff, 0x3d3b6dff, 0x3c3a69ff, 0x3b3866ff, 0x393763ff, 0x38365fff, 0x37355cff, 0x363459ff, 0x343356ff, 0x333153ff, 0x323050ff, 0x312f4dff, 0x302e4aff, 0x2e2d48ff, 0x2d2c45ff, 0x2c2b42ff, 0x2b2a40ff, 0x2a293dff, 0x29283bff, 0x282739ff, 0x272636ff, 0x262534ff, 0x252532ff, 0x242430ff, 0x24232eff, 0x23222dff, 0x22222bff, 0x222129ff, 0x212028ff, 0x212026ff, 0x202025ff, 0x201f24ff, 0x1f1f23ff, 0x1f1f21ff, 0x1f1e21ff, 0x1f1e20ff, 0x1f1e1fff, 0x1f1e1eff, 0x1f1e1eff, 0x201e1eff, 0x211e1eff, 0x221e1eff, 0x231e1eff, 0x251e1fff, 0x261e1fff, 0x271e1fff, 0x291e20ff, 0x2a1e20ff, 0x2c1e21ff, 0x2d1f21ff, 0x2f1f22ff, 0x311f23ff, 0x332023ff, 0x352024ff, 0x372025ff, 0x392126ff, 0x3b2127ff, 0x3d2228ff, 0x3f2228ff, 0x412329ff, 0x43232aff, 0x46242bff, 0x48242cff, 0x4a252eff, 0x4d252fff, 0x4f2630ff, 0x522731ff, 0x542732ff, 0x572833ff, 0x5a2834ff, 0x5c2935ff, 0x5f2936ff, 0x622937ff, 0x642a38ff, 0x672a39ff, 0x6a2b3aff, 0x6d2b3bff, 0x702b3cff, 0x722c3dff, 0x752c3eff, 0x782c3fff, 0x7b2d40ff, 0x7e2d40ff, 0x812d41ff, 0x842d42ff, 0x872d42ff, 0x8a2e43ff, 0x8d2e43ff, 0x902e44ff, 0x932e44ff, 0x962e44ff, 0x992e44ff, 0x9c2f45ff, 0x9f2f44ff, 0xa22f44ff, 0xa52f44ff, 0xa83044ff, 0xab3043ff, 0xae3143ff, 0xb13242ff, 0xb33341ff, 0xb63441ff, 0xb93540ff, 0xbb363fff, 0xbe373eff, 0xc0393dff, 0xc33a3cff, 0xc53c3cff, 0xc73d3bff, 0xc93f3aff, 0xcc4139ff, 0xce4338ff, 0xd04537ff, 0xd24737ff, 0xd34936ff, 0xd54b35ff, 0xd74e35ff, 0xd95034ff, 0xda5334ff, 0xdc5534ff, 0xde5733ff, 0xdf5a33ff, 0xe15c33ff, 0xe25f33ff, 0xe36233ff, 0xe56433ff, 0xe66734ff, 0xe76a34ff, 0xe86d35ff, 0xe96f36ff, 0xea7238ff, 0xeb753aff, 0xec783bff, 0xed7b3eff, 0xed7e40ff, 0xee8142ff, 0xef8445ff, 0xef8748ff, 0xf0894bff, 0xf18c4eff, 0xf18f51ff, 0xf29255ff, 0xf29558ff, 0xf3985bff, 0xf39a5fff, 0xf49d63ff, 0xf5a066ff, 0xf5a36aff, 0xf6a56dff, 0xf6a871ff, 0xf7ab75ff, 0xf7ae79ff, 0xf8b07cff, 0xf8b380ff, 0xf9b684ff, 0xfab887ff, 0xfabb8bff, 0xfbbe8fff, 0xfbc192ff, 0xfcc396ff, 0xfcc69aff, 0xfdc99eff, 0xfdcca1ff, 0xfecea5ff, 0xfed1a9ff, 0xffd4acff);
+preset!(flare; 0xedb081ff, 0xedaf80ff, 0xedae7fff, 0xedad7fff, 0xedac7eff, 0xedab7eff, 0xecaa7dff, 0xeca97cff, 0xeca87cff, 0xeca77bff, 0xeca67bff, 0xeca57aff, 0xeca479ff, 0xeca379ff, 0xeca278ff, 0xeca178ff, 0xeca077ff, 0xec9f76ff, 0xeb9e76ff, 0xeb9d75ff, 0xeb9c75ff, 0xeb9b74ff, 0xeb9a73ff, 0xeb9973ff, 0xeb9972ff, 0xeb9872ff, 0xeb9771ff, 0xea9671ff, 0xea9570ff, 0xea946fff, 0xea936fff, 0xea926eff, 0xea916eff, 0xea906dff, 0xea8f6cff, 0xea8e6cff, 0xe98d6bff, 0xe98c6bff, 0xe98b6aff, 0xe98a6aff, 0xe98969ff, 0xe98868ff, 0xe98768ff, 0xe98667ff, 0xe88567ff, 0xe88466ff, 0xe88366ff, 0xe88265ff, 0xe88165ff, 0xe88064ff, 0xe87f64ff, 0xe77e63ff, 0xe77d63ff, 0xe77c63ff, 0xe77b62ff, 0xe77a62ff, 0xe67961ff, 0xe67861ff, 0xe67760ff, 0xe67660ff, 0xe67560ff, 0xe5745fff, 0xe5735fff, 0xe5725fff, 0xe5715eff, 0xe5705eff, 0xe46f5eff, 0xe46e5eff, 0xe46d5dff, 0xe46c5dff, 0xe36b5dff, 0xe36a5dff, 0xe3695dff, 0xe3685cff, 0xe2675cff, 0xe2665cff, 0xe2655cff, 0xe1645cff, 0xe1635cff, 0xe1625cff, 0xe0615cff, 0xe0605cff, 0xe05f5cff, 0xdf5f5cff, 0xdf5e5cff, 0xde5d5cff, 0xde5c5cff, 0xde5b5cff, 0xdd5a5cff, 0xdd595cff, 0xdc585cff, 0xdc575cff, 0xdb565dff, 0xdb565dff, 0xda555dff, 0xda545dff, 0xd9535dff, 0xd9525eff, 0xd8525eff, 0xd7515eff, 0xd7505eff, 0xd64f5fff, 0xd64f5fff, 0xd54e5fff, 0xd44d60ff, 0xd44c60ff, 0xd34c60ff, 0xd24b60ff, 0xd24a61ff, 0xd14a61ff, 0xd04962ff, 0xd04962ff, 0xcf4862ff, 0xce4763ff, 0xcd4763ff, 0xcc4663ff, 0xcc4664ff, 0xcb4564ff, 0xca4564ff, 0xc94465ff, 0xc84465ff, 0xc84365ff, 0xc74366ff, 0xc64366ff, 0xc54266ff, 0xc44267ff, 0xc34167ff, 0xc24167ff, 0xc14168ff, 0xc14068ff, 0xc04068ff, 0xbf4069ff, 0xbe3f69ff, 0xbd3f69ff, 0xbc3f69ff, 0xbb3f6aff, 0xba3e6aff, 0xb93e6aff, 0xb83e6bff, 0xb73d6bff, 0xb63d6bff, 0xb53d6bff, 0xb43d6bff, 0xb33c6cff, 0xb23c6cff, 0xb13c6cff, 0xb13c6cff, 0xb03b6dff, 0xaf3b6dff, 0xae3b6dff, 0xad3b6dff, 0xac3a6dff, 0xab3a6dff, 0xaa3a6eff, 0xa93a6eff, 0xa8396eff, 0xa7396eff, 0xa6396eff, 0xa5396eff, 0xa4386fff, 0xa3386fff, 0xa2386fff, 0xa1386fff, 0xa1376fff, 0xa0376fff, 0x9f376fff, 0x9e3770ff, 0x9d3670ff, 0x9c3670ff, 0x9b3670ff, 0x9a3670ff, 0x993570ff, 0x983570ff, 0x973570ff, 0x963570ff, 0x953470ff, 0x943470ff, 0x943471ff, 0x933471ff, 0x923371ff, 0x913371ff, 0x903371ff, 0x8f3371ff, 0x8e3271ff, 0x8d3271ff, 0x8c3271ff, 0x8b3271ff, 0x8a3171ff, 0x893171ff, 0x883171ff, 0x873171ff, 0x873171ff, 0x863071ff, 0x853071ff, 0x843071ff, 0x833070ff, 0x822f70ff, 0x812f70ff, 0x802f70ff, 0x7f2f70ff, 0x7e2f70ff, 0x7d2e70ff, 0x7c2e70ff, 0x7b2e70ff, 0x7a2e70ff, 0x792e6fff, 0x782e6fff, 0x772d6fff, 0x762d6fff, 0x752d6fff, 0x752d6fff, 0x742d6eff, 0x732c6eff, 0x722c6eff, 0x712c6eff, 0x702c6eff, 0x6f2c6dff, 0x6e2c6dff, 0x6d2b6dff, 0x6c2b6dff, 0x6b2b6cff, 0x6a2b6cff, 0x692b6cff, 0x682a6cff, 0x672a6bff, 0x662a6bff, 0x652a6bff, 0x642a6aff, 0x642a6aff, 0x63296aff, 0x62296aff, 0x612969ff, 0x602969ff, 0x5f2969ff, 0x5e2868ff, 0x5d2868ff, 0x5c2868ff, 0x5b2867ff, 0x5a2767ff, 0x592767ff, 0x582766ff, 0x582766ff, 0x572766ff, 0x562666ff, 0x552665ff, 0x542665ff, 0x532665ff, 0x522564ff, 0x512564ff, 0x502564ff, 0x4f2463ff, 0x4f2463ff, 0x4e2463ff, 0x4d2463ff, 0x4c2362ff, 0x4b2362ff);
+preset!(crest; 0xa5cd90ff, 0xa4cc90ff, 0xa3cc91ff, 0xa2cb91ff, 0xa0cb91ff, 0x9fca91ff, 0x9eca91ff, 0x9dc991ff, 0x9cc891ff, 0x9bc891ff, 0x9ac791ff, 0x99c791ff, 0x98c691ff, 0x96c691ff, 0x95c591ff, 0x94c591ff, 0x93c491ff, 0x92c491ff, 0x91c391ff, 0x90c391ff, 0x8fc291ff, 0x8ec291ff, 0x8dc191ff, 0x8bc191ff, 0x8ac091ff, 0x89bf91ff, 0x88bf91ff, 0x87be91ff, 0x86be91ff, 0x85bd91ff, 0x84bd91ff, 0x82bc91ff, 0x81bc91ff, 0x80bb91ff, 0x7fbb91ff, 0x7eba91ff, 0x7dba91ff, 0x7cb991ff, 0x7bb991ff, 0x79b891ff, 0x78b891ff, 0x77b791ff, 0x76b791ff, 0x75b690ff, 0x74b690ff, 0x73b590ff, 0x72b490ff, 0x71b490ff, 0x70b390ff, 0x6fb390ff, 0x6eb290ff, 0x6db290ff, 0x6cb190ff, 0x6bb190ff, 0x6ab090ff, 0x69b090ff, 0x68af90ff, 0x67ae90ff, 0x66ae90ff, 0x65ad90ff, 0x64ad90ff, 0x63ac90ff, 0x62ac90ff, 0x62ab90ff, 0x61aa90ff, 0x60aa90ff, 0x5fa990ff, 0x5ea990ff, 0x5da890ff, 0x5ca890ff, 0x5ba790ff, 0x5ba690ff, 0x5aa690ff, 0x59a590ff, 0x58a590ff, 0x57a490ff, 0x57a490ff, 0x56a390ff, 0x55a290ff, 0x54a290ff, 0x53a190ff, 0x53a190ff, 0x52a090ff, 0x519f90ff, 0x509f90ff, 0x509e90ff, 0x4f9e90ff, 0x4e9d90ff, 0x4e9d90ff, 0x4d9c90ff, 0x4c9b90ff, 0x4b9b90ff, 0x4b9a8fff, 0x4a9a8fff, 0x49998fff, 0x49988fff, 0x48988fff, 0x47978fff, 0x47978fff, 0x46968fff, 0x45958fff, 0x45958fff, 0x44948fff, 0x43948fff, 0x43938fff, 0x42928fff, 0x41928fff, 0x41918fff, 0x40918fff, 0x40908eff, 0x3f8f8eff, 0x3e8f8eff, 0x3e8e8eff, 0x3d8e8eff, 0x3c8d8eff, 0x3c8c8eff, 0x3b8c8eff, 0x3a8b8eff, 0x3a8b8eff, 0x398a8eff, 0x388a8eff, 0x38898eff, 0x37888eff, 0x37888dff, 0x36878dff, 0x35878dff, 0x35868dff, 0x34858dff, 0x33858dff, 0x33848dff, 0x32848dff, 0x31838dff, 0x31828dff, 0x30828dff, 0x2f818dff, 0x2f818dff, 0x2e808dff, 0x2d808cff, 0x2d7f8cff, 0x2c7e8cff, 0x2c7e8cff, 0x2b7d8cff, 0x2a7d8cff, 0x2a7c8cff, 0x297b8cff, 0x287b8cff, 0x287a8cff, 0x277a8cff, 0x27798cff, 0x26788cff, 0x25788cff, 0x25778cff, 0x24778bff, 0x24768bff, 0x23758bff, 0x23758bff, 0x22748bff, 0x22748bff, 0x21738bff, 0x21728bff, 0x20728bff, 0x20718bff, 0x20718bff, 0x1f708bff, 0x1f6f8aff, 0x1e6f8aff, 0x1e6e8aff, 0x1e6d8aff, 0x1e6d8aff, 0x1d6c8aff, 0x1d6c8aff, 0x1d6b8aff, 0x1d6a8aff, 0x1d6a8aff, 0x1c6989ff, 0x1c6889ff, 0x1c6889ff, 0x1c6789ff, 0x1c6689ff, 0x1c6689ff, 0x1c6589ff, 0x1c6488ff, 0x1c6488ff, 0x1c6388ff, 0x1d6388ff, 0x1d6288ff, 0x1d6188ff, 0x1d6187ff, 0x1d6087ff, 0x1d5f87ff, 0x1d5f87ff, 0x1e5e87ff, 0x1e5d86ff, 0x1e5d86ff, 0x1e5c86ff, 0x1e5b86ff, 0x1f5b86ff, 0x1f5a85ff, 0x1f5985ff, 0x1f5985ff, 0x205885ff, 0x205784ff, 0x205784ff, 0x205684ff, 0x215584ff, 0x215583ff, 0x215483ff, 0x225383ff, 0x225283ff, 0x225282ff, 0x225182ff, 0x235082ff, 0x235081ff, 0x234f81ff, 0x244e81ff, 0x244e80ff, 0x244d80ff, 0x254c80ff, 0x254c7fff, 0x254b7fff, 0x254a7fff, 0x26497eff, 0x26497eff, 0x26487eff, 0x27477dff, 0x27477dff, 0x27467cff, 0x27457cff, 0x28457cff, 0x28447bff, 0x28437bff, 0x28427aff, 0x29427aff, 0x29417aff, 0x294079ff, 0x294079ff, 0x2a3f78ff, 0x2a3e78ff, 0x2a3d78ff, 0x2a3d77ff, 0x2a3c77ff, 0x2a3b76ff, 0x2b3b76ff, 0x2b3a76ff, 0x2b3975ff, 0x2b3875ff, 0x2b3875ff, 0x2b3774ff, 0x2b3674ff, 0x2c3574ff, 0x2c3573ff, 0x2c3473ff, 0x2c3373ff, 0x2c3272ff, 0x2c3172ff, 0x2c3172ff);
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_color_strings() {
+ #[track_caller]
+ fn test(hex: &str, r: u8, g: u8, b: u8, a: u8) {
+ assert_eq!(Color::from_str(hex), Ok(Color::from_u8(r, g, b, a)));
+ }
+
+ test("f61243ff", 0xf6, 0x12, 0x43, 255);
+ test("b3d8b3", 0xb3, 0xd8, 0xb3, 255);
+ test("fCd2a9AD", 0xfc, 0xd2, 0xa9, 0xad);
+ test("233", 0x22, 0x33, 0x33, 255);
+ test("111b", 0x11, 0x11, 0x11, 0xbb);
+ }
+
+ #[test]
+ fn test_parse_invalid_colors() {
+ #[track_caller]
+ fn test(hex: &str, message: &str) {
+ assert_eq!(Color::from_str(hex), Err(message));
+ }
+
+ test("a5", "color string has wrong length");
+ test("12345", "color string has wrong length");
+ test("f075ff011", "color string has wrong length");
+ test("hmmm", "color string contains non-hexadecimal letters");
+ test("14B2AH", "color string contains non-hexadecimal letters");
+ }
+}
diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs
new file mode 100644
index 00000000..2be7e370
--- /dev/null
+++ b/crates/typst-library/src/visualize/gradient.rs
@@ -0,0 +1,1260 @@
+use std::f64::consts::{FRAC_PI_2, PI, TAU};
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+use std::sync::Arc;
+
+use ecow::EcoString;
+use kurbo::Vec2;
+use typst_syntax::{Span, Spanned};
+
+use crate::diag::{bail, SourceResult};
+use crate::foundations::{
+ array, cast, func, scope, ty, Args, Array, Cast, Func, IntoValue, Repr, Smart,
+};
+use crate::layout::{Angle, Axes, Dir, Quadrant, Ratio};
+use crate::visualize::{Color, ColorSpace, WeightedColor};
+
+/// A color gradient.
+///
+/// Typst supports linear gradients through the
+/// [`gradient.linear` function]($gradient.linear), radial gradients through
+/// the [`gradient.radial` function]($gradient.radial), and conic gradients
+/// through the [`gradient.conic` function]($gradient.conic).
+///
+/// A gradient can be used for the following purposes:
+/// - As a fill to paint the interior of a shape:
+/// `{rect(fill: gradient.linear(..))}`
+/// - As a stroke to paint the outline of a shape:
+/// `{rect(stroke: 1pt + gradient.linear(..))}`
+/// - As the fill of text:
+/// `{set text(fill: gradient.linear(..))}`
+/// - As a color map you can [sample]($gradient.sample) from:
+/// `{gradient.linear(..).sample(50%)}`
+///
+/// # Examples
+/// ```example
+/// >>> #set square(size: 50pt)
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// square(fill: gradient.linear(..color.map.rainbow)),
+/// square(fill: gradient.radial(..color.map.rainbow)),
+/// square(fill: gradient.conic(..color.map.rainbow)),
+/// )
+/// ```
+///
+/// Gradients are also supported on text, but only when setting the
+/// [relativeness]($gradient.relative) to either `{auto}` (the default value) or
+/// `{"parent"}`. To create word-by-word or glyph-by-glyph gradients, you can
+/// wrap the words or characters of your text in [boxes]($box) manually or
+/// through a [show rule]($styling/#show-rules).
+///
+/// ```example
+/// >>> #set page(width: auto, height: auto, margin: 12pt)
+/// >>> #set text(size: 12pt)
+/// #set text(fill: gradient.linear(red, blue))
+/// #let rainbow(content) = {
+/// set text(fill: gradient.linear(..color.map.rainbow))
+/// box(content)
+/// }
+///
+/// This is a gradient on text, but with a #rainbow[twist]!
+/// ```
+///
+/// # Stops
+/// A gradient is composed of a series of stops. Each of these stops has a color
+/// and an offset. The offset is a [ratio]($ratio) between `{0%}` and `{100%}` or
+/// an angle between `{0deg}` and `{360deg}`. The offset is a relative position
+/// that determines how far along the gradient the stop is located. The stop's
+/// color is the color of the gradient at that position. You can choose to omit
+/// the offsets when defining a gradient. In this case, Typst will space all
+/// stops evenly.
+///
+/// # Relativeness
+/// The location of the `{0%}` and `{100%}` stops depends on the dimensions
+/// of a container. This container can either be the shape that it is being
+/// painted on, or the closest surrounding container. This is controlled by the
+/// `relative` argument of a gradient constructor. By default, gradients are
+/// relative to the shape they are being painted on, unless the gradient is
+/// applied on text, in which case they are relative to the closest ancestor
+/// container.
+///
+/// Typst determines the ancestor container as follows:
+/// - For shapes that are placed at the root/top level of the document, the
+/// closest ancestor is the page itself.
+/// - For other shapes, the ancestor is the innermost [`block`] or [`box`] that
+/// contains the shape. This includes the boxes and blocks that are implicitly
+/// created by show rules and elements. For example, a [`rotate`] will not
+/// affect the parent of a gradient, but a [`grid`] will.
+///
+/// # Color spaces and interpolation
+/// Gradients can be interpolated in any color space. By default, gradients are
+/// interpolated in the [Oklab]($color.oklab) color space, which is a
+/// [perceptually uniform](https://programmingdesignsystems.com/color/perceptually-uniform-color-spaces/index.html)
+/// color space. This means that the gradient will be perceived as having a
+/// smooth progression of colors. This is particularly useful for data
+/// visualization.
+///
+/// However, you can choose to interpolate the gradient in any supported color
+/// space you want, but beware that some color spaces are not suitable for
+/// perceptually interpolating between colors. Consult the table below when
+/// choosing an interpolation space.
+///
+/// | Color space | Perceptually uniform? |
+/// | ------------------------------- |-----------------------|
+/// | [Oklab]($color.oklab) | *Yes* |
+/// | [Oklch]($color.oklch) | *Yes* |
+/// | [sRGB]($color.rgb) | *No* |
+/// | [linear-RGB]($color.linear-rgb) | *Yes* |
+/// | [CMYK]($color.cmyk) | *No* |
+/// | [Grayscale]($color.luma) | *Yes* |
+/// | [HSL]($color.hsl) | *No* |
+/// | [HSV]($color.hsv) | *No* |
+///
+/// ```preview
+/// >>> #set text(fill: white, font: "IBM Plex Sans", 8pt)
+/// >>> #set block(spacing: 0pt)
+/// #let spaces = (
+/// ("Oklab", color.oklab),
+/// ("Oklch", color.oklch),
+/// ("linear-RGB", color.linear-rgb),
+/// ("sRGB", color.rgb),
+/// ("CMYK", color.cmyk),
+/// ("HSL", color.hsl),
+/// ("HSV", color.hsv),
+/// ("Grayscale", color.luma),
+/// )
+///
+/// #for (name, space) in spaces {
+/// block(
+/// width: 100%,
+/// inset: 4pt,
+/// fill: gradient.linear(
+/// red,
+/// blue,
+/// space: space,
+/// ),
+/// strong(upper(name)),
+/// )
+/// }
+/// ```
+///
+/// # Direction
+/// Some gradients are sensitive to direction. For example, a linear gradient
+/// has an angle that determines its direction. Typst uses a clockwise angle,
+/// with 0° being from left to right, 90° from top to bottom, 180° from right to
+/// left, and 270° from bottom to top.
+///
+/// ```example
+/// >>> #set square(size: 50pt)
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// square(fill: gradient.linear(red, blue, angle: 0deg)),
+/// square(fill: gradient.linear(red, blue, angle: 90deg)),
+/// square(fill: gradient.linear(red, blue, angle: 180deg)),
+/// square(fill: gradient.linear(red, blue, angle: 270deg)),
+/// )
+/// ```
+///
+/// # Presets
+/// Typst predefines color maps that you can use with your gradients. See the
+/// [`color`]($color/#predefined-color-maps) documentation for more details.
+///
+/// # Note on file sizes
+///
+/// Gradients can be quite large, especially if they have many stops. This is
+/// because gradients are stored as a list of colors and offsets, which can
+/// take up a lot of space. If you are concerned about file sizes, you should
+/// consider the following:
+/// - SVG gradients are currently inefficiently encoded. This will be improved
+/// in the future.
+/// - PDF gradients in the [`color.oklab`]($color.oklab), [`color.hsv`]($color.hsv),
+/// [`color.hsl`]($color.hsl), and [`color.oklch`]($color.oklch) color spaces
+/// are stored as a list of [`color.rgb`]($color.rgb) colors with extra stops
+/// in between. This avoids needing to encode these color spaces in your PDF
+/// file, but it does add extra stops to your gradient, which can increase
+/// the file size.
+#[ty(scope, cast)]
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub enum Gradient {
+ Linear(Arc<LinearGradient>),
+ Radial(Arc<RadialGradient>),
+ Conic(Arc<ConicGradient>),
+}
+
+#[scope]
+#[allow(clippy::too_many_arguments)]
+impl Gradient {
+ /// Creates a new linear gradient, in which colors transition along a
+ /// straight line.
+ ///
+ /// ```example
+ /// #rect(
+ /// width: 100%,
+ /// height: 20pt,
+ /// fill: gradient.linear(
+ /// ..color.map.viridis,
+ /// ),
+ /// )
+ /// ```
+ #[func(title = "Linear Gradient")]
+ pub fn linear(
+ /// The args of this function.
+ args: &mut Args,
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// The color space in which to interpolate the gradient.
+ ///
+ /// Defaults to a perceptually uniform color space called
+ /// [Oklab]($color.oklab).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ /// The [relative placement](#relativeness) of the gradient.
+ ///
+ /// For an element placed at the root/top level of the document, the
+ /// parent is the page itself. For other elements, the parent is the
+ /// innermost block, box, column, grid, or stack that contains the
+ /// element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The direction of the gradient.
+ #[external]
+ #[default(Dir::LTR)]
+ dir: Dir,
+ /// The angle of the gradient.
+ #[external]
+ angle: Angle,
+ ) -> SourceResult<Gradient> {
+ let angle = if let Some(angle) = args.named::<Angle>("angle")? {
+ angle
+ } else if let Some(dir) = args.named::<Dir>("dir")? {
+ match dir {
+ Dir::LTR => Angle::rad(0.0),
+ Dir::RTL => Angle::rad(PI),
+ Dir::TTB => Angle::rad(FRAC_PI_2),
+ Dir::BTT => Angle::rad(3.0 * FRAC_PI_2),
+ }
+ } else {
+ Angle::rad(0.0)
+ };
+
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ Ok(Self::Linear(Arc::new(LinearGradient {
+ stops: process_stops(&stops)?,
+ angle,
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a new radial gradient, in which colors radiate away from an
+ /// origin.
+ ///
+ /// The gradient is defined by two circles: the focal circle and the end
+ /// circle. The focal circle is a circle with center `focal-center` and
+ /// radius `focal-radius`, that defines the points at which the gradient
+ /// starts and has the color of the first stop. The end circle is a circle
+ /// with center `center` and radius `radius`, that defines the points at
+ /// which the gradient ends and has the color of the last stop. The gradient
+ /// is then interpolated between these two circles.
+ ///
+ /// Using these four values, also called the focal point for the starting
+ /// circle and the center and radius for the end circle, we can define a
+ /// gradient with more interesting properties than a basic radial gradient.
+ ///
+ /// ```example
+ /// >>> #set circle(radius: 30pt)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// circle(fill: gradient.radial(
+ /// ..color.map.viridis,
+ /// )),
+ /// circle(fill: gradient.radial(
+ /// ..color.map.viridis,
+ /// focal-center: (10%, 40%),
+ /// focal-radius: 5%,
+ /// )),
+ /// )
+ /// ```
+ #[func]
+ fn radial(
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// The color space in which to interpolate the gradient.
+ ///
+ /// Defaults to a perceptually uniform color space called
+ /// [Oklab]($color.oklab).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ /// The [relative placement](#relativeness) of the gradient.
+ ///
+ /// For an element placed at the root/top level of the document, the parent
+ /// is the page itself. For other elements, the parent is the innermost block,
+ /// box, column, grid, or stack that contains the element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The center of the end circle of the gradient.
+ ///
+ /// A value of `{(50%, 50%)}` means that the end circle is
+ /// centered inside of its container.
+ #[named]
+ #[default(Axes::splat(Ratio::new(0.5)))]
+ center: Axes<Ratio>,
+ /// The radius of the end circle of the gradient.
+ ///
+ /// By default, it is set to `{50%}`. The ending radius must be bigger
+ /// than the focal radius.
+ #[named]
+ #[default(Spanned::new(Ratio::new(0.5), Span::detached()))]
+ radius: Spanned<Ratio>,
+ /// The center of the focal circle of the gradient.
+ ///
+ /// The focal center must be inside of the end circle.
+ ///
+ /// A value of `{(50%, 50%)}` means that the focal circle is
+ /// centered inside of its container.
+ ///
+ /// By default it is set to the same as the center of the last circle.
+ #[named]
+ #[default(Smart::Auto)]
+ focal_center: Smart<Axes<Ratio>>,
+ /// The radius of the focal circle of the gradient.
+ ///
+ /// The focal center must be inside of the end circle.
+ ///
+ /// By default, it is set to `{0%}`. The focal radius must be smaller
+ /// than the ending radius`.
+ #[named]
+ #[default(Spanned::new(Ratio::new(0.0), Span::detached()))]
+ focal_radius: Spanned<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ if focal_radius.v > radius.v {
+ bail!(
+ focal_radius.span,
+ "the focal radius must be smaller than the end radius";
+ hint: "try using a focal radius of `0%` instead"
+ );
+ }
+
+ let focal_center = focal_center.unwrap_or(center);
+ let d_center_sqr = (focal_center.x - center.x).get().powi(2)
+ + (focal_center.y - center.y).get().powi(2);
+ if d_center_sqr.sqrt() >= (radius.v - focal_radius.v).get() {
+ bail!(
+ span,
+ "the focal circle must be inside of the end circle";
+ hint: "try using a focal center of `auto` instead"
+ );
+ }
+
+ Ok(Gradient::Radial(Arc::new(RadialGradient {
+ stops: process_stops(&stops)?,
+ center: center.map(From::from),
+ radius: radius.v,
+ focal_center,
+ focal_radius: focal_radius.v,
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a new conic gradient, in which colors change radially around a
+ /// center point.
+ ///
+ /// You can control the center point of the gradient by using the `center`
+ /// argument. By default, the center point is the center of the shape.
+ ///
+ /// ```example
+ /// >>> #set circle(radius: 30pt)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// circle(fill: gradient.conic(
+ /// ..color.map.viridis,
+ /// )),
+ /// circle(fill: gradient.conic(
+ /// ..color.map.viridis,
+ /// center: (20%, 30%),
+ /// )),
+ /// )
+ /// ```
+ #[func]
+ pub fn conic(
+ /// The call site of this function.
+ span: Span,
+ /// The color [stops](#stops) of the gradient.
+ #[variadic]
+ stops: Vec<Spanned<GradientStop>>,
+ /// The angle of the gradient.
+ #[named]
+ #[default(Angle::zero())]
+ angle: Angle,
+ /// The color space in which to interpolate the gradient.
+ ///
+ /// Defaults to a perceptually uniform color space called
+ /// [Oklab]($color.oklab).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+ /// The [relative placement](#relativeness) of the gradient.
+ ///
+ /// For an element placed at the root/top level of the document, the parent
+ /// is the page itself. For other elements, the parent is the innermost block,
+ /// box, column, grid, or stack that contains the element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The center of the last circle of the gradient.
+ ///
+ /// A value of `{(50%, 50%)}` means that the end circle is
+ /// centered inside of its container.
+ #[named]
+ #[default(Axes::splat(Ratio::new(0.5)))]
+ center: Axes<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if stops.len() < 2 {
+ bail!(
+ span, "a gradient must have at least two stops";
+ hint: "try filling the shape with a single color instead"
+ );
+ }
+
+ Ok(Gradient::Conic(Arc::new(ConicGradient {
+ stops: process_stops(&stops)?,
+ angle,
+ center: center.map(From::from),
+ space,
+ relative,
+ anti_alias: true,
+ })))
+ }
+
+ /// Creates a sharp version of this gradient.
+ ///
+ /// Sharp gradients have discrete jumps between colors, instead of a
+ /// smooth transition. They are particularly useful for creating color
+ /// lists for a preset gradient.
+ ///
+ /// ```example
+ /// #set rect(width: 100%, height: 20pt)
+ /// #let grad = gradient.linear(..color.map.rainbow)
+ /// #rect(fill: grad)
+ /// #rect(fill: grad.sharp(5))
+ /// #rect(fill: grad.sharp(5, smoothness: 20%))
+ /// ```
+ #[func]
+ pub fn sharp(
+ &self,
+ /// The number of stops in the gradient.
+ steps: Spanned<usize>,
+ /// How much to smooth the gradient.
+ #[named]
+ #[default(Spanned::new(Ratio::zero(), Span::detached()))]
+ smoothness: Spanned<Ratio>,
+ ) -> SourceResult<Gradient> {
+ if steps.v < 2 {
+ bail!(steps.span, "sharp gradients must have at least two stops");
+ }
+
+ if smoothness.v.get() < 0.0 || smoothness.v.get() > 1.0 {
+ bail!(smoothness.span, "smoothness must be between 0 and 1");
+ }
+
+ let n = steps.v;
+ let smoothness = smoothness.v.get();
+ let colors = (0..n)
+ .flat_map(|i| {
+ let c = self
+ .sample(RatioOrAngle::Ratio(Ratio::new(i as f64 / (n - 1) as f64)));
+
+ [c, c]
+ })
+ .collect::<Vec<_>>();
+
+ let mut positions = Vec::with_capacity(n * 2);
+ let index_to_progress = |i| i as f64 * 1.0 / n as f64;
+
+ let progress = smoothness * 1.0 / (4.0 * n as f64);
+ for i in 0..n {
+ let mut j = 2 * i;
+ positions.push(index_to_progress(i));
+ if j > 0 {
+ positions[j] += progress;
+ }
+
+ j += 1;
+ positions.push(index_to_progress(i + 1));
+ if j < colors.len() - 1 {
+ positions[j] -= progress;
+ }
+ }
+
+ let mut stops = colors
+ .into_iter()
+ .zip(positions)
+ .map(|(c, p)| (c, Ratio::new(p)))
+ .collect::<Vec<_>>();
+
+ stops.dedup();
+
+ Ok(match self {
+ Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
+ stops,
+ angle: linear.angle,
+ space: linear.space,
+ relative: linear.relative,
+ anti_alias: false,
+ })),
+ Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient {
+ stops,
+ center: radial.center,
+ radius: radial.radius,
+ focal_center: radial.focal_center,
+ focal_radius: radial.focal_radius,
+ space: radial.space,
+ relative: radial.relative,
+ anti_alias: false,
+ })),
+ Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
+ stops,
+ angle: conic.angle,
+ center: conic.center,
+ space: conic.space,
+ relative: conic.relative,
+ anti_alias: false,
+ })),
+ })
+ }
+
+ /// Repeats this gradient a given number of times, optionally mirroring it
+ /// at each repetition.
+ ///
+ /// ```example
+ /// #circle(
+ /// radius: 40pt,
+ /// fill: gradient
+ /// .radial(aqua, white)
+ /// .repeat(4),
+ /// )
+ /// ```
+ #[func]
+ pub fn repeat(
+ &self,
+ /// The number of times to repeat the gradient.
+ repetitions: Spanned<usize>,
+ /// Whether to mirror the gradient at each repetition.
+ #[named]
+ #[default(false)]
+ mirror: bool,
+ ) -> SourceResult<Gradient> {
+ if repetitions.v == 0 {
+ bail!(repetitions.span, "must repeat at least once");
+ }
+
+ let n = repetitions.v;
+ let mut stops = std::iter::repeat(self.stops_ref())
+ .take(n)
+ .enumerate()
+ .flat_map(|(i, stops)| {
+ let mut stops = stops
+ .iter()
+ .map(move |&(color, offset)| {
+ let t = i as f64 / n as f64;
+ let r = offset.get();
+ if i % 2 == 1 && mirror {
+ (color, Ratio::new(t + (1.0 - r) / n as f64))
+ } else {
+ (color, Ratio::new(t + r / n as f64))
+ }
+ })
+ .collect::<Vec<_>>();
+
+ if i % 2 == 1 && mirror {
+ stops.reverse();
+ }
+
+ stops
+ })
+ .collect::<Vec<_>>();
+
+ stops.dedup();
+
+ Ok(match self {
+ Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
+ stops,
+ angle: linear.angle,
+ space: linear.space,
+ relative: linear.relative,
+ anti_alias: linear.anti_alias,
+ })),
+ Self::Radial(radial) => Self::Radial(Arc::new(RadialGradient {
+ stops,
+ center: radial.center,
+ radius: radial.radius,
+ focal_center: radial.focal_center,
+ focal_radius: radial.focal_radius,
+ space: radial.space,
+ relative: radial.relative,
+ anti_alias: radial.anti_alias,
+ })),
+ Self::Conic(conic) => Self::Conic(Arc::new(ConicGradient {
+ stops,
+ angle: conic.angle,
+ center: conic.center,
+ space: conic.space,
+ relative: conic.relative,
+ anti_alias: conic.anti_alias,
+ })),
+ })
+ }
+
+ /// Returns the kind of this gradient.
+ #[func]
+ pub fn kind(&self) -> Func {
+ match self {
+ Self::Linear(_) => Self::linear_data().into(),
+ Self::Radial(_) => Self::radial_data().into(),
+ Self::Conic(_) => Self::conic_data().into(),
+ }
+ }
+
+ /// Returns the stops of this gradient.
+ #[func]
+ pub fn stops(&self) -> Vec<GradientStop> {
+ match self {
+ Self::Linear(linear) => linear
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ Self::Radial(radial) => radial
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ Self::Conic(conic) => conic
+ .stops
+ .iter()
+ .map(|(color, offset)| GradientStop {
+ color: *color,
+ offset: Some(*offset),
+ })
+ .collect(),
+ }
+ }
+
+ /// Returns the mixing space of this gradient.
+ #[func]
+ pub fn space(&self) -> ColorSpace {
+ match self {
+ Self::Linear(linear) => linear.space,
+ Self::Radial(radial) => radial.space,
+ Self::Conic(conic) => conic.space,
+ }
+ }
+
+ /// Returns the relative placement of this gradient.
+ #[func]
+ pub fn relative(&self) -> Smart<RelativeTo> {
+ match self {
+ Self::Linear(linear) => linear.relative,
+ Self::Radial(radial) => radial.relative,
+ Self::Conic(conic) => conic.relative,
+ }
+ }
+
+ /// Returns the angle of this gradient.
+ #[func]
+ pub fn angle(&self) -> Option<Angle> {
+ match self {
+ Self::Linear(linear) => Some(linear.angle),
+ Self::Radial(_) => None,
+ Self::Conic(conic) => Some(conic.angle),
+ }
+ }
+
+ /// Sample the gradient at a given position.
+ ///
+ /// The position is either a position along the gradient (a [ratio] between
+ /// `{0%}` and `{100%}`) or an [angle]. Any value outside of this range will
+ /// be clamped.
+ #[func]
+ pub fn sample(
+ &self,
+ /// The position at which to sample the gradient.
+ t: RatioOrAngle,
+ ) -> Color {
+ let value: f64 = t.to_ratio().get();
+
+ match self {
+ Self::Linear(linear) => sample_stops(&linear.stops, linear.space, value),
+ Self::Radial(radial) => sample_stops(&radial.stops, radial.space, value),
+ Self::Conic(conic) => sample_stops(&conic.stops, conic.space, value),
+ }
+ }
+
+ /// Samples the gradient at multiple positions at once and returns the
+ /// results as an array.
+ #[func]
+ pub fn samples(
+ &self,
+ /// The positions at which to sample the gradient.
+ #[variadic]
+ ts: Vec<RatioOrAngle>,
+ ) -> Array {
+ ts.into_iter().map(|t| self.sample(t).into_value()).collect()
+ }
+}
+
+impl Gradient {
+ /// Clones this gradient, but with a different relative placement.
+ pub fn with_relative(mut self, relative: RelativeTo) -> Self {
+ match &mut self {
+ Self::Linear(linear) => {
+ Arc::make_mut(linear).relative = Smart::Custom(relative);
+ }
+ Self::Radial(radial) => {
+ Arc::make_mut(radial).relative = Smart::Custom(relative);
+ }
+ Self::Conic(conic) => {
+ Arc::make_mut(conic).relative = Smart::Custom(relative);
+ }
+ }
+
+ self
+ }
+ /// Returns a reference to the stops of this gradient.
+ pub fn stops_ref(&self) -> &[(Color, Ratio)] {
+ match self {
+ Gradient::Linear(linear) => &linear.stops,
+ Gradient::Radial(radial) => &radial.stops,
+ Gradient::Conic(conic) => &conic.stops,
+ }
+ }
+
+ /// Samples the gradient at a given position, in the given container.
+ /// Handles the aspect ratio and angle directly.
+ pub fn sample_at(&self, (x, y): (f32, f32), (width, height): (f32, f32)) -> Color {
+ // Normalize the coordinates.
+ let (mut x, mut y) = (x / width, y / height);
+ let t = match self {
+ Self::Linear(linear) => {
+ // Aspect ratio correction.
+ let angle = Gradient::correct_aspect_ratio(
+ linear.angle,
+ Ratio::new((width / height) as f64),
+ )
+ .to_rad();
+ let (sin, cos) = angle.sin_cos();
+
+ let length = sin.abs() + cos.abs();
+ if angle > FRAC_PI_2 && angle < 3.0 * FRAC_PI_2 {
+ x = 1.0 - x;
+ }
+
+ if angle > PI {
+ y = 1.0 - y;
+ }
+
+ (x as f64 * cos.abs() + y as f64 * sin.abs()) / length
+ }
+ Self::Radial(radial) => {
+ // Source: @Enivex - https://typst.app/project/pYLeS0QyCCe8mf0pdnwoAI
+ let cr = radial.radius.get();
+ let fr = radial.focal_radius.get();
+ let z = Vec2::new(x as f64, y as f64);
+ let p = Vec2::new(radial.center.x.get(), radial.center.y.get());
+ let q =
+ Vec2::new(radial.focal_center.x.get(), radial.focal_center.y.get());
+
+ if (z - q).hypot() < fr {
+ 0.0
+ } else if (z - p).hypot() > cr {
+ 1.0
+ } else {
+ let uz = (z - q).normalize();
+ let az = (q - p).dot(uz);
+ let rho = cr.powi(2) - (q - p).hypot().powi(2);
+ let bz = (az.powi(2) + rho).sqrt() - az;
+
+ ((z - q).hypot() - fr) / (bz - fr)
+ }
+ }
+ Self::Conic(conic) => {
+ let (x, y) =
+ (x as f64 - conic.center.x.get(), y as f64 - conic.center.y.get());
+ let angle = Gradient::correct_aspect_ratio(
+ conic.angle,
+ Ratio::new((width / height) as f64),
+ );
+ ((-y.atan2(x) + PI + angle.to_rad()) % TAU) / TAU
+ }
+ };
+
+ self.sample(RatioOrAngle::Ratio(Ratio::new(t.clamp(0.0, 1.0))))
+ }
+
+ /// Does this gradient need to be anti-aliased?
+ pub fn anti_alias(&self) -> bool {
+ match self {
+ Self::Linear(linear) => linear.anti_alias,
+ Self::Radial(radial) => radial.anti_alias,
+ Self::Conic(conic) => conic.anti_alias,
+ }
+ }
+
+ /// Returns the relative placement of this gradient, handling
+ /// the special case of `auto`.
+ pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo {
+ self.relative().unwrap_or_else(|| {
+ if on_text {
+ RelativeTo::Parent
+ } else {
+ RelativeTo::Self_
+ }
+ })
+ }
+
+ /// Corrects this angle for the aspect ratio of a gradient.
+ ///
+ /// This is used specifically for gradients.
+ pub fn correct_aspect_ratio(angle: Angle, aspect_ratio: Ratio) -> Angle {
+ let rad = (angle.to_rad().rem_euclid(TAU).tan() / aspect_ratio.get()).atan();
+ let rad = match angle.quadrant() {
+ Quadrant::First => rad,
+ Quadrant::Second => rad + PI,
+ Quadrant::Third => rad + PI,
+ Quadrant::Fourth => rad + TAU,
+ };
+ Angle::rad(rad.rem_euclid(TAU))
+ }
+}
+
+impl Debug for Gradient {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Linear(v) => v.fmt(f),
+ Self::Radial(v) => v.fmt(f),
+ Self::Conic(v) => v.fmt(f),
+ }
+ }
+}
+
+impl Repr for Gradient {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Radial(radial) => radial.repr(),
+ Self::Linear(linear) => linear.repr(),
+ Self::Conic(conic) => conic.repr(),
+ }
+ }
+}
+
+/// A gradient that interpolates between two colors along an axis.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct LinearGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The direction of this gradient.
+ pub angle: Angle,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for LinearGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.linear(");
+
+ let angle = self.angle.to_rad().rem_euclid(TAU);
+ if angle.abs() < f64::EPSILON {
+ // Default value, do nothing
+ } else if (angle - FRAC_PI_2).abs() < f64::EPSILON {
+ r.push_str("dir: rtl, ");
+ } else if (angle - PI).abs() < f64::EPSILON {
+ r.push_str("dir: ttb, ");
+ } else if (angle - 3.0 * FRAC_PI_2).abs() < f64::EPSILON {
+ r.push_str("dir: btt, ");
+ } else {
+ r.push_str("angle: ");
+ r.push_str(&self.angle.repr());
+ r.push_str(", ");
+ }
+
+ if self.space != ColorSpace::Oklab {
+ r.push_str("space: ");
+ r.push_str(&self.space.into_value().repr());
+ r.push_str(", ");
+ }
+
+ if self.relative.is_custom() {
+ r.push_str("relative: ");
+ r.push_str(&self.relative.into_value().repr());
+ r.push_str(", ");
+ }
+
+ for (i, (color, offset)) in self.stops.iter().enumerate() {
+ r.push('(');
+ r.push_str(&color.repr());
+ r.push_str(", ");
+ r.push_str(&offset.repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// A gradient that interpolates between two colors along a circle.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct RadialGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The center of last circle of this gradient.
+ pub center: Axes<Ratio>,
+ /// The radius of last circle of this gradient.
+ pub radius: Ratio,
+ /// The center of first circle of this gradient.
+ pub focal_center: Axes<Ratio>,
+ /// The radius of first circle of this gradient.
+ pub focal_radius: Ratio,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for RadialGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.radial(");
+
+ if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) {
+ r.push_str("center: (");
+ r.push_str(&self.center.x.repr());
+ r.push_str(", ");
+ r.push_str(&self.center.y.repr());
+ r.push_str("), ");
+ }
+
+ if self.radius != Ratio::new(0.5) {
+ r.push_str("radius: ");
+ r.push_str(&self.radius.repr());
+ r.push_str(", ");
+ }
+
+ if self.focal_center != self.center {
+ r.push_str("focal-center: (");
+ r.push_str(&self.focal_center.x.repr());
+ r.push_str(", ");
+ r.push_str(&self.focal_center.y.repr());
+ r.push_str("), ");
+ }
+
+ if self.focal_radius != Ratio::zero() {
+ r.push_str("focal-radius: ");
+ r.push_str(&self.focal_radius.repr());
+ r.push_str(", ");
+ }
+
+ if self.space != ColorSpace::Oklab {
+ r.push_str("space: ");
+ r.push_str(&self.space.into_value().repr());
+ r.push_str(", ");
+ }
+
+ if self.relative.is_custom() {
+ r.push_str("relative: ");
+ r.push_str(&self.relative.into_value().repr());
+ r.push_str(", ");
+ }
+
+ for (i, (color, offset)) in self.stops.iter().enumerate() {
+ r.push('(');
+ r.push_str(&color.repr());
+ r.push_str(", ");
+ r.push_str(&offset.repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// A gradient that interpolates between two colors radially
+/// around a center point.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct ConicGradient {
+ /// The color stops of this gradient.
+ pub stops: Vec<(Color, Ratio)>,
+ /// The direction of this gradient.
+ pub angle: Angle,
+ /// The center of last circle of this gradient.
+ pub center: Axes<Ratio>,
+ /// The color space in which to interpolate the gradient.
+ pub space: ColorSpace,
+ /// The relative placement of the gradient.
+ pub relative: Smart<RelativeTo>,
+ /// Whether to anti-alias the gradient (used for sharp gradients).
+ pub anti_alias: bool,
+}
+
+impl Repr for ConicGradient {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("gradient.conic(");
+
+ let angle = self.angle.to_rad().rem_euclid(TAU);
+ if angle.abs() > f64::EPSILON {
+ r.push_str("angle: ");
+ r.push_str(&self.angle.repr());
+ r.push_str(", ");
+ }
+
+ if self.center.x != Ratio::new(0.5) || self.center.y != Ratio::new(0.5) {
+ r.push_str("center: (");
+ r.push_str(&self.center.x.repr());
+ r.push_str(", ");
+ r.push_str(&self.center.y.repr());
+ r.push_str("), ");
+ }
+
+ if self.space != ColorSpace::Oklab {
+ r.push_str("space: ");
+ r.push_str(&self.space.into_value().repr());
+ r.push_str(", ");
+ }
+
+ if self.relative.is_custom() {
+ r.push_str("relative: ");
+ r.push_str(&self.relative.into_value().repr());
+ r.push_str(", ");
+ }
+
+ for (i, (color, offset)) in self.stops.iter().enumerate() {
+ r.push('(');
+ r.push_str(&color.repr());
+ r.push_str(", ");
+ r.push_str(&Angle::deg(offset.get() * 360.0).repr());
+ r.push(')');
+ if i != self.stops.len() - 1 {
+ r.push_str(", ");
+ }
+ }
+
+ r.push(')');
+ r
+ }
+}
+
+/// What is the gradient relative to.
+#[derive(Cast, Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum RelativeTo {
+ /// The gradient is relative to itself (its own bounding box).
+ Self_,
+ /// The gradient is relative to its parent (the parent's bounding box).
+ Parent,
+}
+
+/// A color stop.
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub struct GradientStop {
+ /// The color for this stop.
+ pub color: Color,
+ /// The offset of the stop along the gradient.
+ pub offset: Option<Ratio>,
+}
+
+impl GradientStop {
+ /// Create a new stop from a `color` and an `offset`.
+ pub fn new(color: Color, offset: Ratio) -> Self {
+ Self { color, offset: Some(offset) }
+ }
+}
+
+cast! {
+ GradientStop,
+ self => if let Some(offset) = self.offset {
+ array![self.color.into_value(), offset].into_value()
+ } else {
+ self.color.into_value()
+ },
+ color: Color => Self { color, offset: None },
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Self {
+ color: a.cast()?,
+ offset: Some(b.cast()?)
+ },
+ _ => Err("a color stop must contain exactly two entries")?,
+ }
+ }
+}
+
+/// A ratio or an angle.
+#[derive(Copy, Clone, PartialEq, Eq, Hash)]
+pub enum RatioOrAngle {
+ Ratio(Ratio),
+ Angle(Angle),
+}
+
+impl RatioOrAngle {
+ pub fn to_ratio(self) -> Ratio {
+ match self {
+ Self::Ratio(ratio) => ratio,
+ Self::Angle(angle) => Ratio::new(angle.to_rad().rem_euclid(TAU) / TAU),
+ }
+ .clamp(Ratio::zero(), Ratio::one())
+ }
+}
+
+cast! {
+ RatioOrAngle,
+ self => match self {
+ Self::Ratio(ratio) => ratio.into_value(),
+ Self::Angle(angle) => angle.into_value(),
+ },
+ ratio: Ratio => Self::Ratio(ratio),
+ angle: Angle => Self::Angle(angle),
+}
+
+/// Pre-processes the stops, checking that they are valid and computing the
+/// offsets if necessary.
+///
+/// Returns an error if the stops are invalid.
+///
+/// This is split into its own function because it is used by all of the
+/// different gradient types.
+#[comemo::memoize]
+fn process_stops(stops: &[Spanned<GradientStop>]) -> SourceResult<Vec<(Color, Ratio)>> {
+ let has_offset = stops.iter().any(|stop| stop.v.offset.is_some());
+ if has_offset {
+ let mut last_stop = f64::NEG_INFINITY;
+ for Spanned { v: stop, span } in stops.iter() {
+ let Some(stop) = stop.offset else {
+ bail!(
+ *span, "either all stops must have an offset or none of them can";
+ hint: "try adding an offset to all stops"
+ );
+ };
+
+ if stop.get() < last_stop {
+ bail!(*span, "offsets must be in strictly monotonic order");
+ }
+
+ last_stop = stop.get();
+ }
+
+ let out = stops
+ .iter()
+ .map(|Spanned { v: GradientStop { color, offset }, span }| {
+ if offset.unwrap().get() > 1.0 || offset.unwrap().get() < 0.0 {
+ bail!(*span, "offset must be between 0 and 1");
+ }
+ Ok((*color, offset.unwrap()))
+ })
+ .collect::<SourceResult<Vec<_>>>()?;
+
+ if out[0].1 != Ratio::zero() {
+ bail!(
+ stops[0].span,
+ "first stop must have an offset of 0";
+ hint: "try setting this stop to `0%`"
+ );
+ }
+
+ if out[out.len() - 1].1 != Ratio::one() {
+ bail!(
+ stops[out.len() - 1].span,
+ "last stop must have an offset of 100%";
+ hint: "try setting this stop to `100%`"
+ );
+ }
+
+ return Ok(out);
+ }
+
+ Ok(stops
+ .iter()
+ .enumerate()
+ .map(|(i, stop)| {
+ let offset = i as f64 / (stops.len() - 1) as f64;
+ (stop.v.color, Ratio::new(offset))
+ })
+ .collect())
+}
+
+/// Sample the stops at a given position.
+fn sample_stops(stops: &[(Color, Ratio)], mixing_space: ColorSpace, t: f64) -> Color {
+ let t = t.clamp(0.0, 1.0);
+ let mut low = 0;
+ let mut high = stops.len();
+
+ while low < high {
+ let mid = (low + high) / 2;
+ if stops[mid].1.get() < t {
+ low = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+
+ if low == 0 {
+ low = 1;
+ }
+
+ let (col_0, pos_0) = stops[low - 1];
+ let (col_1, pos_1) = stops[low];
+ let t = (t - pos_0.get()) / (pos_1.get() - pos_0.get());
+
+ Color::mix_iter(
+ [WeightedColor::new(col_0, 1.0 - t), WeightedColor::new(col_1, t)],
+ mixing_space,
+ )
+ .unwrap()
+}
diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs
new file mode 100644
index 00000000..359db252
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/mod.rs
@@ -0,0 +1,360 @@
+//! Image handling.
+
+mod raster;
+mod svg;
+
+pub use self::raster::{RasterFormat, RasterImage};
+pub use self::svg::SvgImage;
+
+use std::fmt::{self, Debug, Formatter};
+use std::sync::Arc;
+
+use comemo::Tracked;
+use ecow::EcoString;
+use typst_syntax::{Span, Spanned};
+use typst_utils::LazyHash;
+
+use crate::diag::{At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, func, scope, Bytes, Cast, Content, NativeElement, Packed, Show, Smart,
+ StyleChain,
+};
+use crate::layout::{BlockElem, Length, Rel, Sizing};
+use crate::loading::Readable;
+use crate::model::Figurable;
+use crate::text::LocalName;
+use crate::World;
+
+/// A raster or vector graphic.
+///
+/// You can wrap the image in a [`figure`] to give it a number and caption.
+///
+/// Like most elements, images are _block-level_ by default and thus do not
+/// integrate themselves into adjacent paragraphs. To force an image to become
+/// inline, put it into a [`box`].
+///
+/// # Example
+/// ```example
+/// #figure(
+/// image("molecular.jpg", width: 80%),
+/// caption: [
+/// A step in the molecular testing
+/// pipeline of our lab.
+/// ],
+/// )
+/// ```
+#[elem(scope, Show, LocalName, Figurable)]
+pub struct ImageElem {
+ /// Path to an image file
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ #[required]
+ #[parse(
+ let Spanned { v: path, span } =
+ args.expect::<Spanned<EcoString>>("path to image file")?;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ path
+ )]
+ #[borrowed]
+ pub path: EcoString,
+
+ /// The raw file data.
+ #[internal]
+ #[required]
+ #[parse(Readable::Bytes(data))]
+ pub data: Readable,
+
+ /// The image's format. Detected automatically by default.
+ ///
+ /// Supported formats are PNG, JPEG, GIF, and SVG. Using a PDF as an image
+ /// is [not currently supported](https://github.com/typst/typst/issues/145).
+ pub format: Smart<ImageFormat>,
+
+ /// The width of the image.
+ pub width: Smart<Rel<Length>>,
+
+ /// The height of the image.
+ pub height: Sizing,
+
+ /// A text describing the image.
+ pub alt: Option<EcoString>,
+
+ /// How the image should adjust itself to a given area (the area is defined
+ /// by the `width` and `height` fields). Note that `fit` doesn't visually
+ /// change anything if the area's aspect ratio is the same as the image's
+ /// one.
+ ///
+ /// ```example
+ /// #set page(width: 300pt, height: 50pt, margin: 10pt)
+ /// #image("tiger.jpg", width: 100%, fit: "cover")
+ /// #image("tiger.jpg", width: 100%, fit: "contain")
+ /// #image("tiger.jpg", width: 100%, fit: "stretch")
+ /// ```
+ #[default(ImageFit::Cover)]
+ pub fit: ImageFit,
+}
+
+#[scope]
+impl ImageElem {
+ /// Decode a raster or vector graphic from bytes or a string.
+ ///
+ /// ```example
+ /// #let original = read("diagram.svg")
+ /// #let changed = original.replace(
+ /// "#2B80FF", // blue
+ /// green.to-hex(),
+ /// )
+ ///
+ /// #image.decode(original)
+ /// #image.decode(changed)
+ /// ```
+ #[func(title = "Decode Image")]
+ pub fn decode(
+ /// The call span of this function.
+ span: Span,
+ /// The data to decode as an image. Can be a string for SVGs.
+ data: Readable,
+ /// The image's format. Detected automatically by default.
+ #[named]
+ format: Option<Smart<ImageFormat>>,
+ /// The width of the image.
+ #[named]
+ width: Option<Smart<Rel<Length>>>,
+ /// The height of the image.
+ #[named]
+ height: Option<Sizing>,
+ /// A text describing the image.
+ #[named]
+ alt: Option<Option<EcoString>>,
+ /// How the image should adjust itself to a given area.
+ #[named]
+ fit: Option<ImageFit>,
+ ) -> StrResult<Content> {
+ let mut elem = ImageElem::new(EcoString::new(), data);
+ if let Some(format) = format {
+ elem.push_format(format);
+ }
+ if let Some(width) = width {
+ elem.push_width(width);
+ }
+ if let Some(height) = height {
+ elem.push_height(height);
+ }
+ if let Some(alt) = alt {
+ elem.push_alt(alt);
+ }
+ if let Some(fit) = fit {
+ elem.push_fit(fit);
+ }
+ Ok(elem.pack().spanned(span))
+ }
+}
+
+impl Show for Packed<ImageElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_image)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+impl LocalName for Packed<ImageElem> {
+ const KEY: &'static str = "figure";
+}
+
+impl Figurable for Packed<ImageElem> {}
+
+/// How an image should adjust itself to a given area,
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum ImageFit {
+ /// The image should completely cover the area (preserves aspect ratio by
+ /// cropping the image only horizontally or vertically). This is the
+ /// default.
+ Cover,
+ /// The image should be fully contained in the area (preserves aspect
+ /// ratio; doesn't crop the image; one dimension can be narrower than
+ /// specified).
+ Contain,
+ /// The image should be stretched so that it exactly fills the area, even if
+ /// this means that the image will be distorted (doesn't preserve aspect
+ /// ratio and doesn't crop the image).
+ Stretch,
+}
+
+/// A loaded raster or vector image.
+///
+/// Values of this type are cheap to clone and hash.
+#[derive(Clone, Hash, Eq, PartialEq)]
+pub struct Image(Arc<LazyHash<Repr>>);
+
+/// The internal representation.
+#[derive(Hash)]
+struct Repr {
+ /// The raw, undecoded image data.
+ kind: ImageKind,
+ /// A text describing the image.
+ alt: Option<EcoString>,
+}
+
+/// A kind of image.
+#[derive(Hash)]
+pub enum ImageKind {
+ /// A raster image.
+ Raster(RasterImage),
+ /// An SVG image.
+ Svg(SvgImage),
+}
+
+impl Image {
+ /// When scaling an image to it's natural size, we default to this DPI
+ /// if the image doesn't contain DPI metadata.
+ pub const DEFAULT_DPI: f64 = 72.0;
+
+ /// Should always be the same as the default DPI used by usvg.
+ pub const USVG_DEFAULT_DPI: f64 = 96.0;
+
+ /// Create an image from a buffer and a format.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load image")]
+ pub fn new(
+ data: Bytes,
+ format: ImageFormat,
+ alt: Option<EcoString>,
+ ) -> StrResult<Image> {
+ let kind = match format {
+ ImageFormat::Raster(format) => {
+ ImageKind::Raster(RasterImage::new(data, format)?)
+ }
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ ImageKind::Svg(SvgImage::new(data)?)
+ }
+ };
+
+ Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt }))))
+ }
+
+ /// Create a possibly font-dependent image from a buffer and a format.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load image")]
+ pub fn with_fonts(
+ data: Bytes,
+ format: ImageFormat,
+ alt: Option<EcoString>,
+ world: Tracked<dyn World + '_>,
+ families: &[&str],
+ ) -> StrResult<Image> {
+ let kind = match format {
+ ImageFormat::Raster(format) => {
+ ImageKind::Raster(RasterImage::new(data, format)?)
+ }
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ ImageKind::Svg(SvgImage::with_fonts(data, world, families)?)
+ }
+ };
+
+ Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt }))))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.data(),
+ ImageKind::Svg(svg) => svg.data(),
+ }
+ }
+
+ /// The format of the image.
+ pub fn format(&self) -> ImageFormat {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.format().into(),
+ ImageKind::Svg(_) => VectorFormat::Svg.into(),
+ }
+ }
+
+ /// The width of the image in pixels.
+ pub fn width(&self) -> f64 {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.width() as f64,
+ ImageKind::Svg(svg) => svg.width(),
+ }
+ }
+
+ /// The height of the image in pixels.
+ pub fn height(&self) -> f64 {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.height() as f64,
+ ImageKind::Svg(svg) => svg.height(),
+ }
+ }
+
+ /// The image's pixel density in pixels per inch, if known.
+ pub fn dpi(&self) -> Option<f64> {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.dpi(),
+ ImageKind::Svg(_) => Some(Image::USVG_DEFAULT_DPI),
+ }
+ }
+
+ /// A text describing the image.
+ pub fn alt(&self) -> Option<&str> {
+ self.0.alt.as_deref()
+ }
+
+ /// The decoded image.
+ pub fn kind(&self) -> &ImageKind {
+ &self.0.kind
+ }
+}
+
+impl Debug for Image {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.debug_struct("Image")
+ .field("format", &self.format())
+ .field("width", &self.width())
+ .field("height", &self.height())
+ .field("alt", &self.alt())
+ .finish()
+ }
+}
+
+/// A raster or vector image format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ImageFormat {
+ /// A raster graphics format.
+ Raster(RasterFormat),
+ /// A vector graphics format.
+ Vector(VectorFormat),
+}
+
+/// A vector graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum VectorFormat {
+ /// The vector graphics format of the web.
+ Svg,
+}
+
+impl From<RasterFormat> for ImageFormat {
+ fn from(format: RasterFormat) -> Self {
+ Self::Raster(format)
+ }
+}
+
+impl From<VectorFormat> for ImageFormat {
+ fn from(format: VectorFormat) -> Self {
+ Self::Vector(format)
+ }
+}
+
+cast! {
+ ImageFormat,
+ self => match self {
+ Self::Raster(v) => v.into_value(),
+ Self::Vector(v) => v.into_value()
+ },
+ v: RasterFormat => Self::Raster(v),
+ v: VectorFormat => Self::Vector(v),
+}
diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs
new file mode 100644
index 00000000..829826c7
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/raster.rs
@@ -0,0 +1,286 @@
+use std::cmp::Ordering;
+use std::hash::{Hash, Hasher};
+use std::io;
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use image::codecs::gif::GifDecoder;
+use image::codecs::jpeg::JpegDecoder;
+use image::codecs::png::PngDecoder;
+use image::{guess_format, DynamicImage, ImageDecoder, ImageResult, Limits};
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{Bytes, Cast};
+
+/// A decoded raster image.
+#[derive(Clone, Hash)]
+pub struct RasterImage(Arc<Repr>);
+
+/// The internal representation.
+struct Repr {
+ data: Bytes,
+ format: RasterFormat,
+ dynamic: image::DynamicImage,
+ icc: Option<Vec<u8>>,
+ dpi: Option<f64>,
+}
+
+impl RasterImage {
+ /// Decode a raster image.
+ #[comemo::memoize]
+ pub fn new(data: Bytes, format: RasterFormat) -> StrResult<RasterImage> {
+ fn decode_with<T: ImageDecoder>(
+ decoder: ImageResult<T>,
+ ) -> ImageResult<(image::DynamicImage, Option<Vec<u8>>)> {
+ let mut decoder = decoder?;
+ let icc = decoder.icc_profile().ok().flatten().filter(|icc| !icc.is_empty());
+ decoder.set_limits(Limits::default())?;
+ let dynamic = image::DynamicImage::from_decoder(decoder)?;
+ Ok((dynamic, icc))
+ }
+
+ let cursor = io::Cursor::new(&data);
+ let (mut dynamic, icc) = match format {
+ RasterFormat::Jpg => decode_with(JpegDecoder::new(cursor)),
+ RasterFormat::Png => decode_with(PngDecoder::new(cursor)),
+ RasterFormat::Gif => decode_with(GifDecoder::new(cursor)),
+ }
+ .map_err(format_image_error)?;
+
+ let exif = exif::Reader::new()
+ .read_from_container(&mut std::io::Cursor::new(&data))
+ .ok();
+
+ // Apply rotation from EXIF metadata.
+ if let Some(rotation) = exif.as_ref().and_then(exif_rotation) {
+ apply_rotation(&mut dynamic, rotation);
+ }
+
+ // Extract pixel density.
+ let dpi = determine_dpi(&data, exif.as_ref());
+
+ Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi })))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The image's format.
+ pub fn format(&self) -> RasterFormat {
+ self.0.format
+ }
+
+ /// The image's pixel width.
+ pub fn width(&self) -> u32 {
+ self.dynamic().width()
+ }
+
+ /// The image's pixel height.
+ pub fn height(&self) -> u32 {
+ self.dynamic().height()
+ }
+
+ /// The image's pixel density in pixels per inch, if known.
+ pub fn dpi(&self) -> Option<f64> {
+ self.0.dpi
+ }
+
+ /// Access the underlying dynamic image.
+ pub fn dynamic(&self) -> &image::DynamicImage {
+ &self.0.dynamic
+ }
+
+ /// Access the ICC profile, if any.
+ pub fn icc(&self) -> Option<&[u8]> {
+ self.0.icc.as_deref()
+ }
+}
+
+impl Hash for Repr {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ // The image is fully defined by data and format.
+ self.data.hash(state);
+ self.format.hash(state);
+ }
+}
+
+/// A raster graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum RasterFormat {
+ /// Raster format for illustrations and transparent graphics.
+ Png,
+ /// Lossy raster format suitable for photos.
+ Jpg,
+ /// Raster format that is typically used for short animated clips.
+ Gif,
+}
+
+impl RasterFormat {
+ /// Try to detect the format of data in a buffer.
+ pub fn detect(data: &[u8]) -> Option<Self> {
+ guess_format(data).ok().and_then(|format| format.try_into().ok())
+ }
+}
+
+impl From<RasterFormat> for image::ImageFormat {
+ fn from(format: RasterFormat) -> Self {
+ match format {
+ RasterFormat::Png => image::ImageFormat::Png,
+ RasterFormat::Jpg => image::ImageFormat::Jpeg,
+ RasterFormat::Gif => image::ImageFormat::Gif,
+ }
+ }
+}
+
+impl TryFrom<image::ImageFormat> for RasterFormat {
+ type Error = EcoString;
+
+ fn try_from(format: image::ImageFormat) -> StrResult<Self> {
+ Ok(match format {
+ image::ImageFormat::Png => RasterFormat::Png,
+ image::ImageFormat::Jpeg => RasterFormat::Jpg,
+ image::ImageFormat::Gif => RasterFormat::Gif,
+ _ => bail!("Format not yet supported."),
+ })
+ }
+}
+
+/// Try to get the rotation from the EXIF metadata.
+fn exif_rotation(exif: &exif::Exif) -> Option<u32> {
+ exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?
+ .value
+ .get_uint(0)
+}
+
+/// Apply an EXIF rotation to a dynamic image.
+fn apply_rotation(image: &mut DynamicImage, rotation: u32) {
+ use image::imageops as ops;
+ match rotation {
+ 2 => ops::flip_horizontal_in_place(image),
+ 3 => ops::rotate180_in_place(image),
+ 4 => ops::flip_vertical_in_place(image),
+ 5 => {
+ ops::flip_horizontal_in_place(image);
+ *image = image.rotate270();
+ }
+ 6 => *image = image.rotate90(),
+ 7 => {
+ ops::flip_horizontal_in_place(image);
+ *image = image.rotate90();
+ }
+ 8 => *image = image.rotate270(),
+ _ => {}
+ }
+}
+
+/// Try to determine the DPI (dots per inch) of the image.
+fn determine_dpi(data: &[u8], exif: Option<&exif::Exif>) -> Option<f64> {
+ // Try to extract the DPI from the EXIF metadata. If that doesn't yield
+ // anything, fall back to specialized procedures for extracting JPEG or PNG
+ // DPI metadata. GIF does not have any.
+ exif.and_then(exif_dpi)
+ .or_else(|| jpeg_dpi(data))
+ .or_else(|| png_dpi(data))
+}
+
+/// Try to get the DPI from the EXIF metadata.
+fn exif_dpi(exif: &exif::Exif) -> Option<f64> {
+ let axis = |tag| {
+ let dpi = exif.get_field(tag, exif::In::PRIMARY)?;
+ let exif::Value::Rational(rational) = &dpi.value else { return None };
+ Some(rational.first()?.to_f64())
+ };
+
+ [axis(exif::Tag::XResolution), axis(exif::Tag::YResolution)]
+ .into_iter()
+ .flatten()
+ .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
+}
+
+/// Tries to extract the DPI from raw JPEG data (by inspecting the JFIF APP0
+/// section).
+fn jpeg_dpi(data: &[u8]) -> Option<f64> {
+ let validate_at = |index: usize, expect: &[u8]| -> Option<()> {
+ data.get(index..)?.starts_with(expect).then_some(())
+ };
+ let u16_at = |index: usize| -> Option<u16> {
+ data.get(index..index + 2)?.try_into().ok().map(u16::from_be_bytes)
+ };
+
+ validate_at(0, b"\xFF\xD8\xFF\xE0\0")?;
+ validate_at(6, b"JFIF\0")?;
+ validate_at(11, b"\x01")?;
+
+ let len = u16_at(4)?;
+ if len < 16 {
+ return None;
+ }
+
+ let units = *data.get(13)?;
+ let x = u16_at(14)?;
+ let y = u16_at(16)?;
+ let dpu = x.max(y) as f64;
+
+ Some(match units {
+ 1 => dpu, // already inches
+ 2 => dpu * 2.54, // cm -> inches
+ _ => return None,
+ })
+}
+
+/// Tries to extract the DPI from raw PNG data.
+fn png_dpi(mut data: &[u8]) -> Option<f64> {
+ let mut decoder = png::StreamingDecoder::new();
+ let dims = loop {
+ let (consumed, event) = decoder.update(data, &mut Vec::new()).ok()?;
+ match event {
+ png::Decoded::PixelDimensions(dims) => break dims,
+ // Bail as soon as there is anything data-like.
+ png::Decoded::ChunkBegin(_, png::chunk::IDAT)
+ | png::Decoded::ImageData
+ | png::Decoded::ImageEnd => return None,
+ _ => {}
+ }
+ data = data.get(consumed..)?;
+ if consumed == 0 {
+ return None;
+ }
+ };
+
+ let dpu = dims.xppu.max(dims.yppu) as f64;
+ match dims.unit {
+ png::Unit::Meter => Some(dpu * 0.0254), // meter -> inches
+ png::Unit::Unspecified => None,
+ }
+}
+
+/// Format the user-facing raster graphic decoding error message.
+fn format_image_error(error: image::ImageError) -> EcoString {
+ match error {
+ image::ImageError::Limits(_) => "file is too large".into(),
+ err => eco_format!("failed to decode image ({err})"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{RasterFormat, RasterImage};
+ use crate::foundations::Bytes;
+
+ #[test]
+ fn test_image_dpi() {
+ #[track_caller]
+ fn test(path: &str, format: RasterFormat, dpi: f64) {
+ let data = typst_dev_assets::get(path).unwrap();
+ let bytes = Bytes::from_static(data);
+ let image = RasterImage::new(bytes, format).unwrap();
+ assert_eq!(image.dpi().map(f64::round), Some(dpi));
+ }
+
+ test("images/f2t.jpg", RasterFormat::Jpg, 220.0);
+ test("images/tiger.jpg", RasterFormat::Jpg, 72.0);
+ test("images/graph.png", RasterFormat::Png, 144.0);
+ }
+}
diff --git a/crates/typst-library/src/visualize/image/svg.rs b/crates/typst-library/src/visualize/image/svg.rs
new file mode 100644
index 00000000..f7a498a8
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/svg.rs
@@ -0,0 +1,289 @@
+use std::collections::HashMap;
+use std::hash::{Hash, Hasher};
+use std::sync::{Arc, Mutex};
+
+use comemo::Tracked;
+use ecow::EcoString;
+use siphasher::sip128::{Hasher128, SipHasher13};
+
+use crate::diag::{format_xml_like_error, StrResult};
+use crate::foundations::Bytes;
+use crate::layout::Axes;
+use crate::text::{
+ Font, FontBook, FontFlags, FontStretch, FontStyle, FontVariant, FontWeight,
+};
+use crate::World;
+
+/// A decoded SVG.
+#[derive(Clone, Hash)]
+pub struct SvgImage(Arc<Repr>);
+
+/// The internal representation.
+struct Repr {
+ data: Bytes,
+ size: Axes<f64>,
+ font_hash: u128,
+ tree: usvg::Tree,
+}
+
+impl SvgImage {
+ /// Decode an SVG image without fonts.
+ #[comemo::memoize]
+ pub fn new(data: Bytes) -> StrResult<SvgImage> {
+ let tree =
+ usvg::Tree::from_data(&data, &base_options()).map_err(format_usvg_error)?;
+ Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash: 0, tree })))
+ }
+
+ /// Decode an SVG image with access to fonts.
+ #[comemo::memoize]
+ pub fn with_fonts(
+ data: Bytes,
+ world: Tracked<dyn World + '_>,
+ families: &[&str],
+ ) -> StrResult<SvgImage> {
+ let book = world.book();
+ let resolver = Mutex::new(FontResolver::new(world, book, families));
+ let tree = usvg::Tree::from_data(
+ &data,
+ &usvg::Options {
+ font_resolver: usvg::FontResolver {
+ select_font: Box::new(|font, db| {
+ resolver.lock().unwrap().select_font(font, db)
+ }),
+ select_fallback: Box::new(|c, exclude_fonts, db| {
+ resolver.lock().unwrap().select_fallback(c, exclude_fonts, db)
+ }),
+ },
+ ..base_options()
+ },
+ )
+ .map_err(format_usvg_error)?;
+ let font_hash = resolver.into_inner().unwrap().finish();
+ Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash, tree })))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The SVG's width in pixels.
+ pub fn width(&self) -> f64 {
+ self.0.size.x
+ }
+
+ /// The SVG's height in pixels.
+ pub fn height(&self) -> f64 {
+ self.0.size.y
+ }
+
+ /// Accesses the usvg tree.
+ pub fn tree(&self) -> &usvg::Tree {
+ &self.0.tree
+ }
+}
+
+impl Hash for Repr {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ // An SVG might contain fonts, which must be incorporated into the hash.
+ // We can't hash a usvg tree directly, but the raw SVG data + a hash of
+ // all used fonts gives us something similar.
+ self.data.hash(state);
+ self.font_hash.hash(state);
+ }
+}
+
+/// The base conversion options, to be extended with font-related options
+/// because those can change across the document.
+fn base_options() -> usvg::Options<'static> {
+ usvg::Options {
+ // Disable usvg's default to "Times New Roman".
+ font_family: String::new(),
+
+ // We don't override the DPI here, because we already
+ // force the image into the corresponding DPI by setting
+ // the width and height. Changing the DPI only trips up
+ // the logic in `resvg`.
+
+ // Override usvg's resource loading defaults.
+ resources_dir: None,
+ image_href_resolver: usvg::ImageHrefResolver {
+ resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
+ resolve_string: Box::new(|_, _| None),
+ },
+
+ ..Default::default()
+ }
+}
+
+/// The pixel size of an SVG.
+fn tree_size(tree: &usvg::Tree) -> Axes<f64> {
+ Axes::new(tree.size().width() as f64, tree.size().height() as f64)
+}
+
+/// Format the user-facing SVG decoding error message.
+fn format_usvg_error(error: usvg::Error) -> EcoString {
+ match error {
+ usvg::Error::NotAnUtf8Str => "file is not valid utf-8".into(),
+ usvg::Error::MalformedGZip => "file is not compressed correctly".into(),
+ usvg::Error::ElementsLimitReached => "file is too large".into(),
+ usvg::Error::InvalidSize => {
+ "failed to parse SVG (width, height, or viewbox is invalid)".into()
+ }
+ usvg::Error::ParsingFailed(error) => format_xml_like_error("SVG", error),
+ }
+}
+
+/// Provides Typst's fonts to usvg.
+struct FontResolver<'a> {
+ /// Typst's font book.
+ book: &'a FontBook,
+ /// The world we use to load fonts.
+ world: Tracked<'a, dyn World + 'a>,
+ /// The active list of font families at the location of the SVG.
+ families: &'a [&'a str],
+ /// A mapping from Typst font indices to fontdb IDs.
+ to_id: HashMap<usize, Option<fontdb::ID>>,
+ /// The reverse mapping.
+ from_id: HashMap<fontdb::ID, Font>,
+ /// Accumulates a hash of all used fonts.
+ hasher: SipHasher13,
+}
+
+impl<'a> FontResolver<'a> {
+ /// Create a new font provider.
+ fn new(
+ world: Tracked<'a, dyn World + 'a>,
+ book: &'a FontBook,
+ families: &'a [&'a str],
+ ) -> Self {
+ Self {
+ book,
+ world,
+ families,
+ to_id: HashMap::new(),
+ from_id: HashMap::new(),
+ hasher: SipHasher13::new(),
+ }
+ }
+
+ /// Returns a hash of all used fonts.
+ fn finish(self) -> u128 {
+ self.hasher.finish128().as_u128()
+ }
+}
+
+impl FontResolver<'_> {
+ /// Select a font.
+ fn select_font(
+ &mut self,
+ font: &usvg::Font,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ let variant = FontVariant {
+ style: font.style().into(),
+ weight: FontWeight::from_number(font.weight()),
+ stretch: font.stretch().into(),
+ };
+
+ // Find a family that is available.
+ font.families()
+ .iter()
+ .filter_map(|family| match family {
+ usvg::FontFamily::Named(named) => Some(named.as_str()),
+ // We don't support generic families at the moment.
+ _ => None,
+ })
+ .chain(self.families.iter().copied())
+ .filter_map(|named| self.book.select(&named.to_lowercase(), variant))
+ .find_map(|index| self.get_or_load(index, db))
+ }
+
+ /// Select a fallback font.
+ fn select_fallback(
+ &mut self,
+ c: char,
+ exclude_fonts: &[fontdb::ID],
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ // Get the font info of the originally selected font.
+ let like = exclude_fonts
+ .first()
+ .and_then(|first| self.from_id.get(first))
+ .map(|font| font.info());
+
+ // usvg doesn't provide a variant in the fallback handler, but
+ // `exclude_fonts` is actually never empty in practice. Still, we
+ // prefer to fall back to the default variant rather than panicking
+ // in case that changes in the future.
+ let variant = like.map(|info| info.variant).unwrap_or_default();
+
+ // Select the font.
+ let index =
+ self.book.select_fallback(like, variant, c.encode_utf8(&mut [0; 4]))?;
+
+ self.get_or_load(index, db)
+ }
+
+ /// Tries to retrieve the ID for the index or loads the font, allocating
+ /// a new ID.
+ fn get_or_load(
+ &mut self,
+ index: usize,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ self.to_id
+ .get(&index)
+ .copied()
+ .unwrap_or_else(|| self.load(index, db))
+ }
+
+ /// Tries to load the font with the given index in the font book into the
+ /// database and returns its ID.
+ fn load(
+ &mut self,
+ index: usize,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ let font = self.world.font(index)?;
+ let info = font.info();
+ let variant = info.variant;
+ let id = Arc::make_mut(db).push_face_info(fontdb::FaceInfo {
+ id: fontdb::ID::dummy(),
+ source: fontdb::Source::Binary(Arc::new(font.data().clone())),
+ index: font.index(),
+ families: vec![(
+ info.family.clone(),
+ ttf_parser::Language::English_UnitedStates,
+ )],
+ post_script_name: String::new(),
+ style: match variant.style {
+ FontStyle::Normal => fontdb::Style::Normal,
+ FontStyle::Italic => fontdb::Style::Italic,
+ FontStyle::Oblique => fontdb::Style::Oblique,
+ },
+ weight: fontdb::Weight(variant.weight.to_number()),
+ stretch: match variant.stretch.round() {
+ FontStretch::ULTRA_CONDENSED => ttf_parser::Width::UltraCondensed,
+ FontStretch::EXTRA_CONDENSED => ttf_parser::Width::ExtraCondensed,
+ FontStretch::CONDENSED => ttf_parser::Width::Condensed,
+ FontStretch::SEMI_CONDENSED => ttf_parser::Width::SemiCondensed,
+ FontStretch::NORMAL => ttf_parser::Width::Normal,
+ FontStretch::SEMI_EXPANDED => ttf_parser::Width::SemiExpanded,
+ FontStretch::EXPANDED => ttf_parser::Width::Expanded,
+ FontStretch::EXTRA_EXPANDED => ttf_parser::Width::ExtraExpanded,
+ FontStretch::ULTRA_EXPANDED => ttf_parser::Width::UltraExpanded,
+ _ => unreachable!(),
+ },
+ monospaced: info.flags.contains(FontFlags::MONOSPACE),
+ });
+
+ font.hash(&mut self.hasher);
+
+ self.to_id.insert(index, Some(id));
+ self.from_id.insert(id, font);
+
+ Some(id)
+ }
+}
diff --git a/crates/typst-library/src/visualize/line.rs b/crates/typst-library/src/visualize/line.rs
new file mode 100644
index 00000000..d9ddab84
--- /dev/null
+++ b/crates/typst-library/src/visualize/line.rs
@@ -0,0 +1,64 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{elem, Content, NativeElement, Packed, Show, StyleChain};
+use crate::layout::{Abs, Angle, Axes, BlockElem, Length, Rel};
+use crate::visualize::Stroke;
+
+/// A line from one point to another.
+///
+/// # Example
+/// ```example
+/// #set page(height: 100pt)
+///
+/// #line(length: 100%)
+/// #line(end: (50%, 50%))
+/// #line(
+/// length: 4cm,
+/// stroke: 2pt + maroon,
+/// )
+/// ```
+#[elem(Show)]
+pub struct LineElem {
+ /// The start point of the line.
+ ///
+ /// Must be an array of exactly two relative lengths.
+ #[resolve]
+ pub start: Axes<Rel<Length>>,
+
+ /// The offset from `start` where the line ends.
+ #[resolve]
+ pub end: Option<Axes<Rel<Length>>>,
+
+ /// The line's length. This is only respected if `end` is `{none}`.
+ #[resolve]
+ #[default(Abs::pt(30.0).into())]
+ pub length: Rel<Length>,
+
+ /// The angle at which the line points away from the origin. This is only
+ /// respected if `end` is `{none}`.
+ pub angle: Angle,
+
+ /// How to [stroke] the line.
+ ///
+ /// ```example
+ /// #set line(length: 100%)
+ /// #stack(
+ /// spacing: 1em,
+ /// line(stroke: 2pt + red),
+ /// line(stroke: (paint: blue, thickness: 4pt, cap: "round")),
+ /// line(stroke: (paint: blue, thickness: 1pt, dash: "dashed")),
+ /// line(stroke: (paint: blue, thickness: 1pt, dash: ("dot", 2pt, 4pt, 2pt))),
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Stroke,
+}
+
+impl Show for Packed<LineElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_line)
+ .pack()
+ .spanned(self.span()))
+ }
+}
diff --git a/crates/typst-library/src/visualize/mod.rs b/crates/typst-library/src/visualize/mod.rs
new file mode 100644
index 00000000..5c8bf646
--- /dev/null
+++ b/crates/typst-library/src/visualize/mod.rs
@@ -0,0 +1,50 @@
+//! Drawing and visualization.
+
+mod color;
+mod gradient;
+mod image;
+mod line;
+mod paint;
+mod path;
+mod pattern;
+mod polygon;
+mod shape;
+mod stroke;
+
+pub use self::color::*;
+pub use self::gradient::*;
+pub use self::image::*;
+pub use self::line::*;
+pub use self::paint::*;
+pub use self::path::*;
+pub use self::pattern::*;
+pub use self::polygon::*;
+pub use self::shape::*;
+pub use self::stroke::*;
+
+use crate::foundations::{category, Category, Scope};
+
+/// Drawing and data visualization.
+///
+/// If you want to create more advanced drawings or plots, also have a look at
+/// the [CetZ](https://github.com/johannes-wolf/cetz) package as well as more
+/// specialized [packages]($universe) for your use case.
+#[category]
+pub static VISUALIZE: Category;
+
+/// Hook up all visualize definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.category(VISUALIZE);
+ global.define_type::<Color>();
+ global.define_type::<Gradient>();
+ global.define_type::<Pattern>();
+ global.define_type::<Stroke>();
+ global.define_elem::<ImageElem>();
+ global.define_elem::<LineElem>();
+ global.define_elem::<RectElem>();
+ global.define_elem::<SquareElem>();
+ global.define_elem::<EllipseElem>();
+ global.define_elem::<CircleElem>();
+ global.define_elem::<PolygonElem>();
+ global.define_elem::<PathElem>();
+}
diff --git a/crates/typst-library/src/visualize/paint.rs b/crates/typst-library/src/visualize/paint.rs
new file mode 100644
index 00000000..cd1006aa
--- /dev/null
+++ b/crates/typst-library/src/visualize/paint.rs
@@ -0,0 +1,102 @@
+use std::fmt::{self, Debug, Formatter};
+
+use ecow::EcoString;
+
+use crate::foundations::{cast, Repr, Smart};
+use crate::visualize::{Color, Gradient, Pattern, RelativeTo};
+
+/// How a fill or stroke should be painted.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub enum Paint {
+ /// A solid color.
+ Solid(Color),
+ /// A gradient.
+ Gradient(Gradient),
+ /// A pattern.
+ Pattern(Pattern),
+}
+
+impl Paint {
+ /// Unwraps a solid color used for text rendering.
+ pub fn unwrap_solid(&self) -> Color {
+ match self {
+ Self::Solid(color) => *color,
+ Self::Gradient(_) | Self::Pattern(_) => panic!("expected solid color"),
+ }
+ }
+
+ /// Gets the relative coordinate system for this paint.
+ pub fn relative(&self) -> Smart<RelativeTo> {
+ match self {
+ Self::Solid(_) => Smart::Auto,
+ Self::Gradient(gradient) => gradient.relative(),
+ Self::Pattern(pattern) => pattern.relative(),
+ }
+ }
+
+ /// Turns this paint into a paint for a text decoration.
+ ///
+ /// If this paint is a gradient, it will be converted to a gradient with
+ /// relative set to [`RelativeTo::Parent`].
+ pub fn as_decoration(&self) -> Self {
+ match self {
+ Self::Solid(color) => Self::Solid(*color),
+ Self::Gradient(gradient) => {
+ Self::Gradient(gradient.clone().with_relative(RelativeTo::Parent))
+ }
+ Self::Pattern(pattern) => {
+ Self::Pattern(pattern.clone().with_relative(RelativeTo::Parent))
+ }
+ }
+ }
+}
+
+impl Debug for Paint {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Solid(v) => v.fmt(f),
+ Self::Gradient(v) => v.fmt(f),
+ Self::Pattern(v) => v.fmt(f),
+ }
+ }
+}
+
+impl From<Pattern> for Paint {
+ fn from(pattern: Pattern) -> Self {
+ Self::Pattern(pattern)
+ }
+}
+
+impl Repr for Paint {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Solid(color) => color.repr(),
+ Self::Gradient(gradient) => gradient.repr(),
+ Self::Pattern(pattern) => pattern.repr(),
+ }
+ }
+}
+
+impl<T: Into<Color>> From<T> for Paint {
+ fn from(t: T) -> Self {
+ Self::Solid(t.into())
+ }
+}
+
+impl From<Gradient> for Paint {
+ fn from(gradient: Gradient) -> Self {
+ Self::Gradient(gradient)
+ }
+}
+
+cast! {
+ Paint,
+ self => match self {
+ Self::Solid(color) => color.into_value(),
+ Self::Gradient(gradient) => gradient.into_value(),
+ Self::Pattern(pattern) => pattern.into_value(),
+ },
+ color: Color => Self::Solid(color),
+ gradient: Gradient => Self::Gradient(gradient),
+ pattern: Pattern => Self::Pattern(pattern),
+}
diff --git a/crates/typst-library/src/visualize/path.rs b/crates/typst-library/src/visualize/path.rs
new file mode 100644
index 00000000..76fd0df0
--- /dev/null
+++ b/crates/typst-library/src/visualize/path.rs
@@ -0,0 +1,276 @@
+use kurbo::ParamCurveExtrema;
+
+use self::PathVertex::{AllControlPoints, MirroredControlPoint, Vertex};
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ array, cast, elem, Array, Content, NativeElement, Packed, Reflect, Show, Smart,
+ StyleChain,
+};
+use crate::layout::{Abs, Axes, BlockElem, Length, Point, Rel, Size};
+use crate::visualize::{FillRule, Paint, Stroke};
+
+/// A path through a list of points, connected by Bezier curves.
+///
+/// # Example
+/// ```example
+/// #path(
+/// fill: blue.lighten(80%),
+/// stroke: blue,
+/// closed: true,
+/// (0pt, 50pt),
+/// (100%, 50pt),
+/// ((50%, 0pt), (40pt, 0pt)),
+/// )
+/// ```
+#[elem(Show)]
+pub struct PathElem {
+ /// How to fill the path.
+ ///
+ /// When setting a fill, the default stroke disappears. To create a
+ /// rectangle with both fill and stroke, you have to configure both.
+ pub fill: Option<Paint>,
+
+ /// The drawing rule used to fill the path.
+ ///
+ /// ```example
+ /// // We use `.with` to get a new
+ /// // function that has the common
+ /// // arguments pre-applied.
+ /// #let star = path.with(
+ /// fill: red,
+ /// closed: true,
+ /// (25pt, 0pt),
+ /// (10pt, 50pt),
+ /// (50pt, 20pt),
+ /// (0pt, 20pt),
+ /// (40pt, 50pt),
+ /// )
+ ///
+ /// #star(fill-rule: "non-zero")
+ /// #star(fill-rule: "even-odd")
+ /// ```
+ #[default]
+ pub fill_rule: FillRule,
+
+ /// How to [stroke] the path. This can be:
+ ///
+ /// Can be set to `{none}` to disable the stroke or to `{auto}` for a
+ /// stroke of `{1pt}` black if and if only if no fill is given.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Option<Stroke>>,
+
+ /// Whether to close this path with one last bezier curve. This curve will
+ /// takes into account the adjacent control points. If you want to close
+ /// with a straight line, simply add one last point that's the same as the
+ /// start point.
+ #[default(false)]
+ pub closed: bool,
+
+ /// The vertices of the path.
+ ///
+ /// Each vertex can be defined in 3 ways:
+ ///
+ /// - A regular point, as given to the [`line`] or [`polygon`] function.
+ /// - An array of two points, the first being the vertex and the second
+ /// being the control point. The control point is expressed relative to
+ /// the vertex and is mirrored to get the second control point. The given
+ /// control point is the one that affects the curve coming _into_ this
+ /// vertex (even for the first point). The mirrored control point affects
+ /// the curve going out of this vertex.
+ /// - An array of three points, the first being the vertex and the next
+ /// being the control points (control point for curves coming in and out,
+ /// respectively).
+ #[variadic]
+ pub vertices: Vec<PathVertex>,
+}
+
+impl Show for Packed<PathElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_path)
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A component used for path creation.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum PathVertex {
+ Vertex(Axes<Rel<Length>>),
+ MirroredControlPoint(Axes<Rel<Length>>, Axes<Rel<Length>>),
+ AllControlPoints(Axes<Rel<Length>>, Axes<Rel<Length>>, Axes<Rel<Length>>),
+}
+
+impl PathVertex {
+ pub fn vertex(&self) -> Axes<Rel<Length>> {
+ match self {
+ Vertex(x) => *x,
+ MirroredControlPoint(x, _) => *x,
+ AllControlPoints(x, _, _) => *x,
+ }
+ }
+
+ pub fn control_point_from(&self) -> Axes<Rel<Length>> {
+ match self {
+ Vertex(_) => Axes::new(Rel::zero(), Rel::zero()),
+ MirroredControlPoint(_, a) => a.map(|x| -x),
+ AllControlPoints(_, _, b) => *b,
+ }
+ }
+
+ pub fn control_point_to(&self) -> Axes<Rel<Length>> {
+ match self {
+ Vertex(_) => Axes::new(Rel::zero(), Rel::zero()),
+ MirroredControlPoint(_, a) => *a,
+ AllControlPoints(_, a, _) => *a,
+ }
+ }
+}
+
+cast! {
+ PathVertex,
+ self => match self {
+ Vertex(x) => x.into_value(),
+ MirroredControlPoint(x, c) => array![x, c].into_value(),
+ AllControlPoints(x, c1, c2) => array![x, c1, c2].into_value(),
+ },
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next(), iter.next()) {
+ (Some(a), None, None, None) => {
+ Vertex(a.cast()?)
+ },
+ (Some(a), Some(b), None, None) => {
+ if Axes::<Rel<Length>>::castable(&a) {
+ MirroredControlPoint(a.cast()?, b.cast()?)
+ } else {
+ Vertex(Axes::new(a.cast()?, b.cast()?))
+ }
+ },
+ (Some(a), Some(b), Some(c), None) => {
+ AllControlPoints(a.cast()?, b.cast()?, c.cast()?)
+ },
+ _ => bail!("path vertex must have 1, 2, or 3 points"),
+ }
+ },
+}
+
+/// A bezier path.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct Path(pub Vec<PathItem>);
+
+/// An item in a bezier path.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum PathItem {
+ MoveTo(Point),
+ LineTo(Point),
+ CubicTo(Point, Point, Point),
+ ClosePath,
+}
+
+impl Path {
+ /// Create an empty path.
+ pub const fn new() -> Self {
+ Self(vec![])
+ }
+
+ /// Create a path that describes a rectangle.
+ pub fn rect(size: Size) -> Self {
+ let z = Abs::zero();
+ let point = Point::new;
+ let mut path = Self::new();
+ path.move_to(point(z, z));
+ path.line_to(point(size.x, z));
+ path.line_to(point(size.x, size.y));
+ path.line_to(point(z, size.y));
+ path.close_path();
+ path
+ }
+
+ /// Create a path that describes an axis-aligned ellipse.
+ pub fn ellipse(size: Size) -> Self {
+ // https://stackoverflow.com/a/2007782
+ let z = Abs::zero();
+ let rx = size.x / 2.0;
+ let ry = size.y / 2.0;
+ let m = 0.551784;
+ let mx = m * rx;
+ let my = m * ry;
+ let point = |x, y| Point::new(x + rx, y + ry);
+
+ let mut path = Path::new();
+ path.move_to(point(-rx, z));
+ path.cubic_to(point(-rx, -my), point(-mx, -ry), point(z, -ry));
+ path.cubic_to(point(mx, -ry), point(rx, -my), point(rx, z));
+ path.cubic_to(point(rx, my), point(mx, ry), point(z, ry));
+ path.cubic_to(point(-mx, ry), point(-rx, my), point(-rx, z));
+ path
+ }
+
+ /// Push a [`MoveTo`](PathItem::MoveTo) item.
+ pub fn move_to(&mut self, p: Point) {
+ self.0.push(PathItem::MoveTo(p));
+ }
+
+ /// Push a [`LineTo`](PathItem::LineTo) item.
+ pub fn line_to(&mut self, p: Point) {
+ self.0.push(PathItem::LineTo(p));
+ }
+
+ /// Push a [`CubicTo`](PathItem::CubicTo) item.
+ pub fn cubic_to(&mut self, p1: Point, p2: Point, p3: Point) {
+ self.0.push(PathItem::CubicTo(p1, p2, p3));
+ }
+
+ /// Push a [`ClosePath`](PathItem::ClosePath) item.
+ pub fn close_path(&mut self) {
+ self.0.push(PathItem::ClosePath);
+ }
+
+ /// Computes the size of bounding box of this path.
+ pub fn bbox_size(&self) -> Size {
+ let mut min_x = Abs::inf();
+ let mut min_y = Abs::inf();
+ let mut max_x = -Abs::inf();
+ let mut max_y = -Abs::inf();
+
+ let mut cursor = Point::zero();
+ for item in self.0.iter() {
+ match item {
+ PathItem::MoveTo(to) => {
+ min_x = min_x.min(cursor.x);
+ min_y = min_y.min(cursor.y);
+ max_x = max_x.max(cursor.x);
+ max_y = max_y.max(cursor.y);
+ cursor = *to;
+ }
+ PathItem::LineTo(to) => {
+ min_x = min_x.min(cursor.x);
+ min_y = min_y.min(cursor.y);
+ max_x = max_x.max(cursor.x);
+ max_y = max_y.max(cursor.y);
+ cursor = *to;
+ }
+ PathItem::CubicTo(c0, c1, end) => {
+ let cubic = kurbo::CubicBez::new(
+ kurbo::Point::new(cursor.x.to_pt(), cursor.y.to_pt()),
+ kurbo::Point::new(c0.x.to_pt(), c0.y.to_pt()),
+ kurbo::Point::new(c1.x.to_pt(), c1.y.to_pt()),
+ kurbo::Point::new(end.x.to_pt(), end.y.to_pt()),
+ );
+
+ let bbox = cubic.bounding_box();
+ min_x = min_x.min(Abs::pt(bbox.x0)).min(Abs::pt(bbox.x1));
+ min_y = min_y.min(Abs::pt(bbox.y0)).min(Abs::pt(bbox.y1));
+ max_x = max_x.max(Abs::pt(bbox.x0)).max(Abs::pt(bbox.x1));
+ max_y = max_y.max(Abs::pt(bbox.y0)).max(Abs::pt(bbox.y1));
+ cursor = *end;
+ }
+ PathItem::ClosePath => (),
+ }
+ }
+
+ Size::new(max_x - min_x, max_y - min_y)
+ }
+}
diff --git a/crates/typst-library/src/visualize/pattern.rs b/crates/typst-library/src/visualize/pattern.rs
new file mode 100644
index 00000000..2017ea65
--- /dev/null
+++ b/crates/typst-library/src/visualize/pattern.rs
@@ -0,0 +1,285 @@
+use std::hash::Hash;
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use typst_syntax::{Span, Spanned};
+use typst_utils::{LazyHash, Numeric};
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, repr, scope, ty, Content, Smart, StyleChain};
+use crate::introspection::Locator;
+use crate::layout::{Abs, Axes, Frame, Length, Region, Size};
+use crate::visualize::RelativeTo;
+use crate::World;
+
+/// A repeating pattern fill.
+///
+/// Typst supports the most common pattern type of tiled patterns, where a
+/// pattern is repeated in a grid-like fashion, covering the entire area of an
+/// element that is filled or stroked. The pattern is defined by a tile size and
+/// a body defining the content of each cell. You can also add horizontal or
+/// vertical spacing between the cells of the pattern.
+///
+/// # Examples
+///
+/// ```example
+/// #let pat = pattern(size: (30pt, 30pt))[
+/// #place(line(start: (0%, 0%), end: (100%, 100%)))
+/// #place(line(start: (0%, 100%), end: (100%, 0%)))
+/// ]
+///
+/// #rect(fill: pat, width: 100%, height: 60pt, stroke: 1pt)
+/// ```
+///
+/// Patterns are also supported on text, but only when setting the
+/// [relativeness]($pattern.relative) to either `{auto}` (the default value) or
+/// `{"parent"}`. To create word-by-word or glyph-by-glyph patterns, you can
+/// wrap the words or characters of your text in [boxes]($box) manually or
+/// through a [show rule]($styling/#show-rules).
+///
+/// ```example
+/// #let pat = pattern(
+/// size: (30pt, 30pt),
+/// relative: "parent",
+/// square(
+/// size: 30pt,
+/// fill: gradient
+/// .conic(..color.map.rainbow),
+/// )
+/// )
+///
+/// #set text(fill: pat)
+/// #lorem(10)
+/// ```
+///
+/// You can also space the elements further or closer apart using the
+/// [`spacing`]($pattern.spacing) feature of the pattern. If the spacing
+/// is lower than the size of the pattern, the pattern will overlap.
+/// If it is higher, the pattern will have gaps of the same color as the
+/// background of the pattern.
+///
+/// ```example
+/// #let pat = pattern(
+/// size: (30pt, 30pt),
+/// spacing: (10pt, 10pt),
+/// relative: "parent",
+/// square(
+/// size: 30pt,
+/// fill: gradient
+/// .conic(..color.map.rainbow),
+/// ),
+/// )
+///
+/// #rect(
+/// width: 100%,
+/// height: 60pt,
+/// fill: pat,
+/// )
+/// ```
+///
+/// # Relativeness
+/// The location of the starting point of the pattern is dependent on the
+/// dimensions of a container. This container can either be the shape that it is
+/// being painted on, or the closest surrounding container. This is controlled
+/// by the `relative` argument of a pattern constructor. By default, patterns
+/// are relative to the shape they are being painted on, unless the pattern is
+/// applied on text, in which case they are relative to the closest ancestor
+/// container.
+///
+/// Typst determines the ancestor container as follows:
+/// - For shapes that are placed at the root/top level of the document, the
+/// closest ancestor is the page itself.
+/// - For other shapes, the ancestor is the innermost [`block`] or [`box`] that
+/// contains the shape. This includes the boxes and blocks that are implicitly
+/// created by show rules and elements. For example, a [`rotate`] will not
+/// affect the parent of a gradient, but a [`grid`] will.
+#[ty(scope, cast)]
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Pattern(Arc<Repr>);
+
+/// Internal representation of [`Pattern`].
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+struct Repr {
+ /// The pattern's rendered content.
+ frame: LazyHash<Frame>,
+ /// The pattern's tile size.
+ size: Size,
+ /// The pattern's tile spacing.
+ spacing: Size,
+ /// The pattern's relative transform.
+ relative: Smart<RelativeTo>,
+}
+
+#[scope]
+impl Pattern {
+ /// Construct a new pattern.
+ ///
+ /// ```example
+ /// #let pat = pattern(
+ /// size: (20pt, 20pt),
+ /// relative: "parent",
+ /// place(
+ /// dx: 5pt,
+ /// dy: 5pt,
+ /// rotate(45deg, square(
+ /// size: 5pt,
+ /// fill: black,
+ /// )),
+ /// ),
+ /// )
+ ///
+ /// #rect(width: 100%, height: 60pt, fill: pat)
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ engine: &mut Engine,
+ /// The callsite span.
+ span: Span,
+ /// The bounding box of each cell of the pattern.
+ #[named]
+ #[default(Spanned::new(Smart::Auto, Span::detached()))]
+ size: Spanned<Smart<Axes<Length>>>,
+ /// The spacing between cells of the pattern.
+ #[named]
+ #[default(Spanned::new(Axes::splat(Length::zero()), Span::detached()))]
+ spacing: Spanned<Axes<Length>>,
+ /// The [relative placement](#relativeness) of the pattern.
+ ///
+ /// For an element placed at the root/top level of the document, the
+ /// parent is the page itself. For other elements, the parent is the
+ /// innermost block, box, column, grid, or stack that contains the
+ /// element.
+ #[named]
+ #[default(Smart::Auto)]
+ relative: Smart<RelativeTo>,
+ /// The content of each cell of the pattern.
+ body: Content,
+ ) -> SourceResult<Pattern> {
+ let size_span = size.span;
+ if let Smart::Custom(size) = size.v {
+ // Ensure that sizes are absolute.
+ if !size.x.em.is_zero() || !size.y.em.is_zero() {
+ bail!(size_span, "pattern tile size must be absolute");
+ }
+
+ // Ensure that sizes are non-zero and finite.
+ if size.x.is_zero()
+ || size.y.is_zero()
+ || !size.x.is_finite()
+ || !size.y.is_finite()
+ {
+ bail!(size_span, "pattern tile size must be non-zero and non-infinite");
+ }
+ }
+
+ // Ensure that spacing is absolute.
+ if !spacing.v.x.em.is_zero() || !spacing.v.y.em.is_zero() {
+ bail!(spacing.span, "pattern tile spacing must be absolute");
+ }
+
+ // Ensure that spacing is finite.
+ if !spacing.v.x.is_finite() || !spacing.v.y.is_finite() {
+ bail!(spacing.span, "pattern tile spacing must be finite");
+ }
+
+ // The size of the frame
+ let size = size.v.map(|l| l.map(|a| a.abs));
+ let region = size.unwrap_or_else(|| Axes::splat(Abs::inf()));
+
+ // Layout the pattern.
+ let world = engine.world;
+ let library = world.library();
+ let locator = Locator::root();
+ let styles = StyleChain::new(&library.styles);
+ let pod = Region::new(region, Axes::splat(false));
+ let mut frame =
+ (engine.routines.layout_frame)(engine, &body, locator, styles, pod)?;
+
+ // Set the size of the frame if the size is enforced.
+ if let Smart::Custom(size) = size {
+ frame.set_size(size);
+ }
+
+ // Check that the frame is non-zero.
+ if frame.width().is_zero() || frame.height().is_zero() {
+ bail!(
+ span, "pattern tile size must be non-zero";
+ hint: "try setting the size manually"
+ );
+ }
+
+ Ok(Self(Arc::new(Repr {
+ size: frame.size(),
+ frame: LazyHash::new(frame),
+ spacing: spacing.v.map(|l| l.abs),
+ relative,
+ })))
+ }
+}
+
+impl Pattern {
+ /// Set the relative placement of the pattern.
+ pub fn with_relative(mut self, relative: RelativeTo) -> Self {
+ if let Some(this) = Arc::get_mut(&mut self.0) {
+ this.relative = Smart::Custom(relative);
+ } else {
+ self.0 = Arc::new(Repr {
+ relative: Smart::Custom(relative),
+ ..self.0.as_ref().clone()
+ });
+ }
+
+ self
+ }
+
+ /// Return the frame of the pattern.
+ pub fn frame(&self) -> &Frame {
+ &self.0.frame
+ }
+
+ /// Return the size of the pattern in absolute units.
+ pub fn size(&self) -> Size {
+ self.0.size
+ }
+
+ /// Return the spacing of the pattern in absolute units.
+ pub fn spacing(&self) -> Size {
+ self.0.spacing
+ }
+
+ /// Returns the relative placement of the pattern.
+ pub fn relative(&self) -> Smart<RelativeTo> {
+ self.0.relative
+ }
+
+ /// Returns the relative placement of the pattern.
+ pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo {
+ self.0.relative.unwrap_or_else(|| {
+ if on_text {
+ RelativeTo::Parent
+ } else {
+ RelativeTo::Self_
+ }
+ })
+ }
+}
+
+impl repr::Repr for Pattern {
+ fn repr(&self) -> EcoString {
+ let mut out =
+ eco_format!("pattern(({}, {})", self.0.size.x.repr(), self.0.size.y.repr());
+
+ if self.0.spacing.is_zero() {
+ out.push_str(", spacing: (");
+ out.push_str(&self.0.spacing.x.repr());
+ out.push_str(", ");
+ out.push_str(&self.0.spacing.y.repr());
+ out.push(')');
+ }
+
+ out.push_str(", ..)");
+
+ out
+ }
+}
diff --git a/crates/typst-library/src/visualize/polygon.rs b/crates/typst-library/src/visualize/polygon.rs
new file mode 100644
index 00000000..33e4fd32
--- /dev/null
+++ b/crates/typst-library/src/visualize/polygon.rs
@@ -0,0 +1,135 @@
+use std::f64::consts::PI;
+
+use typst_syntax::Span;
+
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, func, scope, Content, NativeElement, Packed, Show, Smart, StyleChain,
+};
+use crate::layout::{Axes, BlockElem, Em, Length, Rel};
+use crate::visualize::{FillRule, Paint, Stroke};
+
+/// A closed polygon.
+///
+/// The polygon is defined by its corner points and is closed automatically.
+///
+/// # Example
+/// ```example
+/// #polygon(
+/// fill: blue.lighten(80%),
+/// stroke: blue,
+/// (20%, 0pt),
+/// (60%, 0pt),
+/// (80%, 2cm),
+/// (0%, 2cm),
+/// )
+/// ```
+#[elem(scope, Show)]
+pub struct PolygonElem {
+ /// How to fill the polygon.
+ ///
+ /// When setting a fill, the default stroke disappears. To create a
+ /// rectangle with both fill and stroke, you have to configure both.
+ pub fill: Option<Paint>,
+
+ /// The drawing rule used to fill the polygon.
+ ///
+ /// See the [path documentation]($path.fill-rule) for an example.
+ #[default]
+ pub fill_rule: FillRule,
+
+ /// How to [stroke] the polygon. This can be:
+ ///
+ /// Can be set to `{none}` to disable the stroke or to `{auto}` for a
+ /// stroke of `{1pt}` black if and if only if no fill is given.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Option<Stroke>>,
+
+ /// The vertices of the polygon. Each point is specified as an array of two
+ /// [relative lengths]($relative).
+ #[variadic]
+ pub vertices: Vec<Axes<Rel<Length>>>,
+}
+
+#[scope]
+impl PolygonElem {
+ /// A regular polygon, defined by its size and number of vertices.
+ ///
+ /// ```example
+ /// #polygon.regular(
+ /// fill: blue.lighten(80%),
+ /// stroke: blue,
+ /// size: 30pt,
+ /// vertices: 3,
+ /// )
+ /// ```
+ #[func(title = "Regular Polygon")]
+ pub fn regular(
+ /// The call span of this function.
+ span: Span,
+ /// How to fill the polygon. See the general
+ /// [polygon's documentation]($polygon.fill) for more details.
+ #[named]
+ fill: Option<Option<Paint>>,
+
+ /// How to stroke the polygon. See the general
+ /// [polygon's documentation]($polygon.stroke) for more details.
+ #[named]
+ stroke: Option<Smart<Option<Stroke>>>,
+
+ /// The diameter of the [circumcircle](https://en.wikipedia.org/wiki/Circumcircle)
+ /// of the regular polygon.
+ #[named]
+ #[default(Em::one().into())]
+ size: Length,
+
+ /// The number of vertices in the polygon.
+ #[named]
+ #[default(3)]
+ vertices: u64,
+ ) -> Content {
+ let radius = size / 2.0;
+ let angle = |i: f64| {
+ 2.0 * PI * i / (vertices as f64) + PI * (1.0 / 2.0 - 1.0 / vertices as f64)
+ };
+ let (horizontal_offset, vertical_offset) = (0..=vertices)
+ .map(|v| {
+ (
+ (radius * angle(v as f64).cos()) + radius,
+ (radius * angle(v as f64).sin()) + radius,
+ )
+ })
+ .fold((radius, radius), |(min_x, min_y), (v_x, v_y)| {
+ (
+ if min_x < v_x { min_x } else { v_x },
+ if min_y < v_y { min_y } else { v_y },
+ )
+ });
+ let vertices = (0..=vertices)
+ .map(|v| {
+ let x = (radius * angle(v as f64).cos()) + radius - horizontal_offset;
+ let y = (radius * angle(v as f64).sin()) + radius - vertical_offset;
+ Axes::new(x, y).map(Rel::from)
+ })
+ .collect();
+
+ let mut elem = PolygonElem::new(vertices);
+ if let Some(fill) = fill {
+ elem.push_fill(fill);
+ }
+ if let Some(stroke) = stroke {
+ elem.push_stroke(stroke);
+ }
+ elem.pack().spanned(span)
+ }
+}
+
+impl Show for Packed<PolygonElem> {
+ fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_polygon)
+ .pack()
+ .spanned(self.span()))
+ }
+}
diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs
new file mode 100644
index 00000000..01e316a8
--- /dev/null
+++ b/crates/typst-library/src/visualize/shape.rs
@@ -0,0 +1,448 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::{
+ elem, Cast, Content, NativeElement, Packed, Show, Smart, StyleChain,
+};
+use crate::layout::{Abs, BlockElem, Corners, Length, Point, Rel, Sides, Size, Sizing};
+use crate::visualize::{FixedStroke, Paint, Path, Stroke};
+
+/// A rectangle with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #rect(width: 35%, height: 30pt)
+///
+/// // With content.
+/// #rect[
+/// Automatically sized \
+/// to fit the content.
+/// ]
+/// ```
+#[elem(title = "Rectangle", Show)]
+pub struct RectElem {
+ /// The rectangle's width, relative to its parent container.
+ pub width: Smart<Rel<Length>>,
+
+ /// The rectangle's height, relative to its parent container.
+ pub height: Sizing,
+
+ /// How to fill the rectangle.
+ ///
+ /// When setting a fill, the default stroke disappears. To create a
+ /// rectangle with both fill and stroke, you have to configure both.
+ ///
+ /// ```example
+ /// #rect(fill: blue)
+ /// ```
+ pub fill: Option<Paint>,
+
+ /// How to stroke the rectangle. This can be:
+ ///
+ /// - `{none}` to disable stroking
+ /// - `{auto}` for a stroke of `{1pt + black}` if and if only if no fill is
+ /// given.
+ /// - Any kind of [stroke]
+ /// - A dictionary describing the stroke for each side individually. The
+ /// dictionary can contain the following keys in order of precedence:
+ /// - `top`: The top stroke.
+ /// - `right`: The right stroke.
+ /// - `bottom`: The bottom stroke.
+ /// - `left`: The left stroke.
+ /// - `x`: The horizontal stroke.
+ /// - `y`: The vertical stroke.
+ /// - `rest`: The stroke on all sides except those for which the
+ /// dictionary explicitly sets a size.
+ ///
+ /// ```example
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1fr,
+ /// rect(stroke: red),
+ /// rect(stroke: 2pt),
+ /// rect(stroke: 2pt + red),
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Sides<Option<Option<Stroke>>>>,
+
+ /// How much to round the rectangle's corners, relative to the minimum of
+ /// the width and height divided by two. This can be:
+ ///
+ /// - A relative length for a uniform corner radius.
+ /// - A dictionary: With a dictionary, the stroke for each side can be set
+ /// individually. The dictionary can contain the following keys in order
+ /// of precedence:
+ /// - `top-left`: The top-left corner radius.
+ /// - `top-right`: The top-right corner radius.
+ /// - `bottom-right`: The bottom-right corner radius.
+ /// - `bottom-left`: The bottom-left corner radius.
+ /// - `left`: The top-left and bottom-left corner radii.
+ /// - `top`: The top-left and top-right corner radii.
+ /// - `right`: The top-right and bottom-right corner radii.
+ /// - `bottom`: The bottom-left and bottom-right corner radii.
+ /// - `rest`: The radii for all corners except those for which the
+ /// dictionary explicitly sets a size.
+ ///
+ /// ```example
+ /// #set rect(stroke: 4pt)
+ /// #rect(
+ /// radius: (
+ /// left: 5pt,
+ /// top-right: 20pt,
+ /// bottom-right: 10pt,
+ /// ),
+ /// stroke: (
+ /// left: red,
+ /// top: yellow,
+ /// right: green,
+ /// bottom: blue,
+ /// ),
+ /// )
+ /// ```
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the rectangle's content.
+ /// See the [box's documentation]($box.outset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(Abs::pt(5.0).into())))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the rectangle's size without affecting the layout.
+ /// See the [box's documentation]($box.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the rectangle.
+ ///
+ /// When this is omitted, the rectangle takes on a default size of at most
+ /// `{45pt}` by `{30pt}`.
+ #[positional]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<RectElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_rect)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A square with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #square(size: 40pt)
+///
+/// // With content.
+/// #square[
+/// Automatically \
+/// sized to fit.
+/// ]
+/// ```
+#[elem(Show)]
+pub struct SquareElem {
+ /// The square's side length. This is mutually exclusive with `width` and
+ /// `height`.
+ #[external]
+ pub size: Smart<Length>,
+
+ /// The square's width. This is mutually exclusive with `size` and `height`.
+ ///
+ /// In contrast to `size`, this can be relative to the parent container's
+ /// width.
+ #[parse(
+ let size = args.named::<Smart<Length>>("size")?.map(|s| s.map(Rel::from));
+ match size {
+ None => args.named("width")?,
+ size => size,
+ }
+ )]
+ pub width: Smart<Rel<Length>>,
+
+ /// The square's height. This is mutually exclusive with `size` and `width`.
+ ///
+ /// In contrast to `size`, this can be relative to the parent container's
+ /// height.
+ #[parse(match size {
+ None => args.named("height")?,
+ size => size.map(Into::into),
+ })]
+ pub height: Sizing,
+
+ /// How to fill the square. See the [rectangle's documentation]($rect.fill)
+ /// for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the square. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Sides<Option<Option<Stroke>>>>,
+
+ /// How much to round the square's corners. See the
+ /// [rectangle's documentation]($rect.radius) for more details.
+ #[resolve]
+ #[fold]
+ pub radius: Corners<Option<Rel<Length>>>,
+
+ /// How much to pad the square's content. See the
+ /// [box's documentation]($box.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(Abs::pt(5.0).into())))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the square's size without affecting the layout. See
+ /// the [box's documentation]($box.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the square. The square expands to fit this
+ /// content, keeping the 1-1 aspect ratio.
+ ///
+ /// When this is omitted, the square takes on a default size of at most
+ /// `{30pt}`.
+ #[positional]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<SquareElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_square)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// An ellipse with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #ellipse(width: 35%, height: 30pt)
+///
+/// // With content.
+/// #ellipse[
+/// #set align(center)
+/// Automatically sized \
+/// to fit the content.
+/// ]
+/// ```
+#[elem(Show)]
+pub struct EllipseElem {
+ /// The ellipse's width, relative to its parent container.
+ pub width: Smart<Rel<Length>>,
+
+ /// The ellipse's height, relative to its parent container.
+ pub height: Sizing,
+
+ /// How to fill the ellipse. See the [rectangle's documentation]($rect.fill)
+ /// for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the ellipse. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Smart<Option<Stroke>>,
+
+ /// How much to pad the ellipse's content. See the
+ /// [box's documentation]($box.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(Abs::pt(5.0).into())))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the ellipse's size without affecting the layout. See
+ /// the [box's documentation]($box.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the ellipse.
+ ///
+ /// When this is omitted, the ellipse takes on a default size of at most
+ /// `{45pt}` by `{30pt}`.
+ #[positional]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<EllipseElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_ellipse)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A circle with optional content.
+///
+/// # Example
+/// ```example
+/// // Without content.
+/// #circle(radius: 25pt)
+///
+/// // With content.
+/// #circle[
+/// #set align(center + horizon)
+/// Automatically \
+/// sized to fit.
+/// ]
+/// ```
+#[elem(Show)]
+pub struct CircleElem {
+ /// The circle's radius. This is mutually exclusive with `width` and
+ /// `height`.
+ #[external]
+ pub radius: Length,
+
+ /// The circle's width. This is mutually exclusive with `radius` and
+ /// `height`.
+ ///
+ /// In contrast to `radius`, this can be relative to the parent container's
+ /// width.
+ #[parse(
+ let size = args
+ .named::<Smart<Length>>("radius")?
+ .map(|s| s.map(|r| 2.0 * Rel::from(r)));
+ match size {
+ None => args.named("width")?,
+ size => size,
+ }
+ )]
+ pub width: Smart<Rel<Length>>,
+
+ /// The circle's height. This is mutually exclusive with `radius` and
+ /// `width`.
+ ///
+ /// In contrast to `radius`, this can be relative to the parent container's
+ /// height.
+ #[parse(match size {
+ None => args.named("height")?,
+ size => size.map(Into::into),
+ })]
+ pub height: Sizing,
+
+ /// How to fill the circle. See the [rectangle's documentation]($rect.fill)
+ /// for more details.
+ pub fill: Option<Paint>,
+
+ /// How to stroke the circle. See the
+ /// [rectangle's documentation]($rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Smart::Auto)]
+ pub stroke: Smart<Option<Stroke>>,
+
+ /// How much to pad the circle's content. See the
+ /// [box's documentation]($box.inset) for more details.
+ #[resolve]
+ #[fold]
+ #[default(Sides::splat(Some(Abs::pt(5.0).into())))]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the circle's size without affecting the layout. See
+ /// the [box's documentation]($box.outset) for more details.
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// The content to place into the circle. The circle expands to fit this
+ /// content, keeping the 1-1 aspect ratio.
+ #[positional]
+ #[borrowed]
+ pub body: Option<Content>,
+}
+
+impl Show for Packed<CircleElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_circle)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+/// A geometric shape with optional fill and stroke.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Shape {
+ /// The shape's geometry.
+ pub geometry: Geometry,
+ /// The shape's background fill.
+ pub fill: Option<Paint>,
+ /// The shape's fill rule.
+ pub fill_rule: FillRule,
+ /// The shape's border stroke.
+ pub stroke: Option<FixedStroke>,
+}
+
+/// A path filling rule.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum FillRule {
+ /// Specifies that "inside" is computed by a non-zero sum of signed edge crossings.
+ #[default]
+ NonZero,
+ /// Specifies that "inside" is computed by an odd number of edge crossings.
+ EvenOdd,
+}
+
+/// A shape's geometry.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum Geometry {
+ /// A line to a point (relative to its position).
+ Line(Point),
+ /// A rectangle with its origin in the topleft corner.
+ Rect(Size),
+ /// A bezier path.
+ Path(Path),
+}
+
+impl Geometry {
+ /// Fill the geometry without a stroke.
+ pub fn filled(self, fill: impl Into<Paint>) -> Shape {
+ Shape {
+ geometry: self,
+ fill: Some(fill.into()),
+ fill_rule: FillRule::default(),
+ stroke: None,
+ }
+ }
+
+ /// Stroke the geometry without a fill.
+ pub fn stroked(self, stroke: FixedStroke) -> Shape {
+ Shape {
+ geometry: self,
+ fill: None,
+ fill_rule: FillRule::default(),
+ stroke: Some(stroke),
+ }
+ }
+
+ /// The bounding box of the geometry.
+ pub fn bbox_size(&self) -> Size {
+ match self {
+ Self::Line(line) => Size::new(line.x, line.y),
+ Self::Rect(s) => *s,
+ Self::Path(p) => p.bbox_size(),
+ }
+ }
+}
diff --git a/crates/typst-library/src/visualize/stroke.rs b/crates/typst-library/src/visualize/stroke.rs
new file mode 100644
index 00000000..4ca10920
--- /dev/null
+++ b/crates/typst-library/src/visualize/stroke.rs
@@ -0,0 +1,617 @@
+use ecow::EcoString;
+use typst_utils::{Numeric, Scalar};
+
+use crate::diag::{HintedStrResult, SourceResult};
+use crate::foundations::{
+ cast, dict, func, scope, ty, Args, Cast, Dict, Fold, FromValue, NoneValue, Repr,
+ Resolve, Smart, StyleChain, Value,
+};
+use crate::layout::{Abs, Length};
+use crate::visualize::{Color, Gradient, Paint, Pattern};
+
+/// Defines how to draw a line.
+///
+/// A stroke has a _paint_ (a solid color or gradient), a _thickness,_ a line
+/// _cap,_ a line _join,_ a _miter limit,_ and a _dash_ pattern. All of these
+/// values are optional and have sensible defaults.
+///
+/// # Example
+/// ```example
+/// #set line(length: 100%)
+/// #stack(
+/// spacing: 1em,
+/// line(stroke: 2pt + red),
+/// line(stroke: (paint: blue, thickness: 4pt, cap: "round")),
+/// line(stroke: (paint: blue, thickness: 1pt, dash: "dashed")),
+/// line(stroke: 2pt + gradient.linear(..color.map.rainbow)),
+/// )
+/// ```
+///
+/// # Simple strokes
+/// You can create a simple solid stroke from a color, a thickness, or a
+/// combination of the two. Specifically, wherever a stroke is expected you can
+/// pass any of the following values:
+///
+/// - A length specifying the stroke's thickness. The color is inherited,
+/// defaulting to black.
+/// - A color to use for the stroke. The thickness is inherited, defaulting to
+/// `{1pt}`.
+/// - A stroke combined from color and thickness using the `+` operator as in
+/// `{2pt + red}`.
+///
+/// For full control, you can also provide a [dictionary] or a `{stroke}` object
+/// to any function that expects a stroke. The dictionary's keys may include any
+/// of the parameters for the constructor function, shown below.
+///
+/// # Fields
+/// On a stroke object, you can access any of the fields listed in the
+/// constructor function. For example, `{(2pt + blue).thickness}` is `{2pt}`.
+/// Meanwhile, `{stroke(red).cap}` is `{auto}` because it's unspecified. Fields
+/// set to `{auto}` are inherited.
+#[ty(scope, cast)]
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct Stroke<T: Numeric = Length> {
+ /// The stroke's paint.
+ pub paint: Smart<Paint>,
+ /// The stroke's thickness.
+ pub thickness: Smart<T>,
+ /// The stroke's line cap.
+ pub cap: Smart<LineCap>,
+ /// The stroke's line join.
+ pub join: Smart<LineJoin>,
+ /// The stroke's line dash pattern.
+ pub dash: Smart<Option<DashPattern<T>>>,
+ /// The miter limit.
+ pub miter_limit: Smart<Scalar>,
+}
+
+impl Stroke {
+ /// Create a stroke from a paint and a thickness.
+ pub fn from_pair(paint: impl Into<Paint>, thickness: Length) -> Self {
+ Self {
+ paint: Smart::Custom(paint.into()),
+ thickness: Smart::Custom(thickness),
+ ..Default::default()
+ }
+ }
+}
+
+#[scope]
+impl Stroke {
+ /// Converts a value to a stroke or constructs a stroke with the given
+ /// parameters.
+ ///
+ /// Note that in most cases you do not need to convert values to strokes in
+ /// order to use them, as they will be converted automatically. However,
+ /// this constructor can be useful to ensure a value has all the fields of a
+ /// stroke.
+ ///
+ /// ```example
+ /// #let my-func(x) = {
+ /// x = stroke(x) // Convert to a stroke
+ /// [Stroke has thickness #x.thickness.]
+ /// }
+ /// #my-func(3pt) \
+ /// #my-func(red) \
+ /// #my-func(stroke(cap: "round", thickness: 1pt))
+ /// ```
+ #[func(constructor)]
+ pub fn construct(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+
+ /// The color or gradient to use for the stroke.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{black}`.
+ #[external]
+ paint: Smart<Paint>,
+
+ /// The stroke's thickness.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{1pt}`.
+ #[external]
+ thickness: Smart<Length>,
+
+ /// How the ends of the stroke are rendered.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{"butt"}`.
+ #[external]
+ cap: Smart<LineCap>,
+
+ /// How sharp turns are rendered.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{"miter"}`.
+ #[external]
+ join: Smart<LineJoin>,
+
+ /// The dash pattern to use. This can be:
+ ///
+ /// - One of the predefined patterns:
+ /// - `{"solid"}` or `{none}`
+ /// - `{"dotted"}`
+ /// - `{"densely-dotted"}`
+ /// - `{"loosely-dotted"}`
+ /// - `{"dashed"}`
+ /// - `{"densely-dashed"}`
+ /// - `{"loosely-dashed"}`
+ /// - `{"dash-dotted"}`
+ /// - `{"densely-dash-dotted"}`
+ /// - `{"loosely-dash-dotted"}`
+ /// - An [array] with alternating lengths for dashes and gaps. You can
+ /// also use the string `{"dot"}` for a length equal to the line
+ /// thickness.
+ /// - A [dictionary] with the keys `array` (same as the array above),
+ /// and `phase` (of type [length]), which defines where in the pattern
+ /// to start drawing.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{none}`.
+ ///
+ /// ```example
+ /// #set line(length: 100%, stroke: 2pt)
+ /// #stack(
+ /// spacing: 1em,
+ /// line(stroke: (dash: "dashed")),
+ /// line(stroke: (dash: (10pt, 5pt, "dot", 5pt))),
+ /// line(stroke: (dash: (array: (10pt, 5pt, "dot", 5pt), phase: 10pt))),
+ /// )
+ /// ```
+ #[external]
+ dash: Smart<Option<DashPattern>>,
+
+ /// Number at which protruding sharp bends are rendered with a bevel
+ /// instead or a miter join. The higher the number, the sharper an angle
+ /// can be before it is bevelled. Only applicable if `join` is
+ /// `{"miter"}`.
+ ///
+ /// Specifically, the miter limit is the maximum ratio between the
+ /// corner's protrusion length and the stroke's thickness.
+ ///
+ /// If set to `{auto}`, the value is inherited, defaulting to `{4.0}`.
+ ///
+ /// ```example
+ /// #let points = ((15pt, 0pt), (0pt, 30pt), (30pt, 30pt), (10pt, 20pt))
+ /// #set path(stroke: 6pt + blue)
+ /// #stack(
+ /// dir: ltr,
+ /// spacing: 1cm,
+ /// path(stroke: (miter-limit: 1), ..points),
+ /// path(stroke: (miter-limit: 4), ..points),
+ /// path(stroke: (miter-limit: 5), ..points),
+ /// )
+ /// ```
+ #[external]
+ miter_limit: Smart<f64>,
+ ) -> SourceResult<Stroke> {
+ if let Some(stroke) = args.eat::<Stroke>()? {
+ return Ok(stroke);
+ }
+
+ fn take<T: FromValue>(args: &mut Args, arg: &str) -> SourceResult<Smart<T>> {
+ Ok(args.named::<Smart<T>>(arg)?.unwrap_or(Smart::Auto))
+ }
+
+ let paint = take::<Paint>(args, "paint")?;
+ let thickness = take::<Length>(args, "thickness")?;
+ let cap = take::<LineCap>(args, "cap")?;
+ let join = take::<LineJoin>(args, "join")?;
+ let dash = take::<Option<DashPattern>>(args, "dash")?;
+ let miter_limit = take::<f64>(args, "miter-limit")?.map(Scalar::new);
+
+ Ok(Self { paint, thickness, cap, join, dash, miter_limit })
+ }
+}
+
+impl<T: Numeric> Stroke<T> {
+ /// Map the contained lengths with `f`.
+ pub fn map<F, U: Numeric>(self, f: F) -> Stroke<U>
+ where
+ F: Fn(T) -> U,
+ {
+ Stroke {
+ paint: self.paint,
+ thickness: self.thickness.map(&f),
+ cap: self.cap,
+ join: self.join,
+ dash: self.dash.map(|pattern| {
+ pattern.map(|pattern| DashPattern {
+ array: pattern
+ .array
+ .into_iter()
+ .map(|l| match l {
+ DashLength::Length(v) => DashLength::Length(f(v)),
+ DashLength::LineWidth => DashLength::LineWidth,
+ })
+ .collect(),
+ phase: f(pattern.phase),
+ })
+ }),
+ miter_limit: self.miter_limit,
+ }
+ }
+}
+
+impl Stroke<Abs> {
+ /// Unpack the stroke, filling missing fields from the `default`.
+ pub fn unwrap_or(self, default: FixedStroke) -> FixedStroke {
+ let thickness = self.thickness.unwrap_or(default.thickness);
+ let dash = self
+ .dash
+ .map(|pattern| {
+ pattern.map(|pattern| DashPattern {
+ array: pattern
+ .array
+ .into_iter()
+ .map(|l| l.finish(thickness))
+ .collect(),
+ phase: pattern.phase,
+ })
+ })
+ .unwrap_or(default.dash);
+
+ FixedStroke {
+ paint: self.paint.unwrap_or(default.paint),
+ thickness,
+ cap: self.cap.unwrap_or(default.cap),
+ join: self.join.unwrap_or(default.join),
+ dash,
+ miter_limit: self.miter_limit.unwrap_or(default.miter_limit),
+ }
+ }
+
+ /// Unpack the stroke, filling missing fields with the default values.
+ pub fn unwrap_or_default(self) -> FixedStroke {
+ // we want to do this; the Clippy lint is not type-aware
+ #[allow(clippy::unwrap_or_default)]
+ self.unwrap_or(FixedStroke::default())
+ }
+}
+
+impl<T: Numeric + Repr> Repr for Stroke<T> {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::new();
+ let Self { paint, thickness, cap, join, dash, miter_limit } = &self;
+ if cap.is_auto() && join.is_auto() && dash.is_auto() && miter_limit.is_auto() {
+ match (&self.paint, &self.thickness) {
+ (Smart::Custom(paint), Smart::Custom(thickness)) => {
+ r.push_str(&thickness.repr());
+ r.push_str(" + ");
+ r.push_str(&paint.repr());
+ }
+ (Smart::Custom(paint), Smart::Auto) => r.push_str(&paint.repr()),
+ (Smart::Auto, Smart::Custom(thickness)) => r.push_str(&thickness.repr()),
+ (Smart::Auto, Smart::Auto) => r.push_str("1pt + black"),
+ }
+ } else {
+ r.push('(');
+ let mut sep = "";
+ if let Smart::Custom(paint) = &paint {
+ r.push_str(sep);
+ r.push_str("paint: ");
+ r.push_str(&paint.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(thickness) = &thickness {
+ r.push_str(sep);
+ r.push_str("thickness: ");
+ r.push_str(&thickness.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(cap) = &cap {
+ r.push_str(sep);
+ r.push_str("cap: ");
+ r.push_str(&cap.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(join) = &join {
+ r.push_str(sep);
+ r.push_str("join: ");
+ r.push_str(&join.repr());
+ sep = ", ";
+ }
+ if let Smart::Custom(dash) = &dash {
+ r.push_str(sep);
+ r.push_str("dash: ");
+ if let Some(dash) = dash {
+ r.push_str(&dash.repr());
+ } else {
+ r.push_str(&NoneValue.repr());
+ }
+ sep = ", ";
+ }
+ if let Smart::Custom(miter_limit) = &miter_limit {
+ r.push_str(sep);
+ r.push_str("miter-limit: ");
+ r.push_str(&miter_limit.get().repr());
+ }
+ r.push(')');
+ }
+ r
+ }
+}
+
+impl<T: Numeric + Fold> Fold for Stroke<T> {
+ fn fold(self, outer: Self) -> Self {
+ Self {
+ paint: self.paint.or(outer.paint),
+ thickness: self.thickness.or(outer.thickness),
+ cap: self.cap.or(outer.cap),
+ join: self.join.or(outer.join),
+ dash: self.dash.or(outer.dash),
+ miter_limit: self.miter_limit.or(outer.miter_limit),
+ }
+ }
+}
+
+impl Resolve for Stroke {
+ type Output = Stroke<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ Stroke {
+ paint: self.paint,
+ thickness: self.thickness.resolve(styles),
+ cap: self.cap,
+ join: self.join,
+ dash: self.dash.resolve(styles),
+ miter_limit: self.miter_limit,
+ }
+ }
+}
+
+cast! {
+ type Stroke,
+ thickness: Length => Self {
+ thickness: Smart::Custom(thickness),
+ ..Default::default()
+ },
+ color: Color => Self {
+ paint: Smart::Custom(color.into()),
+ ..Default::default()
+ },
+ gradient: Gradient => Self {
+ paint: Smart::Custom(gradient.into()),
+ ..Default::default()
+ },
+ pattern: Pattern => Self {
+ paint: Smart::Custom(pattern.into()),
+ ..Default::default()
+ },
+ mut dict: Dict => {
+ // Get a value by key, accepting either Auto or something convertible to type T.
+ fn take<T: FromValue>(dict: &mut Dict, key: &str) -> HintedStrResult<Smart<T>> {
+ Ok(dict.take(key).ok().map(Smart::<T>::from_value)
+ .transpose()?.unwrap_or(Smart::Auto))
+ }
+
+ let paint = take::<Paint>(&mut dict, "paint")?;
+ let thickness = take::<Length>(&mut dict, "thickness")?;
+ let cap = take::<LineCap>(&mut dict, "cap")?;
+ let join = take::<LineJoin>(&mut dict, "join")?;
+ let dash = take::<Option<DashPattern>>(&mut dict, "dash")?;
+ let miter_limit = take::<f64>(&mut dict, "miter-limit")?;
+ dict.finish(&["paint", "thickness", "cap", "join", "dash", "miter-limit"])?;
+
+ Self {
+ paint,
+ thickness,
+ cap,
+ join,
+ dash,
+ miter_limit: miter_limit.map(Scalar::new),
+ }
+ },
+}
+
+cast! {
+ Stroke<Abs>,
+ self => self.map(Length::from).into_value(),
+}
+
+/// The line cap of a stroke
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum LineCap {
+ /// Square stroke cap with the edge at the stroke's end point.
+ Butt,
+ /// Circular stroke cap centered at the stroke's end point.
+ Round,
+ /// Square stroke cap centered at the stroke's end point.
+ Square,
+}
+
+impl Repr for LineCap {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Butt => "butt".repr(),
+ Self::Round => "round".repr(),
+ Self::Square => "square".repr(),
+ }
+ }
+}
+
+/// The line join of a stroke
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum LineJoin {
+ /// Segments are joined with sharp edges. Sharp bends exceeding the miter
+ /// limit are bevelled instead.
+ Miter,
+ /// Segments are joined with circular corners.
+ Round,
+ /// Segments are joined with a bevel (a straight edge connecting the butts
+ /// of the joined segments).
+ Bevel,
+}
+
+impl Repr for LineJoin {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::Miter => "miter".repr(),
+ Self::Round => "round".repr(),
+ Self::Bevel => "bevel".repr(),
+ }
+ }
+}
+
+/// A line dash pattern.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct DashPattern<T: Numeric = Length, DT = DashLength<T>> {
+ /// The dash array.
+ pub array: Vec<DT>,
+ /// The dash phase.
+ pub phase: T,
+}
+
+impl<T: Numeric + Repr, DT: Repr> Repr for DashPattern<T, DT> {
+ fn repr(&self) -> EcoString {
+ let mut r = EcoString::from("(array: (");
+ for (i, elem) in self.array.iter().enumerate() {
+ if i != 0 {
+ r.push_str(", ")
+ }
+ r.push_str(&elem.repr())
+ }
+ r.push_str("), phase: ");
+ r.push_str(&self.phase.repr());
+ r.push(')');
+ r
+ }
+}
+
+impl<T: Numeric + Default> From<Vec<DashLength<T>>> for DashPattern<T> {
+ fn from(array: Vec<DashLength<T>>) -> Self {
+ Self { array, phase: T::default() }
+ }
+}
+
+impl Resolve for DashPattern {
+ type Output = DashPattern<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ DashPattern {
+ array: self.array.into_iter().map(|l| l.resolve(styles)).collect(),
+ phase: self.phase.resolve(styles),
+ }
+ }
+}
+
+// Same names as tikz:
+// https://tex.stackexchange.com/questions/45275/tikz-get-values-for-predefined-dash-patterns
+cast! {
+ DashPattern,
+ self => dict! { "array" => self.array, "phase" => self.phase }.into_value(),
+
+ "solid" => Vec::new().into(),
+ "dotted" => vec![DashLength::LineWidth, Abs::pt(2.0).into()].into(),
+ "densely-dotted" => vec![DashLength::LineWidth, Abs::pt(1.0).into()].into(),
+ "loosely-dotted" => vec![DashLength::LineWidth, Abs::pt(4.0).into()].into(),
+ "dashed" => vec![Abs::pt(3.0).into(), Abs::pt(3.0).into()].into(),
+ "densely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into()].into(),
+ "loosely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(6.0).into()].into(),
+ "dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into(), DashLength::LineWidth, Abs::pt(2.0).into()].into(),
+ "densely-dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(1.0).into(), DashLength::LineWidth, Abs::pt(1.0).into()].into(),
+ "loosely-dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(4.0).into(), DashLength::LineWidth, Abs::pt(4.0).into()].into(),
+
+ array: Vec<DashLength> => Self { array, phase: Length::zero() },
+ mut dict: Dict => {
+ let array: Vec<DashLength> = dict.take("array")?.cast()?;
+ let phase = dict.take("phase").ok().map(Value::cast)
+ .transpose()?.unwrap_or(Length::zero());
+ dict.finish(&["array", "phase"])?;
+ Self {
+ array,
+ phase,
+ }
+ },
+}
+
+/// The length of a dash in a line dash pattern.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub enum DashLength<T: Numeric = Length> {
+ LineWidth,
+ Length(T),
+}
+
+impl<T: Numeric> DashLength<T> {
+ fn finish(self, line_width: T) -> T {
+ match self {
+ Self::LineWidth => line_width,
+ Self::Length(l) => l,
+ }
+ }
+}
+
+impl<T: Numeric + Repr> Repr for DashLength<T> {
+ fn repr(&self) -> EcoString {
+ match self {
+ Self::LineWidth => "dot".repr(),
+ Self::Length(v) => v.repr(),
+ }
+ }
+}
+
+impl Resolve for DashLength {
+ type Output = DashLength<Abs>;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ match self {
+ Self::LineWidth => DashLength::LineWidth,
+ Self::Length(v) => DashLength::Length(v.resolve(styles)),
+ }
+ }
+}
+
+impl From<Abs> for DashLength {
+ fn from(l: Abs) -> Self {
+ DashLength::Length(l.into())
+ }
+}
+
+cast! {
+ DashLength,
+ self => match self {
+ Self::LineWidth => "dot".into_value(),
+ Self::Length(v) => v.into_value(),
+ },
+ "dot" => Self::LineWidth,
+ v: Length => Self::Length(v),
+}
+
+/// A fully specified stroke of a geometric shape.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct FixedStroke {
+ /// The stroke's paint.
+ pub paint: Paint,
+ /// The stroke's thickness.
+ pub thickness: Abs,
+ /// The stroke's line cap.
+ pub cap: LineCap,
+ /// The stroke's line join.
+ pub join: LineJoin,
+ /// The stroke's line dash pattern.
+ pub dash: Option<DashPattern<Abs, Abs>>,
+ /// The miter limit. Defaults to 4.0, same as `tiny-skia`.
+ pub miter_limit: Scalar,
+}
+
+impl FixedStroke {
+ /// Create a stroke from a paint and a thickness.
+ pub fn from_pair(paint: impl Into<Paint>, thickness: Abs) -> Self {
+ Self {
+ paint: paint.into(),
+ thickness,
+ ..Default::default()
+ }
+ }
+}
+
+impl Default for FixedStroke {
+ fn default() -> Self {
+ Self {
+ paint: Paint::Solid(Color::BLACK),
+ thickness: Abs::pt(1.0),
+ cap: LineCap::Butt,
+ join: LineJoin::Miter,
+ dash: None,
+ miter_limit: Scalar::new(4.0),
+ }
+ }
+}