summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSébastien d'Herbais de Thun <sebastien.d.herbais@gmail.com>2023-10-06 16:47:20 +0200
committerGitHub <noreply@github.com>2023-10-06 16:47:20 +0200
commite7443abfe681a27ee47993d192f8a82953d43b73 (patch)
tree03446e4bfe9ab9aae96f151c7c228d3e26bdc89d
parentbced71b250225e30efa2fd0341bf825bf685a376 (diff)
Gradient Part 3 - Radial gradients (#2312)
-rw-r--r--crates/typst/src/export/pdf/gradient.rs44
-rw-r--r--crates/typst/src/export/render.rs95
-rw-r--r--crates/typst/src/export/svg.rs94
-rw-r--r--crates/typst/src/geom/axes.rs12
-rw-r--r--crates/typst/src/geom/gradient.rs302
-rw-r--r--tests/ref/visualize/gradient-radial.pngbin0 -> 56014 bytes
-rw-r--r--tests/ref/visualize/gradient-relative-linear.png (renamed from tests/ref/visualize/gradient-relative.png)bin485209 -> 485209 bytes
-rw-r--r--tests/ref/visualize/gradient-relative-radial.pngbin0 -> 403617 bytes
-rw-r--r--tests/ref/visualize/gradient-sharp.pngbin45753 -> 89427 bytes
-rw-r--r--tests/ref/visualize/gradient-stroke.pngbin15995 -> 11463 bytes
-rw-r--r--tests/typ/visualize/gradient-radial.typ49
-rw-r--r--tests/typ/visualize/gradient-relative-linear.typ (renamed from tests/typ/visualize/gradient-relative.typ)3
-rw-r--r--tests/typ/visualize/gradient-relative-radial.typ29
-rw-r--r--tests/typ/visualize/gradient-sharp.typ8
-rw-r--r--tests/typ/visualize/gradient-stroke.typ12
15 files changed, 567 insertions, 81 deletions
diff --git a/crates/typst/src/export/pdf/gradient.rs b/crates/typst/src/export/pdf/gradient.rs
index 842c7666..18b4de36 100644
--- a/crates/typst/src/export/pdf/gradient.rs
+++ b/crates/typst/src/export/pdf/gradient.rs
@@ -7,7 +7,8 @@ use super::color::{ColorSpaceExt, PaintEncode};
use super::page::{PageContext, Transforms};
use super::{AbsExt, PdfContext};
use crate::geom::{
- Abs, Color, ColorSpace, Gradient, Numeric, Quadrant, Ratio, Relative, Transform,
+ Abs, Angle, Color, ColorSpace, Gradient, Numeric, Quadrant, Ratio, Relative,
+ Transform,
};
/// A unique-transform-aspect-ratio combination that will be encoded into the
@@ -56,6 +57,32 @@ pub fn write_gradients(ctx: &mut PdfContext) {
shading_pattern
}
+ Gradient::Radial(radial) => {
+ let shading_function = shading_function(ctx, &gradient);
+ let mut shading_pattern = ctx.pdf.shading_pattern(shading);
+ let mut shading = shading_pattern.function_shading();
+ shading.shading_type(FunctionShadingType::Radial);
+
+ ctx.colors
+ .write(gradient.space(), shading.color_space(), &mut ctx.alloc);
+
+ shading
+ .anti_alias(gradient.anti_alias())
+ .function(shading_function)
+ .coords([
+ radial.focal_center.x.get() as f32,
+ radial.focal_center.y.get() as f32,
+ radial.focal_radius.get() as f32,
+ radial.center.x.get() as f32,
+ radial.center.y.get() as f32,
+ radial.radius.get() as f32,
+ ])
+ .extend([true; 2]);
+
+ shading.finish();
+
+ shading_pattern
+ }
};
shading_pattern.matrix(transform_to_array(transform));
@@ -231,12 +258,13 @@ fn register_gradient(
Relative::Parent => transforms.container_size,
};
- let (offset_x, offset_y) = match gradient.angle().quadrant() {
- Quadrant::First => (Abs::zero(), Abs::zero()),
- Quadrant::Second => (size.x, Abs::zero()),
- Quadrant::Third => (size.x, size.y),
- Quadrant::Fourth => (Abs::zero(), size.y),
- };
+ let (offset_x, offset_y) =
+ match gradient.angle().unwrap_or_else(Angle::zero).quadrant() {
+ Quadrant::First => (Abs::zero(), Abs::zero()),
+ Quadrant::Second => (size.x, Abs::zero()),
+ Quadrant::Third => (size.x, size.y),
+ Quadrant::Fourth => (Abs::zero(), size.y),
+ };
let transform = match gradient.unwrap_relative(false) {
Relative::Self_ => transforms.transform,
@@ -252,7 +280,7 @@ fn register_gradient(
Ratio::new(size.y.to_pt()),
))
.pre_concat(Transform::rotate(Gradient::correct_aspect_ratio(
- gradient.angle(),
+ gradient.angle().unwrap_or_else(Angle::zero),
size.aspect_ratio(),
))),
gradient: gradient.clone(),
diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs
index 8544c5a6..0b26045e 100644
--- a/crates/typst/src/export/render.rs
+++ b/crates/typst/src/export/render.rs
@@ -14,7 +14,7 @@ use usvg::{NodeExt, TreeParsing};
use crate::doc::{Frame, FrameItem, FrameKind, GroupItem, Meta, TextItem};
use crate::font::Font;
use crate::geom::{
- self, Abs, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, Paint,
+ self, Abs, Axes, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, Paint,
PathItem, Point, Ratio, Relative, Shape, Size, Transform,
};
use crate::image::{Image, ImageKind, RasterFormat};
@@ -136,8 +136,11 @@ impl<'a> State<'a> {
}
/// Pre concat the container's transform.
- fn pre_concat_container(self, container_transform: sk::Transform) -> Self {
- Self { container_transform, ..self }
+ fn pre_concat_container(self, transform: sk::Transform) -> Self {
+ Self {
+ container_transform: self.container_transform.pre_concat(transform),
+ ..self
+ }
}
}
@@ -378,7 +381,7 @@ fn render_outline_glyph(
// TODO: Implement gradients on text.
let mut pixmap = None;
- let paint = to_sk_paint(&text.fill, state, Size::zero(), None, &mut pixmap);
+ let paint = to_sk_paint(&text.fill, state, Size::zero(), None, &mut pixmap, None);
let rule = sk::FillRule::default();
@@ -512,7 +515,7 @@ fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option<
if let Some(fill) = &shape.fill {
let mut pixmap = None;
let mut paint: sk::Paint =
- to_sk_paint(fill, state, shape.geometry.bbox_size(), None, &mut pixmap);
+ to_sk_paint(fill, state, shape.geometry.bbox_size(), None, &mut pixmap, None);
if matches!(shape.geometry, Geometry::Rect(_)) {
paint.anti_alias = false;
@@ -547,10 +550,42 @@ fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option<
sk::StrokeDash::new(dash_array, pattern.phase.to_f32())
});
- let mut pixmap = None;
- let paint =
- to_sk_paint(paint, state, shape.geometry.bbox_size(), None, &mut pixmap);
+ let bbox = shape.geometry.bbox_size();
+ let offset_bbox = (!matches!(shape.geometry, Geometry::Line(..)))
+ .then(|| offset_bounding_box(bbox, *thickness))
+ .unwrap_or(bbox);
+
+ let fill_transform =
+ (!matches!(shape.geometry, Geometry::Line(..))).then(|| {
+ sk::Transform::from_translate(
+ -thickness.to_f32(),
+ -thickness.to_f32(),
+ )
+ });
+
+ let gradient_map =
+ (!matches!(shape.geometry, Geometry::Line(..))).then(|| {
+ (
+ Point::new(
+ -*thickness * state.pixel_per_pt as f64,
+ -*thickness * state.pixel_per_pt as f64,
+ ),
+ Axes::new(
+ Ratio::new(offset_bbox.x / bbox.x),
+ Ratio::new(offset_bbox.y / bbox.y),
+ ),
+ )
+ });
+ let mut pixmap = None;
+ let paint = to_sk_paint(
+ paint,
+ state,
+ offset_bbox,
+ fill_transform,
+ &mut pixmap,
+ gradient_map,
+ );
let stroke = sk::Stroke {
width,
line_cap: line_cap.into(),
@@ -700,23 +735,40 @@ impl From<sk::Transform> for Transform {
}
}
-/// Transforms a [`Paint`] into a [`sk::Paint`].
+// Transforms a [`Paint`] into a [`sk::Paint`].
/// Applying the necessary transform, if the paint is a gradient.
+///
+/// `gradient_map` is used to scale and move the gradient being sampled,
+/// this is used to line up the stroke and the fill of a shape.
fn to_sk_paint<'a>(
paint: &Paint,
state: State,
item_size: Size,
fill_transform: Option<sk::Transform>,
pixmap: &'a mut Option<Arc<sk::Pixmap>>,
+ gradient_map: Option<(Point, Axes<Ratio>)>,
) -> sk::Paint<'a> {
/// Actual sampling of the gradient, cached for performance.
#[comemo::memoize]
- fn cached(gradient: &Gradient, width: u32, height: u32) -> Arc<sk::Pixmap> {
+ fn cached(
+ gradient: &Gradient,
+ width: u32,
+ height: u32,
+ gradient_map: Option<(Point, Axes<Ratio>)>,
+ ) -> Arc<sk::Pixmap> {
+ let (offset, scale) =
+ gradient_map.unwrap_or_else(|| (Point::zero(), Axes::splat(Ratio::one())));
let mut pixmap = sk::Pixmap::new(width.max(1), height.max(1)).unwrap();
for x in 0..width {
for y in 0..height {
let color: sk::Color = gradient
- .sample_at((x as f32, y as f32), (width as f32, height as f32))
+ .sample_at(
+ (
+ (x as f32 + offset.x.to_f32()) * scale.x.get() as f32,
+ (y as f32 + offset.y.to_f32()) * scale.y.get() as f32,
+ ),
+ (width as f32, height as f32),
+ )
.into();
pixmap.pixels_mut()[(y * width + x) as usize] =
@@ -734,18 +786,18 @@ fn to_sk_paint<'a>(
sk_paint.anti_alias = true;
}
Paint::Gradient(gradient) => {
- let container_size = match gradient.unwrap_relative(false) {
+ let relative = gradient.unwrap_relative(false);
+ let container_size = match relative {
Relative::Self_ => item_size,
Relative::Parent => state.size,
};
- let fill_transform =
- fill_transform.unwrap_or_else(|| match gradient.unwrap_relative(false) {
- Relative::Self_ => sk::Transform::identity(),
- Relative::Parent => state
- .container_transform
- .post_concat(state.transform.invert().unwrap()),
- });
+ let fill_transform = match relative {
+ Relative::Self_ => fill_transform.unwrap_or_default(),
+ Relative::Parent => state
+ .container_transform
+ .post_concat(state.transform.invert().unwrap()),
+ };
let width = (container_size.x.to_f32() * state.pixel_per_pt).ceil() as u32;
let height = (container_size.y.to_f32() * state.pixel_per_pt).ceil() as u32;
@@ -753,6 +805,7 @@ fn to_sk_paint<'a>(
gradient,
width.max(state.pixel_per_pt.ceil() as u32),
height.max(state.pixel_per_pt.ceil() as u32),
+ gradient_map,
));
// We can use FilterQuality::Nearest here because we're
@@ -860,3 +913,7 @@ fn alpha_mul(color: u32, scale: u32) -> u32 {
let ag = ((color >> 8) & mask) * scale;
(rb & mask) | (ag & !mask)
}
+
+fn offset_bounding_box(bbox: Size, stroke_width: Abs) -> Size {
+ Size::new(bbox.x + stroke_width * 2.0, bbox.y + stroke_width * 2.0)
+}
diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs
index 03e12181..1aa5141a 100644
--- a/crates/typst/src/export/svg.rs
+++ b/crates/typst/src/export/svg.rs
@@ -8,6 +8,7 @@ use ttf_parser::{GlyphId, OutlineBuilder};
use xmlwriter::XmlWriter;
use crate::doc::{Frame, FrameItem, FrameKind, GroupItem, TextItem};
+use crate::eval::Repr;
use crate::font::Font;
use crate::geom::{
Abs, Angle, Axes, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, Paint,
@@ -135,12 +136,15 @@ struct GradientRef {
enum GradientKind {
/// A linear gradient.
Linear,
+ /// A radial gradient.
+ Radial,
}
impl From<&Gradient> for GradientKind {
fn from(value: &Gradient) -> Self {
match value {
Gradient::Linear { .. } => GradientKind::Linear,
+ Gradient::Radial { .. } => GradientKind::Radial,
}
}
}
@@ -664,48 +668,59 @@ impl SVGRenderer {
self.xml.write_attribute("y1", &y1);
self.xml.write_attribute("x2", &x2);
self.xml.write_attribute("y2", &y2);
+ }
+ Gradient::Radial(radial) => {
+ self.xml.start_element("radialGradient");
+ self.xml.write_attribute("id", &id);
+ self.xml.write_attribute("spreadMethod", "pad");
+ self.xml.write_attribute("gradientUnits", "userSpaceOnUse");
+ self.xml.write_attribute("cx", &radial.center.x.get());
+ self.xml.write_attribute("cy", &radial.center.y.get());
+ self.xml.write_attribute("r", &radial.radius.get());
+ self.xml.write_attribute("fx", &radial.focal_center.x.get());
+ self.xml.write_attribute("fy", &radial.focal_center.y.get());
+ self.xml.write_attribute("fr", &radial.focal_radius.get());
+ }
+ }
- for window in linear.stops.windows(2) {
- let (start_c, start_t) = window[0];
- let (end_c, end_t) = window[1];
-
- self.xml.start_element("stop");
- self.xml
- .write_attribute_fmt("offset", format_args!("{start_t:?}"));
- self.xml.write_attribute("stop-color", &start_c.to_hex());
- self.xml.end_element();
-
- // Generate (256 / len) stops between the two stops.
- // This is a workaround for a bug in many readers:
- // They tend to just ignore the color space of the gradient.
- // The goal is to have smooth gradients but not to balloon the file size
- // too much if there are already a lot of stops as in most presets.
- let len = if gradient.anti_alias() {
- (256 / linear.stops.len() as u32).max(2)
- } else {
- 2
- };
-
- for i in 1..(len - 1) {
- let t0 = i as f64 / (len - 1) as f64;
- let t = start_t + (end_t - start_t) * t0;
- let c = gradient.sample(RatioOrAngle::Ratio(t));
-
- self.xml.start_element("stop");
- self.xml.write_attribute_fmt("offset", format_args!("{t:?}"));
- self.xml.write_attribute("stop-color", &c.to_hex());
- self.xml.end_element();
- }
+ for window in gradient.stops_ref().windows(2) {
+ let (start_c, start_t) = window[0];
+ let (end_c, end_t) = window[1];
+
+ self.xml.start_element("stop");
+ self.xml.write_attribute("offset", &start_t.repr());
+ self.xml.write_attribute("stop-color", &start_c.to_hex());
+ self.xml.end_element();
+
+ // Generate (256 / len) stops between the two stops.
+ // This is a workaround for a bug in many readers:
+ // They tend to just ignore the color space of the gradient.
+ // The goal is to have smooth gradients but not to balloon the file size
+ // too much if there are already a lot of stops as in most presets.
+ let len = if gradient.anti_alias() {
+ (256 / gradient.stops_ref().len() as u32).max(2)
+ } else {
+ 2
+ };
- self.xml.start_element("stop");
- self.xml.write_attribute_fmt("offset", format_args!("{end_t:?}"));
- self.xml.write_attribute("stop-color", &end_c.to_hex());
- self.xml.end_element()
- }
+ for i in 1..(len - 1) {
+ let t0 = i as f64 / (len - 1) as f64;
+ let t = start_t + (end_t - start_t) * t0;
+ let c = gradient.sample(RatioOrAngle::Ratio(t));
+ self.xml.start_element("stop");
+ self.xml.write_attribute("offset", &t.repr());
+ self.xml.write_attribute("stop-color", &c.to_hex());
self.xml.end_element();
}
+
+ self.xml.start_element("stop");
+ self.xml.write_attribute("offset", &end_t.repr());
+ self.xml.write_attribute("stop-color", &end_c.to_hex());
+ self.xml.end_element()
}
+
+ self.xml.end_element();
}
self.xml.end_element()
@@ -727,6 +742,13 @@ impl SVGRenderer {
&SvgMatrix(gradient_ref.transform),
);
}
+ GradientKind::Radial => {
+ self.xml.start_element("radialGradient");
+ self.xml.write_attribute(
+ "gradientTransform",
+ &SvgMatrix(gradient_ref.transform),
+ );
+ }
}
self.xml.write_attribute("id", &id);
diff --git a/crates/typst/src/geom/axes.rs b/crates/typst/src/geom/axes.rs
index 14875553..2439182a 100644
--- a/crates/typst/src/geom/axes.rs
+++ b/crates/typst/src/geom/axes.rs
@@ -290,6 +290,18 @@ cast! {
},
}
+cast! {
+ Axes<Ratio>,
+ self => array![self.x, self.y].into_value(),
+ array: Array => {
+ let mut iter = array.into_iter();
+ match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?),
+ _ => bail!("ratio array must contain exactly two entries"),
+ }
+ },
+}
+
impl<T: Resolve> Resolve for Axes<T> {
type Output = Axes<T::Output>;
diff --git a/crates/typst/src/geom/gradient.rs b/crates/typst/src/geom/gradient.rs
index 2d821268..33b8ca77 100644
--- a/crates/typst/src/geom/gradient.rs
+++ b/crates/typst/src/geom/gradient.rs
@@ -3,6 +3,8 @@ use std::f64::{EPSILON, NEG_INFINITY};
use std::hash::Hash;
use std::sync::Arc;
+use kurbo::Vec2;
+
use super::color::{Hsl, Hsv};
use super::*;
use crate::diag::{bail, error, SourceResult};
@@ -13,15 +15,25 @@ use crate::syntax::{Span, Spanned};
/// A color gradient.
///
/// Typst supports linear gradients through the
-/// [`gradient.linear` function]($gradient.linear). Radial and conic gradients
-/// will be available soon.
+/// [`gradient.linear` function]($gradient.linear) and radial gradients through
+/// the [`gradient.radial` function]($gradient.radial). Conic gradients will be
+/// available soon.
///
/// See the [tracking issue](https://github.com/typst/typst/issues/2282) for
/// more details on the progress of gradient implementation.
///
+/// ```example
+/// #stack(
+/// dir: ltr,
+/// square(size: 50pt, fill: gradient.linear(..color.map.rainbow)),
+/// square(size: 50pt, fill: gradient.radial(..color.map.rainbow)),
+/// )
+/// ```
+///
/// # 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%}`
+/// 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
@@ -161,11 +173,21 @@ use crate::syntax::{Span, Spanned};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Gradient {
Linear(Arc<LinearGradient>),
+ Radial(Arc<RadialGradient>),
}
#[scope]
+#[allow(clippy::too_many_arguments)]
impl Gradient {
/// Creates a new linear gradient.
+ ///
+ /// ```example
+ /// #rect(
+ /// width: 100%,
+ /// height: 20pt,
+ /// fill: gradient.linear(..color.map.viridis)
+ /// )
+ /// ```
#[func(title = "Linear Gradient")]
pub fn linear(
/// The args of this function.
@@ -226,6 +248,123 @@ impl Gradient {
})))
}
+ /// Creates a new radial gradient.
+ ///
+ /// ```example
+ /// #circle(
+ /// radius: 20pt,
+ /// fill: gradient.radial(..color.map.viridis)
+ /// )
+ /// ```
+ ///
+ /// _Focal Point_
+ /// 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
+ /// #circle(
+ /// radius: 20pt,
+ /// 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<Stop>>,
+ /// 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<Relative>,
+ /// 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>,
+ /// The radius of the last 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!(error!(span, "a gradient must have at least two stops")
+ .with_hint("try filling the shape with a single color instead"));
+ }
+
+ if focal_radius.v > radius.v {
+ bail!(error!(
+ focal_radius.span,
+ "the focal radius must be smaller than the end radius"
+ )
+ .with_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!(error!(span, "the focal circle must be inside of the end circle")
+ .with_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,
+ })))
+ }
+
/// Returns the stops of this gradient.
#[func]
pub fn stops(&self) -> Vec<Stop> {
@@ -235,6 +374,11 @@ impl Gradient {
.iter()
.map(|(color, offset)| Stop { color: *color, offset: Some(*offset) })
.collect(),
+ Self::Radial(radial) => radial
+ .stops
+ .iter()
+ .map(|(color, offset)| Stop { color: *color, offset: Some(*offset) })
+ .collect(),
}
}
@@ -243,6 +387,7 @@ impl Gradient {
pub fn space(&self) -> ColorSpace {
match self {
Self::Linear(linear) => linear.space,
+ Self::Radial(radial) => radial.space,
}
}
@@ -251,14 +396,16 @@ impl Gradient {
pub fn relative(&self) -> Smart<Relative> {
match self {
Self::Linear(linear) => linear.relative,
+ Self::Radial(radial) => radial.relative,
}
}
/// Returns the angle of this gradient.
#[func]
- pub fn angle(&self) -> Angle {
+ pub fn angle(&self) -> Option<Angle> {
match self {
- Self::Linear(linear) => linear.angle,
+ Self::Linear(linear) => Some(linear.angle),
+ Self::Radial(_) => None,
}
}
@@ -267,6 +414,7 @@ impl Gradient {
pub fn kind(&self) -> Func {
match self {
Self::Linear(_) => Self::linear_data().into(),
+ Self::Radial(_) => Self::radial_data().into(),
}
}
@@ -287,6 +435,7 @@ impl Gradient {
match self {
Self::Linear(linear) => sample_stops(&linear.stops, linear.space, value),
+ Self::Radial(radial) => sample_stops(&radial.stops, radial.space, value),
}
}
@@ -381,6 +530,16 @@ impl Gradient {
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,
+ })),
})
}
@@ -429,12 +588,22 @@ impl Gradient {
stops.dedup();
Ok(match self {
- Self::Linear(grad) => Self::Linear(Arc::new(LinearGradient {
+ Self::Linear(linear) => Self::Linear(Arc::new(LinearGradient {
stops,
- angle: grad.angle,
- space: grad.space,
- relative: grad.relative,
- anti_alias: grad.anti_alias,
+ 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,
})),
})
}
@@ -445,17 +614,17 @@ impl Gradient {
pub fn stops_ref(&self) -> &[(Color, Ratio)] {
match self {
Gradient::Linear(linear) => &linear.stops,
+ Gradient::Radial(radial) => &radial.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) => {
- // Normalize the coordinates.
- let (mut x, mut y) = (x / width, y / height);
-
// Handle the direction of the gradient.
let angle = linear.angle.to_rad().rem_euclid(TAU);
@@ -481,15 +650,38 @@ impl Gradient {
(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.sample(RatioOrAngle::Ratio(Ratio::new(t)))
+ 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,
}
}
@@ -523,6 +715,7 @@ impl Gradient {
impl Repr for Gradient {
fn repr(&self) -> EcoString {
match self {
+ Self::Radial(radial) => radial.repr(),
Self::Linear(linear) => linear.repr(),
}
}
@@ -590,6 +783,87 @@ impl Repr for LinearGradient {
}
}
+/// 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<Relative>,
+ /// 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("space: (");
+ 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(&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 Relative {
diff --git a/tests/ref/visualize/gradient-radial.png b/tests/ref/visualize/gradient-radial.png
new file mode 100644
index 00000000..2e8e9af3
--- /dev/null
+++ b/tests/ref/visualize/gradient-radial.png
Binary files differ
diff --git a/tests/ref/visualize/gradient-relative.png b/tests/ref/visualize/gradient-relative-linear.png
index 56e46119..56e46119 100644
--- a/tests/ref/visualize/gradient-relative.png
+++ b/tests/ref/visualize/gradient-relative-linear.png
Binary files differ
diff --git a/tests/ref/visualize/gradient-relative-radial.png b/tests/ref/visualize/gradient-relative-radial.png
new file mode 100644
index 00000000..210ea7b0
--- /dev/null
+++ b/tests/ref/visualize/gradient-relative-radial.png
Binary files differ
diff --git a/tests/ref/visualize/gradient-sharp.png b/tests/ref/visualize/gradient-sharp.png
index 30e6fb66..a01cf08f 100644
--- a/tests/ref/visualize/gradient-sharp.png
+++ b/tests/ref/visualize/gradient-sharp.png
Binary files differ
diff --git a/tests/ref/visualize/gradient-stroke.png b/tests/ref/visualize/gradient-stroke.png
index 75f37c51..c7bc765b 100644
--- a/tests/ref/visualize/gradient-stroke.png
+++ b/tests/ref/visualize/gradient-stroke.png
Binary files differ
diff --git a/tests/typ/visualize/gradient-radial.typ b/tests/typ/visualize/gradient-radial.typ
new file mode 100644
index 00000000..5d83e71f
--- /dev/null
+++ b/tests/typ/visualize/gradient-radial.typ
@@ -0,0 +1,49 @@
+// Test the different radial gradient features.
+---
+
+#square(
+ size: 100pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl),
+)
+---
+
+#grid(
+ columns: 2,
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (0%, 0%)),
+ ),
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (0%, 100%)),
+ ),
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (100%, 0%)),
+ ),
+ square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, center: (100%, 100%)),
+ ),
+)
+
+---
+
+#square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, radius: 10%),
+)
+#square(
+ size: 50pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl, radius: 72%),
+)
+
+---
+#circle(
+ radius: 25pt,
+ fill: gradient.radial(white, rgb("#8fbc8f"), focal-center: (35%, 35%), focal-radius: 5%),
+)
+#circle(
+ radius: 25pt,
+ fill: gradient.radial(white, rgb("#8fbc8f"), focal-center: (75%, 35%), focal-radius: 5%),
+) \ No newline at end of file
diff --git a/tests/typ/visualize/gradient-relative.typ b/tests/typ/visualize/gradient-relative-linear.typ
index 549a05b4..8e1d04dc 100644
--- a/tests/typ/visualize/gradient-relative.typ
+++ b/tests/typ/visualize/gradient-relative-linear.typ
@@ -1,5 +1,4 @@
-// Test whether `relative: "parent"` works correctly.
-
+// Test whether `relative: "parent"` works correctly on linear gradients.
---
// The image should look as if there is a single gradient that is being used for
diff --git a/tests/typ/visualize/gradient-relative-radial.typ b/tests/typ/visualize/gradient-relative-radial.typ
new file mode 100644
index 00000000..87686896
--- /dev/null
+++ b/tests/typ/visualize/gradient-relative-radial.typ
@@ -0,0 +1,29 @@
+// Test whether `relative: "parent"` works correctly on radial gradients.
+
+---
+// The image should look as if there is a single gradient that is being used for
+// both the page and the rectangles.
+#let grad = gradient.radial(red, blue, green, purple, relative: "parent");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 200pt,
+ width: 200pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
+
+---
+// The image should look as if there are multiple gradients, one for each
+// rectangle.
+#let grad = gradient.radial(red, blue, green, purple, relative: "self");
+#let my-rect = rect(width: 50%, height: 50%, fill: grad)
+#set page(
+ height: 200pt,
+ width: 200pt,
+ fill: grad,
+ background: place(top + left, my-rect),
+)
+#place(top + right, my-rect)
+#place(bottom + center, rotate(45deg, my-rect))
diff --git a/tests/typ/visualize/gradient-sharp.typ b/tests/typ/visualize/gradient-sharp.typ
index 424beb8b..1f090f7e 100644
--- a/tests/typ/visualize/gradient-sharp.typ
+++ b/tests/typ/visualize/gradient-sharp.typ
@@ -5,9 +5,17 @@
size: 100pt,
fill: gradient.linear(..color.map.rainbow, space: color.hsl).sharp(10),
)
+#square(
+ size: 100pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10),
+)
---
#square(
size: 100pt,
fill: gradient.linear(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%),
)
+#square(
+ size: 100pt,
+ fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%),
+)
diff --git a/tests/typ/visualize/gradient-stroke.typ b/tests/typ/visualize/gradient-stroke.typ
index a156dde7..01616fe3 100644
--- a/tests/typ/visualize/gradient-stroke.typ
+++ b/tests/typ/visualize/gradient-stroke.typ
@@ -1,8 +1,16 @@
// Test gradients on strokes.
---
-#set page(width: 100pt, height: 100pt)
-#align(center + horizon, square(size: 50pt, fill: black, stroke: 5pt + gradient.linear(red, blue)))
+#set page(width: 100pt, height: auto, margin: 10pt)
+#align(center + top, square(size: 50pt, fill: black, stroke: 5pt + gradient.linear(red, blue)))
+#align(
+ center + bottom,
+ square(
+ size: 50pt,
+ fill: gradient.radial(red, blue, radius: 70.7%, focal-center: (10%, 10%)),
+ stroke: 10pt + gradient.radial(red, blue, radius: 70.7%, focal-center: (10%, 10%))
+ )
+)
---
// Test gradient on lines