Skip to main content

alpha2_text/
gradient.rs

1//! Gradient fills of glyphs, matching shape paints (`alpha2-render`'s `paint_wgsl.rs`): sRGB
2//! stops interpolated premultiplied in linear light. Uses `f32` with `libm` so native and wasm
3//! give the same bytes. COLR font gradients follow their own rules in `color.rs`.
4
5use std::f32::consts::TAU;
6
7/// A gradient over a text's layout box. Points are fractions of the box (`(0, 0)` top-left) and
8/// a radius is a fraction of its shorter side.
9#[derive(Clone, Debug, PartialEq)]
10pub struct TextGradient {
11    /// Geometry.
12    pub kind: TextGradientKind,
13    /// Two or more stops with non-decreasing offsets in `0..=1`.
14    pub stops: Vec<TextGradientStop>,
15    /// Behavior past the end stops; ignored by conic gradients.
16    pub spread: TextSpread,
17}
18
19/// Where a gradient's offsets lie.
20#[derive(Copy, Clone, Debug, PartialEq)]
21pub enum TextGradientKind {
22    /// Offset 0 at `start`, 1 at `end`, constant across the line.
23    Linear {
24        /// Where offset 0 lies.
25        start: [f32; 2],
26        /// Where offset 1 lies; distinct from `start`.
27        end: [f32; 2],
28    },
29    /// Offset 1 at `radius` from `center`.
30    Radial {
31        /// Where offset 0 lies.
32        center: [f32; 2],
33        /// Positive, a fraction of the box's shorter side.
34        radius: f32,
35    },
36    /// One clockwise turn about `center`, starting `angle` radians clockwise from straight up.
37    Conic {
38        /// Center of rotation.
39        center: [f32; 2],
40        /// Where offset 0 lies.
41        angle: f32,
42    },
43}
44
45/// One color of a [`TextGradient`].
46#[derive(Copy, Clone, Debug, PartialEq)]
47pub struct TextGradientStop {
48    /// Position along the gradient, in `0..=1`.
49    pub offset: f32,
50    /// Straight, sRGB-encoded RGBA in `0..=1`.
51    pub color: [f32; 4],
52}
53
54/// What a linear or radial gradient shows past its first and last stops.
55#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
56pub enum TextSpread {
57    /// The end colors continue.
58    #[default]
59    Pad,
60    /// The gradient starts again.
61    Repeat,
62    /// The gradient runs back and forth.
63    Reflect,
64}
65
66/// Same limit as shape paints.
67pub(crate) const MAX_STOPS: usize = 16;
68
69impl TextGradient {
70    // Coincident linear end points are compared exactly, as the model does.
71    #[allow(clippy::float_cmp)]
72    pub(crate) fn is_valid(&self) -> bool {
73        let finite = |point: [f32; 2]| point.iter().all(|value| value.is_finite());
74        let geometry = match self.kind {
75            TextGradientKind::Linear { start, end } => finite(start) && finite(end) && start != end,
76            TextGradientKind::Radial { center, radius } => {
77                finite(center) && radius.is_finite() && radius > 0.0
78            }
79            TextGradientKind::Conic { center, angle } => finite(center) && angle.is_finite(),
80        };
81        geometry
82            && (2..=MAX_STOPS).contains(&self.stops.len())
83            && self.stops.iter().all(|stop| {
84                (0.0..=1.0).contains(&stop.offset)
85                    && stop.color.iter().all(|value| (0.0..=1.0).contains(value))
86            })
87            && self
88                .stops
89                .windows(2)
90                .all(|pair| pair[0].offset <= pair[1].offset)
91    }
92
93    /// Converts to pixel geometry and premultiplied linear stops, once per raster.
94    pub(crate) fn prepared(&self, size: [f32; 2]) -> PreparedGradient<'_> {
95        let at = |point: [f32; 2]| [point[0] * size[0], point[1] * size[1]];
96        let kind = match self.kind {
97            TextGradientKind::Linear { start, end } => TextGradientKind::Linear {
98                start: at(start),
99                end: at(end),
100            },
101            TextGradientKind::Radial { center, radius } => TextGradientKind::Radial {
102                center: at(center),
103                radius: radius * size[0].min(size[1]),
104            },
105            TextGradientKind::Conic { center, angle } => TextGradientKind::Conic {
106                center: at(center),
107                angle,
108            },
109        };
110        PreparedGradient {
111            gradient: self,
112            kind,
113            colors: self
114                .stops
115                .iter()
116                .map(|stop| premultiplied_linear(stop.color))
117                .collect(),
118        }
119    }
120}
121
122/// A gradient with its stop colors converted.
123pub(crate) struct PreparedGradient<'a> {
124    gradient: &'a TextGradient,
125    /// Geometry in pixels.
126    kind: TextGradientKind,
127    colors: Vec<[f32; 4]>,
128}
129
130impl PreparedGradient<'_> {
131    /// Straight sRGB bytes at `point`, matching the stored shape paint value.
132    pub(crate) fn rgba8_at(&self, point: [f32; 2]) -> [u8; 4] {
133        stored_rgba8(self.linear_at(point))
134    }
135
136    pub(crate) fn linear_at(&self, point: [f32; 2]) -> [f32; 4] {
137        let gradient = self.gradient;
138        let t = spread(self.kind, gradient.spread, position(self.kind, point));
139        let stops = &gradient.stops;
140        if t <= stops[0].offset {
141            return self.colors[0];
142        }
143        for index in 1..stops.len() {
144            if t <= stops[index].offset {
145                let span = stops[index].offset - stops[index - 1].offset;
146                let weight = if span <= 0.0 {
147                    1.0
148                } else {
149                    (t - stops[index - 1].offset) / span
150                };
151                let (from, to) = (self.colors[index - 1], self.colors[index]);
152                // WGSL's `mix`: from * (1 - w) + to * w.
153                return std::array::from_fn(|channel| {
154                    from[channel] * (1.0 - weight) + to[channel] * weight
155                });
156            }
157        }
158        self.colors[stops.len() - 1]
159    }
160}
161
162fn position(kind: TextGradientKind, point: [f32; 2]) -> f32 {
163    match kind {
164        TextGradientKind::Linear { start, end } => {
165            let direction = [end[0] - start[0], end[1] - start[1]];
166            let from = [point[0] - start[0], point[1] - start[1]];
167            dot(from, direction) / dot(direction, direction).max(0.000_001)
168        }
169        TextGradientKind::Radial { center, radius } => {
170            let from = [point[0] - center[0], point[1] - center[1]];
171            dot(from, from).sqrt() / radius.max(0.000_001)
172        }
173        TextGradientKind::Conic { center, angle } => {
174            let from = [point[0] - center[0], point[1] - center[1]];
175            if dot(from, from) <= 0.0 {
176                return 0.0;
177            }
178            // Clockwise from straight up (-y), a whole turn from 0 to 1.
179            let turn = (libm::atan2f(from[0], -from[1]) - angle) / TAU;
180            turn - turn.floor()
181        }
182    }
183}
184
185fn spread(kind: TextGradientKind, spread: TextSpread, t: f32) -> f32 {
186    if matches!(kind, TextGradientKind::Conic { .. }) {
187        return t;
188    }
189    match spread {
190        TextSpread::Pad => t.clamp(0.0, 1.0),
191        TextSpread::Repeat => t - t.floor(),
192        TextSpread::Reflect => {
193            let turn = t - 2.0 * (t * 0.5).floor();
194            if turn > 1.0 { 2.0 - turn } else { turn }
195        }
196    }
197}
198
199fn dot(left: [f32; 2], right: [f32; 2]) -> f32 {
200    left[0] * right[0] + left[1] * right[1]
201}
202
203/// Straight sRGB to premultiplied linear light; 1 stays exactly 1.
204pub(crate) fn premultiplied_linear(color: [f32; 4]) -> [f32; 4] {
205    let alpha = color[3].clamp(0.0, 1.0);
206    let linear = |value: f32| {
207        let value = value.clamp(0.0, 1.0);
208        if value >= 1.0 {
209            1.0
210        } else if value <= 0.040_45 {
211            value / 12.92
212        } else {
213            libm::powf((value + 0.055) / 1.055, 2.4)
214        }
215    };
216    [
217        linear(color[0]) * alpha,
218        linear(color[1]) * alpha,
219        linear(color[2]) * alpha,
220        alpha,
221    ]
222}
223
224/// Premultiplied linear light to straight sRGB bytes; 1 stays exactly 1.
225#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
226pub(crate) fn stored_rgba8(color: [f32; 4]) -> [u8; 4] {
227    let alpha = color[3].clamp(0.0, 1.0);
228    if alpha <= 0.0 {
229        return [0; 4];
230    }
231    let encode = |value: f32| {
232        let value = (value / alpha).clamp(0.0, 1.0);
233        if value >= 1.0 {
234            1.0
235        } else if value <= 0.003_130_8 {
236            value * 12.92
237        } else {
238            1.055 * libm::powf(value, 1.0 / 2.4) - 0.055
239        }
240    };
241    let byte = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
242    [
243        byte(encode(color[0])),
244        byte(encode(color[1])),
245        byte(encode(color[2])),
246        byte(alpha),
247    ]
248}