summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSébastien d'Herbais de Thun <sebastien.d.herbais@gmail.com>2023-10-12 18:03:52 +0200
committerGitHub <noreply@github.com>2023-10-12 18:03:52 +0200
commita59666369b946c3a8b62db363659cbfca35f0a26 (patch)
tree4707eac9a6d6ee8a271c87fb5123e2a74c559f55
parentd3b62bd02ef92b747b724f344e9b8c7d5668ffbe (diff)
Part 5a: Gradients on text with `relative: auto` or `relative: "parent"` (#2364)
-rw-r--r--crates/typst-library/src/text/deco.rs2
-rw-r--r--crates/typst-library/src/text/mod.rs14
-rw-r--r--crates/typst/src/export/pdf/color.rs25
-rw-r--r--crates/typst/src/export/pdf/gradient.rs72
-rw-r--r--crates/typst/src/export/pdf/page.rs22
-rw-r--r--crates/typst/src/export/render.rs153
-rw-r--r--crates/typst/src/export/svg.rs57
-rw-r--r--crates/typst/src/geom/gradient.rs41
-rw-r--r--crates/typst/src/geom/paint.rs16
-rw-r--r--tests/ref/visualize/gradient-text-decorations.pngbin0 -> 6393 bytes
-rw-r--r--tests/ref/visualize/gradient-text-other.pngbin0 -> 60270 bytes
-rw-r--r--tests/ref/visualize/gradient-text.pngbin0 -> 48309 bytes
-rw-r--r--tests/typ/visualize/gradient-text-decorations.typ9
-rw-r--r--tests/typ/visualize/gradient-text-other.typ14
-rw-r--r--tests/typ/visualize/gradient-text.typ48
15 files changed, 389 insertions, 84 deletions
diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs
index 20323207..136dfad8 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 b380431b..748d71ad 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<Spanned<Paint>> = 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 6ad273d8..d5b6f3f1 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 1123b53e..6ecf8588 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::<Vec<_>>()
{
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<Vec<u8>> {
+fn compute_vertex_stream(
+ conic: &ConicGradient,
+ aspect_ratio: Ratio,
+ on_text: bool,
+) -> Arc<Vec<u8>> {
// 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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 04470aad..c36bed4e 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 090c9756..7a33849f 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<S: PaintSampler>(
+ 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();
-
- pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] = color;
+ let color: sk::ColorU8 = sampler.sample((x, y)).into();
+
+ 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::<u8, u32>(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<sk::Transform> 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<sk::Transform>,
pixmap: &'a mut Option<Arc<sk::Pixmap>>,
gradient_map: Option<(Point, Axes<Ratio>)>,
@@ -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 6399f77d..00fc5c2b 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 f4c750a3..b7944b10 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 c5a6c7e4..1277107f 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<T: Into<Color>> From<T> 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 00000000..887cd500
--- /dev/null
+++ b/tests/ref/visualize/gradient-text-decorations.png
Binary files differ
diff --git a/tests/ref/visualize/gradient-text-other.png b/tests/ref/visualize/gradient-text-other.png
new file mode 100644
index 00000000..78555b18
--- /dev/null
+++ b/tests/ref/visualize/gradient-text-other.png
Binary files differ
diff --git a/tests/ref/visualize/gradient-text.png b/tests/ref/visualize/gradient-text.png
new file mode 100644
index 00000000..478a0586
--- /dev/null
+++ b/tests/ref/visualize/gradient-text.png
Binary files differ
diff --git a/tests/typ/visualize/gradient-text-decorations.typ b/tests/typ/visualize/gradient-text-decorations.typ
new file mode 100644
index 00000000..a4e861d8
--- /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 00000000..04b84cb6
--- /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 e9316083..a233ba6b 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).
+// 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
+
+---
// 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"))
---
-// Hint: 17-43 gradients on text will be supported soon
-// Error: 17-43 text fill must be a solid color
+// 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])