diff --git a/crates/typst/src/export/pdf/gradient.rs b/crates/typst/src/export/pdf/gradient.rs index 18b4de367..1123b53e2 100644 --- a/crates/typst/src/export/pdf/gradient.rs +++ b/crates/typst/src/export/pdf/gradient.rs @@ -1,14 +1,19 @@ +use std::f32::consts::{PI, TAU}; +use std::sync::Arc; + use ecow::{eco_format, EcoString}; use pdf_writer::types::FunctionShadingType; +use pdf_writer::writers::StreamShadingType; use pdf_writer::{types::ColorSpaceOperand, Name}; -use pdf_writer::{Finish, Ref}; +use pdf_writer::{Filter, Finish, Ref}; -use super::color::{ColorSpaceExt, PaintEncode}; +use super::color::{ColorSpaceExt, PaintEncode, QuantizedColor}; use super::page::{PageContext, Transforms}; use super::{AbsExt, PdfContext}; +use crate::export::pdf::deflate; use crate::geom::{ - Abs, Angle, Color, ColorSpace, Gradient, Numeric, Quadrant, Ratio, Relative, - Transform, + Abs, Angle, Color, ColorSpace, ConicGradient, Gradient, Numeric, Point, Quadrant, + Ratio, Relative, Transform, WeightedColor, }; /// A unique-transform-aspect-ratio combination that will be encoded into the @@ -83,6 +88,38 @@ pub fn write_gradients(ctx: &mut PdfContext) { shading_pattern } + Gradient::Conic(conic) => { + let vertices = compute_vertex_stream(conic); + + let stream_shading_id = ctx.alloc.bump(); + let mut stream_shading = + ctx.pdf.stream_shading(stream_shading_id, &vertices); + + ctx.colors.write( + conic.space, + stream_shading.color_space(), + &mut ctx.alloc, + ); + + let range = conic.space.range(); + stream_shading + .bits_per_coordinate(16) + .bits_per_component(16) + .bits_per_flag(8) + .shading_type(StreamShadingType::CoonsPatch) + .decode([ + 0.0, 1.0, 0.0, 1.0, range[0], range[1], range[2], range[3], + range[4], range[5], + ]) + .anti_alias(gradient.anti_alias()) + .filter(Filter::FlateDecode); + + stream_shading.finish(); + + let mut shading_pattern = ctx.pdf.shading_pattern(shading); + shading_pattern.shading_ref(stream_shading_id); + shading_pattern + } }; shading_pattern.matrix(transform_to_array(transform)); @@ -258,29 +295,47 @@ fn register_gradient( Relative::Parent => transforms.container_size, }; - let (offset_x, offset_y) = - match gradient.angle().unwrap_or_else(Angle::zero).quadrant() { + let (offset_x, offset_y) = match gradient { + Gradient::Conic(conic) => ( + -size.x * (1.0 - conic.center.x.get() / 2.0) / 2.0, + -size.y * (1.0 - conic.center.y.get() / 2.0) / 2.0, + ), + gradient => 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 rotation = match gradient { + Gradient::Conic(_) => Angle::zero(), + gradient => gradient.angle().unwrap_or_default(), + }; let transform = match gradient.unwrap_relative(false) { Relative::Self_ => transforms.transform, Relative::Parent => transforms.container_transform, }; + let scale_offset = match gradient { + Gradient::Conic(_) => 4.0_f64, + _ => 1.0, + }; + let pdf_gradient = PdfGradient { aspect_ratio: size.aspect_ratio(), transform: transform - .pre_concat(Transform::translate(offset_x, offset_y)) + .pre_concat(Transform::translate( + offset_x * scale_offset, + offset_y * scale_offset, + )) .pre_concat(Transform::scale( - Ratio::new(size.x.to_pt()), - Ratio::new(size.y.to_pt()), + Ratio::new(size.x.to_pt() * scale_offset), + Ratio::new(size.y.to_pt() * scale_offset), )) .pre_concat(Transform::rotate(Gradient::correct_aspect_ratio( - gradient.angle().unwrap_or_else(Angle::zero), + rotation, size.aspect_ratio(), ))), gradient: gradient.clone(), @@ -301,3 +356,189 @@ fn transform_to_array(ts: Transform) -> [f32; 6] { ts.ty.to_f32(), ] } + +/// Writes a single Coons Patch as defined in the PDF specification +/// to a binary vec. +/// +/// Structure: +/// - flag: `u8` +/// - points: `[u16; 24]` +/// - colors: `[u16; 12]` +fn write_patch( + target: &mut Vec, + t: f32, + t1: f32, + c0: [u16; 3], + c1: [u16; 3], + angle: Angle, +) { + let theta = -TAU * t + angle.to_rad() as f32 + PI; + let theta1 = -TAU * t1 + angle.to_rad() as f32 + PI; + + let (cp1, cp2) = + control_point(Point::new(Abs::pt(0.5), Abs::pt(0.5)), 0.5, theta, theta1); + + // Push the flag + target.push(0); + + let p1 = + [u16::quantize(0.5, [0.0, 1.0]).to_be(), u16::quantize(0.5, [0.0, 1.0]).to_be()]; + + let p2 = [ + u16::quantize(theta.cos(), [-1.0, 1.0]).to_be(), + u16::quantize(theta.sin(), [-1.0, 1.0]).to_be(), + ]; + + let p3 = [ + u16::quantize(theta1.cos(), [-1.0, 1.0]).to_be(), + u16::quantize(theta1.sin(), [-1.0, 1.0]).to_be(), + ]; + + let cp1 = [ + u16::quantize(cp1.x.to_f32(), [0.0, 1.0]).to_be(), + u16::quantize(cp1.y.to_f32(), [0.0, 1.0]).to_be(), + ]; + + let cp2 = [ + u16::quantize(cp2.x.to_f32(), [0.0, 1.0]).to_be(), + u16::quantize(cp2.y.to_f32(), [0.0, 1.0]).to_be(), + ]; + + // Push the points + target.extend_from_slice(bytemuck::cast_slice(&[ + p1, p1, p2, p2, cp1, cp2, p3, p3, p1, p1, p1, p1, + ])); + + let colors = + [c0.map(u16::to_be), c0.map(u16::to_be), c1.map(u16::to_be), c1.map(u16::to_be)]; + + // Push the colors. + target.extend_from_slice(bytemuck::cast_slice(&colors)); +} + +fn control_point(c: Point, r: f32, angle_start: f32, angle_end: f32) -> (Point, Point) { + let n = (TAU / (angle_end - angle_start)).abs(); + let f = ((angle_end - angle_start) / n).tan() * 4.0 / 3.0; + + let p1 = c + Point::new( + Abs::pt((r * angle_start.cos() - f * r * angle_start.sin()) as f64), + Abs::pt((r * angle_start.sin() + f * r * angle_start.cos()) as f64), + ); + + let p2 = c + Point::new( + Abs::pt((r * angle_end.cos() + f * r * angle_end.sin()) as f64), + Abs::pt((r * angle_end.sin() - f * r * angle_end.cos()) as f64), + ); + + (p1, p2) +} + +#[comemo::memoize] +fn compute_vertex_stream(conic: &ConicGradient) -> Arc> { + // Generated vertices for the Coons patches + let mut vertices = Vec::new(); + + // We want to generate a vertex based on some conditions, either: + // - At the boundary of a stop + // - At the boundary of a quadrant + // - When we cross the boundary of a hue turn (for HSV and HSL only) + for window in conic.stops.windows(2) { + let ((c0, t0), (c1, t1)) = (window[0], window[1]); + + // Skip stops with the same position + if t0 == t1 { + continue; + } + + // If the angle between the two stops is greater than 90 degrees, we need to + // generate a vertex at the boundary of the quadrant. + // However, we add more stops in-between to make the gradient smoother, so we + // need to generate a vertex at least every 5 degrees. + // If the colors are the same, we do it every quadrant only. + let slope = 1.0 / (t1.get() - t0.get()); + let mut t_x = t0.get(); + let dt = (t1.get() - t0.get()).min(0.25); + while t_x < t1.get() { + let t_next = (t_x + dt).min(t1.get()); + + let t1 = slope * (t_x - t0.get()); + let t2 = slope * (t_next - t0.get()); + + // We don't use `Gradient::sample` to avoid issues with sharp gradients. + let c = Color::mix_iter( + [WeightedColor::new(c0, 1.0 - t1), WeightedColor::new(c1, t1)], + conic.space, + ) + .unwrap(); + + let c_next = Color::mix_iter( + [WeightedColor::new(c0, 1.0 - t2), WeightedColor::new(c1, t2)], + conic.space, + ) + .unwrap(); + + // If the color space is HSL or HSV, and we cross the 0°/360° boundary, + // we need to create two separate stops. + if conic.space == ColorSpace::Hsl || conic.space == ColorSpace::Hsv { + let [h1, s1, x1, _] = c.to_space(conic.space).to_vec4(); + let [h2, s2, x2, _] = c_next.to_space(conic.space).to_vec4(); + + // Compute the intermediary stop at 360°. + if (h1 - h2).abs() > 180.0 { + let h1 = if h1 < h2 { h1 + 360.0 } else { h1 }; + let h2 = if h2 < h1 { h2 + 360.0 } else { h2 }; + + // We compute where the crossing happens between zero and one + let t = (360.0 - h1) / (h2 - h1); + // We then map it back to the original range. + let t_prime = t * (t_next as f32 - t_x as f32) + t_x as f32; + + // If the crossing happens between the two stops, + // we need to create an extra stop. + if t_prime <= t_next as f32 && t_prime >= t_x as f32 { + let c0 = [1.0, s1 * (1.0 - t) + s2 * t, x1 * (1.0 - t) + x2 * t]; + let c1 = [0.0, s1 * (1.0 - t) + s2 * t, x1 * (1.0 - t) + x2 * t]; + let c0 = c0.map(|c| u16::quantize(c, [0.0, 1.0])); + let c1 = c1.map(|c| u16::quantize(c, [0.0, 1.0])); + + write_patch( + &mut vertices, + t_x as f32, + t_prime, + conic.space.convert(c), + c0, + conic.angle, + ); + + write_patch(&mut vertices, t_prime, t_prime, c0, c1, conic.angle); + + write_patch( + &mut vertices, + t_prime, + t_next as f32, + c1, + conic.space.convert(c_next), + conic.angle, + ); + + t_x = t_next; + continue; + } + } + } + + write_patch( + &mut vertices, + t_x as f32, + t_next as f32, + conic.space.convert(c), + conic.space.convert(c_next), + conic.angle, + ); + + t_x = t_next; + } + } + + Arc::new(deflate(&vertices)) +} diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs index 0b26045ef..6fd47387d 100644 --- a/crates/typst/src/export/render.rs +++ b/crates/typst/src/export/render.rs @@ -735,7 +735,7 @@ impl From 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, diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs index 1aa5141af..25cb75197 100644 --- a/crates/typst/src/export/svg.rs +++ b/crates/typst/src/export/svg.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::f32::consts::TAU; use std::fmt::{self, Display, Formatter, Write}; use std::io::Read; @@ -17,6 +18,11 @@ use crate::geom::{ use crate::image::{Image, ImageFormat, RasterFormat, VectorFormat}; use crate::util::hash128; +/// The number of segments in a conic gradient. +/// This is a heuristic value that seems to work well. +/// Smaller values could be interesting for optimization. +const CONIC_SEGMENT: usize = 360; + /// Export a frame into a SVG file. #[tracing::instrument(skip_all)] pub fn svg(frame: &Frame) -> String { @@ -76,6 +82,8 @@ struct SVGRenderer { /// The `Ratio` is the aspect ratio of the gradient, this is used to correct /// the angle of the gradient. gradients: Deduplicator<(Gradient, Ratio)>, + /// These are the gradients that compose a conic gradient. + conic_subgradients: Deduplicator, } /// Contextual information for rendering. @@ -131,6 +139,21 @@ struct GradientRef { transform: Transform, } +/// A subgradient for conic gradients. +#[derive(Hash)] +struct SVGSubGradient { + /// The center point of the gradient. + center: Axes, + /// The start point of the subgradient. + t0: Angle, + /// The end point of the subgradient. + t1: Angle, + /// The color at the start point of the subgradient. + c0: Color, + /// The color at the end point of the subgradient. + c1: Color, +} + /// The kind of linear gradient. #[derive(Hash, Clone, Copy, PartialEq, Eq)] enum GradientKind { @@ -138,6 +161,8 @@ enum GradientKind { Linear, /// A radial gradient. Radial, + /// A conic gradient. + Conic, } impl From<&Gradient> for GradientKind { @@ -145,6 +170,7 @@ impl From<&Gradient> for GradientKind { match value { Gradient::Linear { .. } => GradientKind::Linear, Gradient::Radial { .. } => GradientKind::Radial, + Gradient::Conic { .. } => GradientKind::Conic, } } } @@ -170,6 +196,7 @@ impl SVGRenderer { clip_paths: Deduplicator::new('c'), gradient_refs: Deduplicator::new('g'), gradients: Deduplicator::new('f'), + conic_subgradients: Deduplicator::new('s'), } } @@ -572,6 +599,7 @@ impl SVGRenderer { self.write_clip_path_defs(); self.write_gradients(); self.write_gradient_refs(); + self.write_subgradients(); self.xml.end_document() } @@ -681,6 +709,87 @@ impl SVGRenderer { self.xml.write_attribute("fy", &radial.focal_center.y.get()); self.xml.write_attribute("fr", &radial.focal_radius.get()); } + Gradient::Conic(conic) => { + self.xml.start_element("pattern"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("viewBox", "0 0 1 1"); + self.xml.write_attribute("preserveAspectRatio", "none"); + self.xml.write_attribute("patternUnits", "userSpaceOnUse"); + self.xml.write_attribute("width", "2"); + self.xml.write_attribute("height", "2"); + self.xml.write_attribute("x", "-0.5"); + self.xml.write_attribute("y", "-0.5"); + + // The rotation angle, negated to match rotation in PNG. + let angle: f32 = + -(Gradient::correct_aspect_ratio(conic.angle, *ratio).to_rad() + as f32) + .rem_euclid(TAU); + let center: (f32, f32) = + (conic.center.x.get() as f32, conic.center.y.get() as f32); + + // We build an arg segment for each segment of a circle. + let dtheta = TAU / CONIC_SEGMENT as f32; + for i in 0..CONIC_SEGMENT { + let theta1 = dtheta * i as f32; + let theta2 = dtheta * (i + 1) as f32; + + // Create the path for the segment. + let mut builder = SvgPathBuilder::default(); + builder.move_to( + correct_pattern_pos(center.0), + correct_pattern_pos(center.1), + ); + builder.line_to( + correct_pattern_pos(-2.0 * (theta1 + angle).cos() + center.0), + correct_pattern_pos(2.0 * (theta1 + angle).sin() + center.1), + ); + builder.arc( + (2.0, 2.0), + 0.0, + 0, + 1, + ( + correct_pattern_pos( + -2.0 * (theta2 + angle).cos() + center.0, + ), + correct_pattern_pos( + 2.0 * (theta2 + angle).sin() + center.1, + ), + ), + ); + builder.close(); + + let t1 = (i as f32) / CONIC_SEGMENT as f32; + let t2 = (i + 1) as f32 / CONIC_SEGMENT as f32; + let subgradient = SVGSubGradient { + center: conic.center, + t0: Angle::rad((theta1 + angle) as f64), + t1: Angle::rad((theta2 + angle) as f64), + c0: gradient + .sample(RatioOrAngle::Ratio(Ratio::new(t1 as f64))), + c1: gradient + .sample(RatioOrAngle::Ratio(Ratio::new(t2 as f64))), + }; + let id = self + .conic_subgradients + .insert_with(hash128(&subgradient), || subgradient); + + // Add the path to the pattern. + self.xml.start_element("path"); + self.xml.write_attribute("d", &builder.0); + self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); + self.xml + .write_attribute_fmt("stroke", format_args!("url(#{id})")); + self.xml.write_attribute("stroke-width", "0"); + self.xml.write_attribute("shape-rendering", "optimizeSpeed"); + self.xml.end_element(); + } + + // We skip the default stop generation code. + self.xml.end_element(); + continue; + } } for window in gradient.stops_ref().windows(2) { @@ -726,6 +835,43 @@ impl SVGRenderer { self.xml.end_element() } + /// Write the sub-gradients that are used for conic gradients. + fn write_subgradients(&mut self) { + if self.conic_subgradients.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "subgradients"); + for (id, gradient) in self.conic_subgradients.iter() { + let x1 = 2.0 - gradient.t0.cos() as f32 + gradient.center.x.get() as f32; + let y1 = gradient.t0.sin() as f32 + gradient.center.y.get() as f32; + let x2 = 2.0 - gradient.t1.cos() as f32 + gradient.center.x.get() as f32; + let y2 = gradient.t1.sin() as f32 + gradient.center.y.get() as f32; + + self.xml.start_element("linearGradient"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("gradientUnits", "objectBoundingBox"); + self.xml.write_attribute("x1", &x1); + self.xml.write_attribute("y1", &y1); + self.xml.write_attribute("x2", &x2); + self.xml.write_attribute("y2", &y2); + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", "0%"); + self.xml.write_attribute("stop-color", &gradient.c0.to_hex()); + self.xml.end_element(); + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", "100%"); + self.xml.write_attribute("stop-color", &gradient.c1.to_hex()); + self.xml.end_element(); + + self.xml.end_element(); + } + self.xml.end_element(); + } + fn write_gradient_refs(&mut self) { if self.gradient_refs.is_empty() { return; @@ -749,6 +895,13 @@ impl SVGRenderer { &SvgMatrix(gradient_ref.transform), ); } + GradientKind::Conic => { + self.xml.start_element("pattern"); + self.xml.write_attribute( + "patternTransform", + &SvgMatrix(gradient_ref.transform), + ); + } } self.xml.write_attribute("id", &id); @@ -996,6 +1149,26 @@ impl SvgPathBuilder { self.line_to(width, 0.0); self.close(); } + + /// Creates an arc path. + fn arc( + &mut self, + radius: (f32, f32), + x_axis_rot: f32, + large_arc_flag: u32, + sweep_flag: u32, + pos: (f32, f32), + ) { + write!( + &mut self.0, + "A {rx} {ry} {x_axis_rot} {large_arc_flag} {sweep_flag} {x} {y} ", + rx = radius.0, + ry = radius.1, + x = pos.0, + y = pos.1, + ) + .unwrap(); + } } /// A builder for SVG path. This is used to build the path for a glyph. @@ -1091,3 +1264,8 @@ impl ColorEncode for Color { } } } + +/// Maps a coordinate in a unit size square to a coordinate in the pattern. +fn correct_pattern_pos(x: f32) -> f32 { + (x + 0.5) / 2.0 +} diff --git a/crates/typst/src/geom/gradient.rs b/crates/typst/src/geom/gradient.rs index 33b8ca776..f4c750a37 100644 --- a/crates/typst/src/geom/gradient.rs +++ b/crates/typst/src/geom/gradient.rs @@ -15,9 +15,9 @@ use crate::syntax::{Span, Spanned}; /// A color gradient. /// /// Typst supports linear gradients through the -/// [`gradient.linear` function]($gradient.linear) and radial gradients through -/// the [`gradient.radial` function]($gradient.radial). Conic gradients will be -/// available soon. +/// [`gradient.linear` function]($gradient.linear), radial gradients through +/// the [`gradient.radial` function]($gradient.radial), and conic gradients +/// through the [`gradient.conic` function]($gradient.conic). /// /// See the [tracking issue](https://github.com/typst/typst/issues/2282) for /// more details on the progress of gradient implementation. @@ -27,6 +27,7 @@ use crate::syntax::{Span, Spanned}; /// dir: ltr, /// square(size: 50pt, fill: gradient.linear(..color.map.rainbow)), /// square(size: 50pt, fill: gradient.radial(..color.map.rainbow)), +/// square(size: 50pt, fill: gradient.conic(..color.map.rainbow)), /// ) /// ``` /// @@ -174,6 +175,7 @@ use crate::syntax::{Span, Spanned}; pub enum Gradient { Linear(Arc), Radial(Arc), + Conic(Arc), } #[scope] @@ -365,6 +367,75 @@ impl Gradient { }))) } + /// Creates a new conic gradient (i.e a gradient whose color changes + /// radially around a center point). + /// + /// ```example + /// #circle( + /// radius: 20pt, + /// fill: gradient.conic(..color.map.viridis) + /// ) + /// ``` + /// + /// _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 + /// #circle( + /// radius: 20pt, + /// fill: gradient.conic(..color.map.viridis, center: (10%, 40%)) + /// ) + /// ``` + #[func] + pub fn conic( + /// The call site of this function. + span: Span, + /// The color [stops](#stops) of the gradient. + #[variadic] + stops: Vec>, + /// 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, + /// 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, + ) -> SourceResult { + 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")); + } + + Ok(Gradient::Conic(Arc::new(ConicGradient { + stops: process_stops(&stops)?, + angle, + center: center.map(From::from), + space, + relative, + anti_alias: true, + }))) + } + /// Returns the stops of this gradient. #[func] pub fn stops(&self) -> Vec { @@ -379,6 +450,11 @@ impl Gradient { .iter() .map(|(color, offset)| Stop { color: *color, offset: Some(*offset) }) .collect(), + Self::Conic(conic) => conic + .stops + .iter() + .map(|(color, offset)| Stop { color: *color, offset: Some(*offset) }) + .collect(), } } @@ -388,6 +464,7 @@ impl Gradient { match self { Self::Linear(linear) => linear.space, Self::Radial(radial) => radial.space, + Self::Conic(conic) => conic.space, } } @@ -397,6 +474,7 @@ impl Gradient { match self { Self::Linear(linear) => linear.relative, Self::Radial(radial) => radial.relative, + Self::Conic(conic) => conic.relative, } } @@ -406,6 +484,7 @@ impl Gradient { match self { Self::Linear(linear) => Some(linear.angle), Self::Radial(_) => None, + Self::Conic(conic) => Some(conic.angle), } } @@ -415,6 +494,7 @@ impl Gradient { match self { Self::Linear(_) => Self::linear_data().into(), Self::Radial(_) => Self::radial_data().into(), + Self::Conic(_) => Self::conic_data().into(), } } @@ -436,6 +516,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), + Self::Conic(conic) => sample_stops(&conic.stops, conic.space, value), } } @@ -540,6 +621,14 @@ impl Gradient { 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, + })), }) } @@ -605,6 +694,14 @@ impl Gradient { 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, + })), }) } } @@ -615,6 +712,7 @@ impl Gradient { match self { Gradient::Linear(linear) => &linear.stops, Gradient::Radial(radial) => &radial.stops, + Gradient::Conic(conic) => &conic.stops, } } @@ -625,18 +723,12 @@ impl Gradient { let (mut x, mut y) = (x / width, y / height); let t = match self { Self::Linear(linear) => { - // Handle the direction of the gradient. - let angle = linear.angle.to_rad().rem_euclid(TAU); - // Aspect ratio correction. - let angle = (angle.tan() * height as f64).atan2(width as f64); - let angle = match linear.angle.quadrant() { - Quadrant::First => angle, - Quadrant::Second => angle + PI, - Quadrant::Third => angle + PI, - Quadrant::Fourth => angle + TAU, - }; - + 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(); @@ -672,6 +764,15 @@ impl Gradient { ((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)))) @@ -682,6 +783,7 @@ impl Gradient { match self { Self::Linear(linear) => linear.anti_alias, Self::Radial(radial) => radial.anti_alias, + Self::Conic(conic) => conic.anti_alias, } } @@ -717,6 +819,7 @@ impl Repr for Gradient { match self { Self::Radial(radial) => radial.repr(), Self::Linear(linear) => linear.repr(), + Self::Conic(conic) => conic.repr(), } } } @@ -809,7 +912,7 @@ impl Repr for RadialGradient { 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("center: ("); r.push_str(&self.center.x.repr()); r.push_str(", "); r.push_str(&self.center.y.repr()); @@ -848,6 +951,71 @@ impl Repr for RadialGradient { 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, + /// The color space in which to interpolate the gradient. + pub space: ColorSpace, + /// The relative placement of the gradient. + pub relative: Smart, + /// 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() > 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()); diff --git a/crates/typst/src/geom/mod.rs b/crates/typst/src/geom/mod.rs index 29ecbe743..105ee5a30 100644 --- a/crates/typst/src/geom/mod.rs +++ b/crates/typst/src/geom/mod.rs @@ -32,13 +32,15 @@ pub use self::abs::{Abs, AbsUnit}; pub use self::align::{Align, FixedAlign, HAlign, VAlign}; pub use self::angle::{Angle, AngleUnit, Quadrant}; pub use self::axes::{Axes, Axis}; -pub use self::color::{Color, ColorSpace, WeightedColor}; +pub use self::color::{Color, ColorSpace, Hsl, Hsv, WeightedColor}; pub use self::corners::{Corner, Corners}; pub use self::dir::Dir; pub use self::ellipse::ellipse; pub use self::em::Em; pub use self::fr::Fr; -pub use self::gradient::{Gradient, LinearGradient, RatioOrAngle, Relative}; +pub use self::gradient::{ + ConicGradient, Gradient, LinearGradient, RatioOrAngle, Relative, +}; pub use self::length::Length; pub use self::paint::Paint; pub use self::path::{Path, PathItem}; diff --git a/crates/typst/src/geom/stroke.rs b/crates/typst/src/geom/stroke.rs index 2b1bb8764..e88d887db 100644 --- a/crates/typst/src/geom/stroke.rs +++ b/crates/typst/src/geom/stroke.rs @@ -15,6 +15,7 @@ use crate::eval::{dict, Cast, FromValue, NoneValue}; /// 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)), /// ) /// ``` /// diff --git a/tests/ref/visualize/gradient-conic.png b/tests/ref/visualize/gradient-conic.png new file mode 100644 index 000000000..ff4a0ca2e Binary files /dev/null and b/tests/ref/visualize/gradient-conic.png differ diff --git a/tests/ref/visualize/gradient-relative-conic.png b/tests/ref/visualize/gradient-relative-conic.png new file mode 100644 index 000000000..232c5f0af Binary files /dev/null and b/tests/ref/visualize/gradient-relative-conic.png differ diff --git a/tests/ref/visualize/gradient-sharp.png b/tests/ref/visualize/gradient-sharp.png index a01cf08f0..b7698cfa4 100644 Binary files a/tests/ref/visualize/gradient-sharp.png and b/tests/ref/visualize/gradient-sharp.png differ diff --git a/tests/ref/visualize/gradient-stroke.png b/tests/ref/visualize/gradient-stroke.png index c7bc765bf..69317f732 100644 Binary files a/tests/ref/visualize/gradient-stroke.png and b/tests/ref/visualize/gradient-stroke.png differ diff --git a/tests/typ/visualize/gradient-conic.typ b/tests/typ/visualize/gradient-conic.typ new file mode 100644 index 000000000..83fdb07cc --- /dev/null +++ b/tests/typ/visualize/gradient-conic.typ @@ -0,0 +1,25 @@ +// Test conic gradients + +--- +#square( + size: 50pt, + fill: gradient.conic(..color.map.rainbow, space: color.hsv), +) + +--- +#square( + size: 50pt, + fill: gradient.conic(..color.map.rainbow, space: color.hsv, center: (10%, 10%)), +) + +--- +#square( + size: 50pt, + fill: gradient.conic(..color.map.rainbow, space: color.hsv, center: (90%, 90%)), +) + +--- +#square( + size: 50pt, + fill: gradient.conic(..color.map.rainbow, space: color.hsv, angle: 90deg), +) diff --git a/tests/typ/visualize/gradient-radial.typ b/tests/typ/visualize/gradient-radial.typ index 5d83e71fc..c0d1b2499 100644 --- a/tests/typ/visualize/gradient-radial.typ +++ b/tests/typ/visualize/gradient-radial.typ @@ -46,4 +46,4 @@ #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-conic.typ b/tests/typ/visualize/gradient-relative-conic.typ new file mode 100644 index 000000000..26b509af5 --- /dev/null +++ b/tests/typ/visualize/gradient-relative-conic.typ @@ -0,0 +1,29 @@ +// Test whether `relative: "parent"` works correctly on conic 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.conic(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.conic(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 1f090f7e9..89841efd6 100644 --- a/tests/typ/visualize/gradient-sharp.typ +++ b/tests/typ/visualize/gradient-sharp.typ @@ -9,6 +9,10 @@ size: 100pt, fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10), ) +#square( + size: 100pt, + fill: gradient.conic(..color.map.rainbow, space: color.hsl).sharp(10), +) --- #square( @@ -19,3 +23,7 @@ size: 100pt, fill: gradient.radial(..color.map.rainbow, space: color.hsl).sharp(10, smoothness: 40%), ) +#square( + size: 100pt, + fill: gradient.conic(..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 01616fe30..d44614970 100644 --- a/tests/typ/visualize/gradient-stroke.typ +++ b/tests/typ/visualize/gradient-stroke.typ @@ -1,8 +1,9 @@ // Test gradients on strokes. --- -#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( @@ -12,6 +13,16 @@ ) ) +--- +#align( + center + bottom, + square( + size: 50pt, + fill: black, + stroke: 10pt + gradient.conic(red, blue) + ) +) + --- // Test gradient on lines #set page(width: 100pt, height: 100pt)