Gradient Part 3 - Radial gradients (#2312)
@ -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
|
||||
@ -54,6 +55,32 @@ pub fn write_gradients(ctx: &mut PdfContext) {
|
||||
|
||||
shading.finish();
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
@ -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(),
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
self.xml.end_element();
|
||||
}
|
||||
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 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
|
||||
};
|
||||
|
||||
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);
|
||||
|
@ -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>;
|
||||
|
||||
|
@ -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 {
|
||||
|
BIN
tests/ref/visualize/gradient-radial.png
Normal file
After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 474 KiB After Width: | Height: | Size: 474 KiB |
BIN
tests/ref/visualize/gradient-relative-radial.png
Normal file
After Width: | Height: | Size: 394 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 87 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 11 KiB |
49
tests/typ/visualize/gradient-radial.typ
Normal file
@ -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%),
|
||||
)
|
@ -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
|
29
tests/typ/visualize/gradient-relative-radial.typ
Normal file
@ -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))
|
@ -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%),
|
||||
)
|
||||
|
@ -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
|
||||
|