diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index 203232071..136dfad87 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -400,7 +400,7 @@ pub(super) fn decorate( let offset = offset.unwrap_or(-metrics.position.at(text.size)) - shift; let stroke = stroke.clone().unwrap_or(FixedStroke { - paint: text.fill.clone(), + paint: text.fill.as_decoration(), thickness: metrics.thickness.at(text.size), ..FixedStroke::default() }); diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index b380431b4..748d71ad0 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -182,10 +182,16 @@ pub struct TextElem { #[parse({ let paint: Option> = args.named_or_find("fill")?; if let Some(paint) = &paint { - // TODO: Implement gradients on text. - if matches!(paint.v, Paint::Gradient(_)) { - bail!(error!(paint.span, "text fill must be a solid color") - .with_hint("gradients on text will be supported soon")); + if let Paint::Gradient(gradient) = &paint.v { + if gradient.relative() == Smart::Custom(Relative::Self_) { + bail!( + error!( + paint.span, + "gradients on text must be relative to the parent" + ) + .with_hint("make sure to set `relative: auto` on your text fill") + ); + } } } paint.map(|paint| paint.v) diff --git a/crates/typst/src/export/pdf/color.rs b/crates/typst/src/export/pdf/color.rs index 6ad273d85..d5b6f3f1d 100644 --- a/crates/typst/src/export/pdf/color.rs +++ b/crates/typst/src/export/pdf/color.rs @@ -274,30 +274,35 @@ impl ColorEncode for ColorSpace { /// Encodes a paint into either a fill or stroke color. pub(super) trait PaintEncode { /// Set the paint as the fill color. - fn set_as_fill(&self, ctx: &mut PageContext, transforms: Transforms); + fn set_as_fill(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms); /// Set the paint as the stroke color. - fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms); + fn set_as_stroke(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms); } impl PaintEncode for Paint { - fn set_as_fill(&self, ctx: &mut PageContext, transforms: Transforms) { + fn set_as_fill(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms) { match self { - Self::Solid(c) => c.set_as_fill(ctx, transforms), - Self::Gradient(gradient) => gradient.set_as_fill(ctx, transforms), + Self::Solid(c) => c.set_as_fill(ctx, on_text, transforms), + Self::Gradient(gradient) => gradient.set_as_fill(ctx, on_text, transforms), } } - fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms) { + fn set_as_stroke( + &self, + ctx: &mut PageContext, + on_text: bool, + transforms: Transforms, + ) { match self { - Self::Solid(c) => c.set_as_stroke(ctx, transforms), - Self::Gradient(gradient) => gradient.set_as_stroke(ctx, transforms), + Self::Solid(c) => c.set_as_stroke(ctx, on_text, transforms), + Self::Gradient(gradient) => gradient.set_as_stroke(ctx, on_text, transforms), } } } impl PaintEncode for Color { - fn set_as_fill(&self, ctx: &mut PageContext, _: Transforms) { + fn set_as_fill(&self, ctx: &mut PageContext, _: bool, _: Transforms) { match self { Color::Luma(_) => { ctx.parent.colors.d65_gray(&mut ctx.parent.alloc); @@ -350,7 +355,7 @@ impl PaintEncode for Color { } } - fn set_as_stroke(&self, ctx: &mut PageContext, _: Transforms) { + fn set_as_stroke(&self, ctx: &mut PageContext, _: bool, _: Transforms) { match self { Color::Luma(_) => { ctx.parent.colors.d65_gray(&mut ctx.parent.alloc); diff --git a/crates/typst/src/export/pdf/gradient.rs b/crates/typst/src/export/pdf/gradient.rs index 1123b53e2..6ecf85882 100644 --- a/crates/typst/src/export/pdf/gradient.rs +++ b/crates/typst/src/export/pdf/gradient.rs @@ -27,12 +27,14 @@ pub struct PdfGradient { pub aspect_ratio: Ratio, /// The gradient. pub gradient: Gradient, + /// Whether the gradient is applied to text. + pub on_text: bool, } /// Writes the actual gradients (shading patterns) to the PDF. /// This is performed once after writing all pages. pub fn write_gradients(ctx: &mut PdfContext) { - for PdfGradient { transform, aspect_ratio, gradient } in + for PdfGradient { transform, aspect_ratio, gradient, on_text } in ctx.gradient_map.items().cloned().collect::>() { let shading = ctx.alloc.bump(); @@ -89,7 +91,7 @@ pub fn write_gradients(ctx: &mut PdfContext) { shading_pattern } Gradient::Conic(conic) => { - let vertices = compute_vertex_stream(conic); + let vertices = compute_vertex_stream(conic, aspect_ratio, on_text); let stream_shading_id = ctx.alloc.bump(); let mut stream_shading = @@ -254,20 +256,25 @@ fn single_gradient( } impl PaintEncode for Gradient { - fn set_as_fill(&self, ctx: &mut PageContext, transforms: Transforms) { + fn set_as_fill(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms) { ctx.reset_fill_color_space(); - let id = register_gradient(ctx, self, transforms); + let id = register_gradient(ctx, self, on_text, transforms); let name = Name(id.as_bytes()); ctx.content.set_fill_color_space(ColorSpaceOperand::Pattern); ctx.content.set_fill_pattern(None, name); } - fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms) { + fn set_as_stroke( + &self, + ctx: &mut PageContext, + on_text: bool, + transforms: Transforms, + ) { ctx.reset_stroke_color_space(); - let id = register_gradient(ctx, self, transforms); + let id = register_gradient(ctx, self, on_text, transforms); let name = Name(id.as_bytes()); ctx.content.set_stroke_color_space(ColorSpaceOperand::Pattern); @@ -279,6 +286,7 @@ impl PaintEncode for Gradient { fn register_gradient( ctx: &mut PageContext, gradient: &Gradient, + on_text: bool, mut transforms: Transforms, ) -> EcoString { // Edge cases for strokes. @@ -290,17 +298,21 @@ fn register_gradient( transforms.size.y = Abs::pt(1.0); } - let size = match gradient.unwrap_relative(false) { + let size = match gradient.unwrap_relative(on_text) { Relative::Self_ => transforms.size, Relative::Parent => transforms.container_size, }; + // Correction for y-axis flipping on text. + let angle = gradient.angle().unwrap_or_else(Angle::zero); + let angle = if on_text { Angle::rad(TAU as f64) - angle } else { angle }; + 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() { + _ => match angle.quadrant() { Quadrant::First => (Abs::zero(), Abs::zero()), Quadrant::Second => (size.x, Abs::zero()), Quadrant::Third => (size.x, size.y), @@ -310,10 +322,10 @@ fn register_gradient( let rotation = match gradient { Gradient::Conic(_) => Angle::zero(), - gradient => gradient.angle().unwrap_or_default(), + _ => angle, }; - let transform = match gradient.unwrap_relative(false) { + let transform = match gradient.unwrap_relative(on_text) { Relative::Self_ => transforms.transform, Relative::Parent => transforms.container_transform, }; @@ -339,6 +351,7 @@ fn register_gradient( size.aspect_ratio(), ))), gradient: gradient.clone(), + on_text, }; let index = ctx.parent.gradient_map.insert(pdf_gradient); @@ -371,9 +384,16 @@ fn write_patch( c0: [u16; 3], c1: [u16; 3], angle: Angle, + on_text: bool, ) { - let theta = -TAU * t + angle.to_rad() as f32 + PI; - let theta1 = -TAU * t1 + angle.to_rad() as f32 + PI; + let mut theta = -TAU * t + angle.to_rad() as f32 + PI; + let mut theta1 = -TAU * t1 + angle.to_rad() as f32 + PI; + + // Correction for y-axis flipping on text. + if on_text { + theta = (TAU - theta).rem_euclid(TAU); + theta1 = (TAU - theta1).rem_euclid(TAU); + } let (cp1, cp2) = control_point(Point::new(Abs::pt(0.5), Abs::pt(0.5)), 0.5, theta, theta1); @@ -434,10 +454,17 @@ fn control_point(c: Point, r: f32, angle_start: f32, angle_end: f32) -> (Point, } #[comemo::memoize] -fn compute_vertex_stream(conic: &ConicGradient) -> Arc> { +fn compute_vertex_stream( + conic: &ConicGradient, + aspect_ratio: Ratio, + on_text: bool, +) -> Arc> { // Generated vertices for the Coons patches let mut vertices = Vec::new(); + // Correct the gradient's angle + let angle = Gradient::correct_aspect_ratio(conic.angle, aspect_ratio); + // We want to generate a vertex based on some conditions, either: // - At the boundary of a stop // - At the boundary of a quadrant @@ -507,10 +534,19 @@ fn compute_vertex_stream(conic: &ConicGradient) -> Arc> { t_prime, conic.space.convert(c), c0, - conic.angle, + angle, + on_text, ); - write_patch(&mut vertices, t_prime, t_prime, c0, c1, conic.angle); + write_patch( + &mut vertices, + t_prime, + t_prime, + c0, + c1, + angle, + on_text, + ); write_patch( &mut vertices, @@ -518,7 +554,8 @@ fn compute_vertex_stream(conic: &ConicGradient) -> Arc> { t_next as f32, c1, conic.space.convert(c_next), - conic.angle, + angle, + on_text, ); t_x = t_next; @@ -533,7 +570,8 @@ fn compute_vertex_stream(conic: &ConicGradient) -> Arc> { t_next as f32, conic.space.convert(c), conic.space.convert(c_next), - conic.angle, + angle, + on_text, ); t_x = t_next; diff --git a/crates/typst/src/export/pdf/page.rs b/crates/typst/src/export/pdf/page.rs index 04470aad1..c36bed4e8 100644 --- a/crates/typst/src/export/pdf/page.rs +++ b/crates/typst/src/export/pdf/page.rs @@ -354,11 +354,11 @@ impl PageContext<'_, '_> { self.state.size = size; } - fn set_fill(&mut self, fill: &Paint, transforms: Transforms) { + fn set_fill(&mut self, fill: &Paint, on_text: bool, transforms: Transforms) { if self.state.fill.as_ref() != Some(fill) || matches!(self.state.fill, Some(Paint::Gradient(_))) { - fill.set_as_fill(self, transforms); + fill.set_as_fill(self, on_text, transforms); self.state.fill = Some(fill.clone()); } } @@ -390,7 +390,7 @@ impl PageContext<'_, '_> { miter_limit, } = stroke; - paint.set_as_stroke(self, transforms); + paint.set_as_stroke(self, false, transforms); self.content.set_line_width(thickness.to_f32()); if self.state.stroke.as_ref().map(|s| &s.line_cap) != Some(line_cap) { @@ -455,13 +455,21 @@ fn write_group(ctx: &mut PageContext, pos: Point, group: &GroupItem) { let translation = Transform::translate(pos.x, pos.y); ctx.save_state(); - ctx.transform(translation.pre_concat(group.transform)); if group.frame.kind().is_hard() { - ctx.group_transform(translation.pre_concat(group.transform)); + ctx.group_transform( + translation + .pre_concat( + ctx.state + .transform + .post_concat(ctx.state.container_transform.invert().unwrap()), + ) + .pre_concat(group.transform), + ); ctx.size(group.frame.size()); } + ctx.transform(translation.pre_concat(group.transform)); if let Some(clip_path) = &group.clip_path { write_path(ctx, 0.0, 0.0, clip_path); ctx.content.clip_nonzero(); @@ -485,7 +493,7 @@ fn write_text(ctx: &mut PageContext, pos: Point, text: &TextItem) { glyph_set.entry(g.id).or_insert_with(|| segment.into()); } - ctx.set_fill(&text.fill, ctx.state.transforms(Size::zero(), pos)); + ctx.set_fill(&text.fill, true, ctx.state.transforms(Size::zero(), pos)); ctx.set_font(&text.font, text.size); ctx.set_opacities(None, Some(&text.fill)); ctx.content.begin_text(); @@ -550,7 +558,7 @@ fn write_shape(ctx: &mut PageContext, pos: Point, shape: &Shape) { } if let Some(fill) = &shape.fill { - ctx.set_fill(fill, ctx.state.transforms(shape.geometry.bbox_size(), pos)); + ctx.set_fill(fill, false, ctx.state.transforms(shape.geometry.bbox_size(), pos)); } if let Some(stroke) = stroke { diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs index 090c9756b..7a33849fd 100644 --- a/crates/typst/src/export/render.rs +++ b/crates/typst/src/export/render.rs @@ -149,7 +149,7 @@ fn render_frame(canvas: &mut sk::Pixmap, state: State, frame: &Frame) { for (pos, item) in frame.items() { match item { FrameItem::Group(group) => { - render_group(canvas, state.pre_translate(*pos), group); + render_group(canvas, state, *pos, group); } FrameItem::Text(text) => { render_text(canvas, state.pre_translate(*pos), text); @@ -172,11 +172,18 @@ fn render_frame(canvas: &mut sk::Pixmap, state: State, frame: &Frame) { } /// Render a group frame with optional transform and clipping into the canvas. -fn render_group(canvas: &mut sk::Pixmap, state: State, group: &GroupItem) { +fn render_group(canvas: &mut sk::Pixmap, state: State, pos: Point, group: &GroupItem) { let state = match group.frame.kind() { - FrameKind::Soft => state.pre_concat(group.transform.into()), + FrameKind::Soft => state.pre_translate(pos).pre_concat(group.transform.into()), FrameKind::Hard => state + .pre_translate(pos) .pre_concat(group.transform.into()) + .pre_concat_container( + state + .transform + .post_concat(state.container_transform.invert().unwrap()), + ) + .pre_concat_container(Transform::translate(pos.x, pos.y).into()) .pre_concat_container(group.transform.into()) .with_size(group.frame.size()), }; @@ -375,15 +382,23 @@ fn render_outline_glyph( builder.0.finish()? }; - // TODO: Implement gradients on text. + let scale = text.size.to_f32() / text.font.units_per_em() as f32; + let mut pixmap = None; - let paint = to_sk_paint(&text.fill, state, Size::zero(), None, &mut pixmap, None); + let paint = to_sk_paint( + &text.fill, + state.pre_concat(sk::Transform::from_scale(scale, -scale)), + Size::zero(), + true, + None, + &mut pixmap, + None, + ); let rule = sk::FillRule::default(); // Flip vertically because font design coordinate // system is Y-up. - let scale = text.size.to_f32() / text.font.units_per_em() as f32; let ts = ts.pre_scale(scale, -scale); canvas.fill_path(&path, &paint, rule, ts, state.mask); return Some(()); @@ -410,31 +425,47 @@ fn render_outline_glyph( // doesn't exist, yet. let bitmap = rasterize(&text.font, id, ts.tx.to_bits(), ts.ty.to_bits(), ppem.to_bits())?; + match &text.fill { + Paint::Gradient(gradient) => { + let sampler = GradientSampler::new(gradient, &state, Size::zero(), true); + write_bitmap(canvas, &bitmap, &state, sampler)?; + } + Paint::Solid(color) => { + write_bitmap(canvas, &bitmap, &state, *color)?; + } + } + Some(()) +} + +fn write_bitmap( + canvas: &mut sk::Pixmap, + bitmap: &Bitmap, + state: &State, + sampler: S, +) -> Option<()> { // If we have a clip mask we first render to a pixmap that we then blend // with our canvas if state.mask.is_some() { let mw = bitmap.width; let mh = bitmap.height; - let color = text.fill.unwrap_solid(); - let color = sk::ColorU8::from(color); - // Pad the pixmap with 1 pixel in each dimension so that we do // not get any problem with floating point errors along their border let mut pixmap = sk::Pixmap::new(mw + 2, mh + 2)?; for x in 0..mw { for y in 0..mh { let alpha = bitmap.coverage[(y * mw + x) as usize]; - let color = sk::ColorU8::from_rgba( - color.red(), - color.green(), - color.blue(), - alpha, - ) - .premultiply(); + let color: sk::ColorU8 = sampler.sample((x, y)).into(); - pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] = color; + pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] = + sk::ColorU8::from_rgba( + color.red(), + color.green(), + color.blue(), + alpha, + ) + .premultiply(); } } @@ -461,10 +492,6 @@ fn render_outline_glyph( let top = bitmap.top; let bottom = top + mh; - // Premultiply the text color. - let Paint::Solid(color) = text.fill else { todo!() }; - let color = bytemuck::cast(sk::ColorU8::from(color).premultiply()); - // Blend the glyph bitmap with the existing pixels on the canvas. let pixels = bytemuck::cast_slice_mut::(canvas.data_mut()); for x in left.clamp(0, cw)..right.clamp(0, cw) { @@ -475,6 +502,8 @@ fn render_outline_glyph( continue; } + let color: sk::ColorU8 = sampler.sample((x as _, y as _)).into(); + let color = bytemuck::cast(color.premultiply()); let pi = (y * cw + x) as usize; if cov == 255 { pixels[pi] = color; @@ -510,8 +539,15 @@ 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, None); + let mut paint: sk::Paint = to_sk_paint( + fill, + state, + shape.geometry.bbox_size(), + false, + None, + &mut pixmap, + None, + ); if matches!(shape.geometry, Geometry::Rect(_)) { paint.anti_alias = false; @@ -578,6 +614,7 @@ fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option< paint, state, offset_bbox, + false, fill_transform, &mut pixmap, gradient_map, @@ -731,6 +768,71 @@ impl From for Transform { } } +/// Trait for sampling of a paint, used as a generic +/// abstraction over solid colors and gradients. +trait PaintSampler: Copy { + /// Sample the color at the `pos` in the pixmap. + fn sample(self, pos: (u32, u32)) -> Color; +} + +impl PaintSampler for Color { + fn sample(self, _: (u32, u32)) -> Color { + self + } +} + +/// State used when sampling colors for text. +/// +/// It caches the inverse transform to the parent, so that we can +/// reuse it instead of recomputing it for each pixel. +#[derive(Clone, Copy)] +struct GradientSampler<'a> { + gradient: &'a Gradient, + container_size: Size, + transform_to_parent: sk::Transform, +} + +impl<'a> GradientSampler<'a> { + fn new( + gradient: &'a Gradient, + state: &State, + item_size: Size, + on_text: bool, + ) -> Self { + let relative = gradient.unwrap_relative(on_text); + let container_size = match relative { + Relative::Self_ => item_size, + Relative::Parent => state.size, + }; + + let fill_transform = match relative { + Relative::Self_ => sk::Transform::identity(), + Relative::Parent => state.container_transform.invert().unwrap(), + }; + + Self { + gradient, + container_size, + transform_to_parent: fill_transform, + } + } +} + +impl PaintSampler for GradientSampler<'_> { + /// Samples a single point in a glyph. + fn sample(self, (x, y): (u32, u32)) -> Color { + // Compute the point in the gradient's coordinate space. + let mut point = sk::Point { x: x as f32, y: y as f32 }; + self.transform_to_parent.map_point(&mut point); + + // Sample the gradient + self.gradient.sample_at( + (point.x, point.y), + (self.container_size.x.to_f32(), self.container_size.y.to_f32()), + ) + } +} + /// Transforms a [`Paint`] into a [`sk::Paint`]. /// Applying the necessary transform, if the paint is a gradient. /// @@ -740,6 +842,7 @@ fn to_sk_paint<'a>( paint: &Paint, state: State, item_size: Size, + on_text: bool, fill_transform: Option, pixmap: &'a mut Option>, gradient_map: Option<(Point, Axes)>, @@ -782,7 +885,7 @@ fn to_sk_paint<'a>( sk_paint.anti_alias = true; } Paint::Gradient(gradient) => { - let relative = gradient.unwrap_relative(false); + let relative = gradient.unwrap_relative(on_text); let container_size = match relative { Relative::Self_ => item_size, Relative::Parent => state.size, diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs index 6399f77d9..00fc5c2ba 100644 --- a/crates/typst/src/export/svg.rs +++ b/crates/typst/src/export/svg.rs @@ -51,7 +51,7 @@ pub fn svg_merged(frames: &[Frame], padding: Abs) -> String { let [x, mut y] = [padding; 2]; for frame in frames { let ts = Transform::translate(x, y); - let state = State::new(frame.size(), ts); + let state = State::new(frame.size(), Transform::identity()); renderer.render_frame(state, ts, frame); y += frame.height() + padding; } @@ -262,9 +262,9 @@ impl SVGRenderer { fn render_group(&mut self, state: State, group: &GroupItem) { let state = match group.frame.kind() { FrameKind::Soft => state.pre_concat(group.transform), - FrameKind::Hard => { - state.with_transform(group.transform).with_size(group.frame.size()) - } + FrameKind::Hard => state + .with_transform(Transform::identity()) + .with_size(group.frame.size()), }; self.xml.start_element("g"); @@ -283,8 +283,7 @@ impl SVGRenderer { /// Render a text item. The text is rendered as a group of glyphs. We will /// try to render the text as SVG first, then bitmap, then outline. If none /// of them works, we will skip the text. - // TODO: implement gradient on text. - fn render_text(&mut self, _state: State, text: &TextItem) { + fn render_text(&mut self, state: State, text: &TextItem) { let scale: f64 = text.size.to_pt() / text.font.units_per_em(); let inv_scale: f64 = text.font.units_per_em() / text.size.to_pt(); @@ -302,7 +301,23 @@ impl SVGRenderer { self.render_svg_glyph(text, id, offset, inv_scale) .or_else(|| self.render_bitmap_glyph(text, id, offset, inv_scale)) - .or_else(|| self.render_outline_glyph(text, id, offset, inv_scale)); + .or_else(|| { + self.render_outline_glyph( + state + .pre_concat(Transform::scale( + Ratio::new(scale), + Ratio::new(-scale), + )) + .pre_translate(Point::new( + Abs::pt(offset / scale), + Abs::zero(), + )), + text, + id, + offset, + inv_scale, + ) + }); x += glyph.x_advance.at(text.size).to_pt(); } @@ -388,25 +403,45 @@ impl SVGRenderer { /// Render a glyph defined by an outline. fn render_outline_glyph( &mut self, + state: State, text: &TextItem, - id: GlyphId, + glyph_id: GlyphId, x_offset: f64, inv_scale: f64, ) -> Option<()> { - let path = convert_outline_glyph_to_path(&text.font, id)?; - let hash = hash128(&(&text.font, id)); + let path = convert_outline_glyph_to_path(&text.font, glyph_id)?; + let hash = hash128(&(&text.font, glyph_id)); let id = self.glyphs.insert_with(hash, || RenderedGlyph::Path(path)); self.xml.start_element("use"); self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); self.xml .write_attribute_fmt("x", format_args!("{}", x_offset * inv_scale)); - self.write_fill(&text.fill, Size::zero(), Transform::identity()); + self.write_fill( + &text.fill, + state.size, + self.text_paint_transform(state, &text.fill), + ); self.xml.end_element(); Some(()) } + fn text_paint_transform(&self, state: State, paint: &Paint) -> Transform { + let Paint::Gradient(gradient) = paint else { + return Transform::identity(); + }; + + match gradient.unwrap_relative(true) { + Relative::Self_ => Transform::scale(Ratio::one(), Ratio::one()), + Relative::Parent => Transform::scale( + Ratio::new(state.size.x.to_pt()), + Ratio::new(state.size.y.to_pt()), + ) + .post_concat(state.transform.invert().unwrap()), + } + } + /// Render a shape element. fn render_shape(&mut self, state: State, shape: &Shape) { self.xml.start_element("path"); diff --git a/crates/typst/src/geom/gradient.rs b/crates/typst/src/geom/gradient.rs index f4c750a37..b7944b10c 100644 --- a/crates/typst/src/geom/gradient.rs +++ b/crates/typst/src/geom/gradient.rs @@ -19,9 +19,6 @@ use crate::syntax::{Span, Spanned}; /// 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. -/// /// ```example /// #stack( /// dir: ltr, @@ -31,6 +28,25 @@ use crate::syntax::{Span, Spanned}; /// ) /// ``` /// +/// # Gradients on text +/// Gradients are supported on text but only when setting the relativeness to +/// either `{auto}` (the default value) or `{"parent"}`. It was decided that +/// glyph-by-glyph gradients would not be supported out-of-the-box but can be +/// emulated using [show rules]($styling/#show-rules). +/// +/// You can use gradients on text as follows: +/// +/// ```example +/// #set page(margin: 1pt) +/// #set text(fill: gradient.linear(red, blue)) +/// #let rainbow(content) = { +/// set text(fill: gradient.linear(..color.map.rainbow)) +/// box(content) +/// } +/// +/// This is a gradient on text, but with a #rainbow[twist]! +/// ``` +/// /// # Stops /// A gradient is composed of a series of stops. Each of these stops has a color /// and an offset. The offset is a [ratio]($ratio) between `{0%}` and `{100%}` or @@ -56,7 +72,8 @@ use crate::syntax::{Span, Spanned}; /// of a container. This container can either be the shape they are painted on, /// or to the closest container ancestor. This is controlled by the `relative` /// argument of a gradient constructor. By default, gradients are relative to -/// the shape they are painted on. +/// the shape they are painted on, unless the gradient is applied on text, in +/// which case they are relative to the closest ancestor container. /// /// Typst determines the ancestor container as follows: /// - For shapes that are placed at the root/top level of the document, the @@ -707,6 +724,22 @@ impl Gradient { } impl Gradient { + /// Clones this gradient, but with a different relative placement. + pub fn with_relative(mut self, relative: Relative) -> Self { + match &mut self { + Self::Linear(linear) => { + Arc::make_mut(linear).relative = Smart::Custom(relative); + } + Self::Radial(radial) => { + Arc::make_mut(radial).relative = Smart::Custom(relative); + } + Self::Conic(conic) => { + Arc::make_mut(conic).relative = Smart::Custom(relative); + } + } + + self + } /// Returns a reference to the stops of this gradient. pub fn stops_ref(&self) -> &[(Color, Ratio)] { match self { diff --git a/crates/typst/src/geom/paint.rs b/crates/typst/src/geom/paint.rs index c5a6c7e43..1277107f9 100644 --- a/crates/typst/src/geom/paint.rs +++ b/crates/typst/src/geom/paint.rs @@ -10,14 +10,26 @@ pub enum Paint { } impl Paint { - /// Temporary method to unwrap a solid color used for text rendering. + /// Unwraps a solid color used for text rendering. pub fn unwrap_solid(&self) -> Color { - // TODO: Implement gradients on text. match self { Self::Solid(color) => *color, Self::Gradient(_) => panic!("expected solid color"), } } + + /// Turns this paint into a paint for a text decoration. + /// + /// If this paint is a gradient, it will be converted to a gradient with + /// relative set to [`Relative::Parent`]. + pub fn as_decoration(&self) -> Self { + match self { + Self::Solid(color) => Self::Solid(*color), + Self::Gradient(gradient) => { + Self::Gradient(gradient.clone().with_relative(Relative::Parent)) + } + } + } } impl> From for Paint { diff --git a/tests/ref/visualize/gradient-text-decorations.png b/tests/ref/visualize/gradient-text-decorations.png new file mode 100644 index 000000000..887cd5008 Binary files /dev/null and b/tests/ref/visualize/gradient-text-decorations.png differ diff --git a/tests/ref/visualize/gradient-text-other.png b/tests/ref/visualize/gradient-text-other.png new file mode 100644 index 000000000..78555b18f Binary files /dev/null and b/tests/ref/visualize/gradient-text-other.png differ diff --git a/tests/ref/visualize/gradient-text.png b/tests/ref/visualize/gradient-text.png new file mode 100644 index 000000000..478a05863 Binary files /dev/null and b/tests/ref/visualize/gradient-text.png differ diff --git a/tests/typ/visualize/gradient-text-decorations.typ b/tests/typ/visualize/gradient-text-decorations.typ new file mode 100644 index 000000000..a4e861d84 --- /dev/null +++ b/tests/typ/visualize/gradient-text-decorations.typ @@ -0,0 +1,9 @@ +// Tests gradients on text decorations. + +--- + +#set text(fill: gradient.linear(red, blue)) + +Hello #underline[World]! \ +Hello #overline[World]! \ +Hello #strike[World]! \ diff --git a/tests/typ/visualize/gradient-text-other.typ b/tests/typ/visualize/gradient-text-other.typ new file mode 100644 index 000000000..04b84cb6c --- /dev/null +++ b/tests/typ/visualize/gradient-text-other.typ @@ -0,0 +1,14 @@ +// Test text gradients with radial and conic gradients. + +--- +#set page(width: 200pt, height: auto, margin: 10pt) +#set par(justify: true) +#set text(fill: gradient.radial(red, blue)) +#lorem(30) + + +--- +#set page(width: 200pt, height: auto, margin: 10pt) +#set par(justify: true) +#set text(fill: gradient.conic(red, blue, angle: 45deg)) +#lorem(30) diff --git a/tests/typ/visualize/gradient-text.typ b/tests/typ/visualize/gradient-text.typ index e93160832..a233ba6b9 100644 --- a/tests/typ/visualize/gradient-text.typ +++ b/tests/typ/visualize/gradient-text.typ @@ -1,7 +1,49 @@ -// Test that gradient fills on text don't work (for now). -// Ref: false +// Test that gradient fills on text. +// The solid bar gradients are used to make sure that all transforms are +// correct: if you can see the text through the bar, then the gradient is +// misaligned to its reference container. +// Ref: true --- -// Hint: 17-43 gradients on text will be supported soon -// Error: 17-43 text fill must be a solid color +// Ref: false +// Make sure they don't work when `relative: "self"`. + +// Hint: 17-61 make sure to set `relative: auto` on your text fill +// Error: 17-61 gradients on text must be relative to the parent +#set text(fill: gradient.linear(red, blue, relative: "self")) + +--- +// Test that gradient fills on text work for globally defined gradients. + +#set page(width: 200pt, height: auto, margin: 10pt, background: { + rect(width: 100%, height: 30pt, fill: gradient.linear(red, blue)) +}) +#set par(justify: true) #set text(fill: gradient.linear(red, blue)) +#lorem(30) + +--- +// Sanity check that the direction works on text. + +#set page(width: 200pt, height: auto, margin: 10pt, background: { + rect(height: 100%, width: 30pt, fill: gradient.linear(dir: btt, red, blue)) +}) +#set par(justify: true) +#set text(fill: gradient.linear(dir: btt, red, blue)) +#lorem(30) + +--- +// Test that gradient fills on text work for locally defined gradients. + +#set page(width: auto, height: auto, margin: 10pt) +#show box: set text(fill: gradient.linear(..color.map.rainbow)) + +Hello, #box[World]! + +--- +// Test that gradients fills on text work with transforms. + +#set page(width: auto, height: auto, margin: 10pt) +#show box: set text(fill: gradient.linear(..color.map.rainbow)) + +#rotate(45deg, box[World])