1use crate::curve::Spline;
41use crate::error::{Error, Result};
42use crate::image::RgbaImage;
43
44#[derive(Debug, Clone, PartialEq)]
46pub struct Stop {
47 pub color: [f64; 4],
49 pub flags: u32,
51 pub position: f64,
53 pub curve: Vec<(f64, f64)>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum Mixing {
61 Normal,
63 Perceptual,
69 Oklab,
71 Linear,
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub struct Ramp {
78 pub stops: Vec<Stop>,
80 pub mixing: Mixing,
82 pub correction: i32,
84 curves: Vec<Spline>,
85}
86
87const DEFAULT_CURVE: [(f64, f64); 4] = [(0.0, 0.0), (0.25, 0.1), (0.75, 0.9), (1.0, 1.0)];
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum Shape {
94 Linear,
96 Circular,
98}
99
100#[derive(Debug, Clone, PartialEq)]
102pub struct Gradient {
103 pub ramp: Ramp,
105 pub fill: bool,
107 pub shape: Shape,
109 pub ellipse: [f64; 3],
111 pub start: (f64, f64),
113 pub end: (f64, f64),
115}
116
117fn word(data: &[u8], offset: usize) -> Result<u32> {
118 data.get(offset..offset + 4)
119 .map(|b| u32::from_be_bytes(b.try_into().expect("4")))
120 .ok_or_else(|| Error::format("GradationFillInfo is cut short"))
121}
122
123fn real(data: &[u8], offset: usize) -> Result<f64> {
124 data.get(offset..offset + 8)
125 .map(|b| f64::from_be_bytes(b.try_into().expect("8")))
126 .ok_or_else(|| Error::format("GradationFillInfo is cut short"))
127}
128
129fn sections(data: &[u8]) -> Vec<(String, &[u8])> {
133 let total = word(data, 0).map(|t| t as usize).unwrap_or(0).min(data.len());
134 let name_at = |at: usize| -> Option<(String, usize)> {
135 let n = word(data, at).ok()? as usize;
136 let units: Vec<u16> = data
137 .get(at + 4..at + 4 + 2 * n)?
138 .as_chunks::<2>()
139 .0
140 .iter()
141 .map(|u| u16::from_be_bytes([u[0], u[1]]))
142 .collect();
143 let name = String::from_utf16(&units).ok()?;
144 (n >= 3 && name.starts_with("Gradation") && name.chars().all(|c| c.is_ascii_alphanumeric()))
145 .then_some((name, at + 4 + 2 * n))
146 };
147 let mut names = Vec::new();
148 let mut at = 8;
149 while at + 4 <= total {
150 match name_at(at) {
151 Some((name, end)) => {
152 names.push((at, name, end));
153 at = end;
154 }
155 None => at += 2,
156 }
157 }
158 names
159 .iter()
160 .enumerate()
161 .map(|(i, (_, name, start))| {
162 let end = names.get(i + 1).map(|next| next.0).unwrap_or(total);
163 (name.clone(), &data[*start..end])
164 })
165 .collect()
166}
167
168impl Ramp {
169 pub fn parse(stops_data: &[u8]) -> Result<Self> {
171 let header = word(stops_data, 4)? as usize;
172 let stop_size = word(stops_data, 8)? as usize;
173 let count = word(stops_data, 12)? as usize;
174 if stop_size < 28 || count == 0 || count > 256 {
175 return Err(Error::format(format!("GradationData: {count} stops of {stop_size} bytes")));
176 }
177 let (mixing, correction) = if header >= 24 {
178 let mixing = match word(stops_data, 20)? {
179 1 => Mixing::Perceptual,
180 2 => Mixing::Oklab,
181 3 => Mixing::Linear,
182 _ => Mixing::Normal,
183 };
184 (mixing, word(stops_data, 24)? as i32)
185 } else {
186 (Mixing::Normal, 0)
187 };
188 let mut stops = Vec::with_capacity(count);
189 let mut curve_offset = 4 + header + count * stop_size;
190 for index in 0..count {
191 let at = 4 + header + index * stop_size;
192 let channel = |i: usize| -> Result<f64> { Ok((word(stops_data, at + 4 * i)? >> 16) as f64 / 65535.0) };
193 let color = [channel(0)?, channel(1)?, channel(2)?, channel(3)?];
194 let flags = word(stops_data, at + 16)?;
195 let position = word(stops_data, at + 20)? as f64 / 32768.0;
196 let mut curve = Vec::new();
197 if flags & 0x10 != 0 {
198 let points = (word(stops_data, at + 24)? as usize).min(64);
199 for p in 0..points {
200 curve
201 .push((real(stops_data, curve_offset + 16 * p)?, real(stops_data, curve_offset + 16 * p + 8)?));
202 }
203 curve_offset += 16 * points;
204 }
205 stops.push(Stop { color, flags, position: position.clamp(0.0, 1.0), curve });
206 }
207 stops.sort_by(|a, b| a.position.total_cmp(&b.position));
208 let curves = stops
209 .iter()
210 .map(|stop| if stop.flags & 0x10 != 0 { Spline::new(&stop.curve) } else { Spline::new(&DEFAULT_CURVE) })
211 .collect();
212 Ok(Ramp { stops, mixing, correction, curves })
213 }
214
215 pub fn color(&self, t: f64) -> [f64; 4] {
217 let first = &self.stops[0];
218 if t <= first.position {
219 return first.color;
220 }
221 for (index, pair) in self.stops.windows(2).enumerate() {
222 let (a, b) = (&pair[0], &pair[1]);
223 if t <= b.position {
224 let span = b.position - a.position;
225 let k = if span > 0.0 { (t - a.position) / span } else { 1.0 };
226 let k = self.curves[index].at(k.clamp(0.0, 1.0));
227 let (mut from, mut to) = (a.color, b.color);
228 if from[3] <= 0.0 {
229 from[..3].copy_from_slice(&to[..3]);
230 } else if to[3] <= 0.0 {
231 to[..3].copy_from_slice(&from[..3]);
232 }
233 return mix(self.mixing, from, to, k, self.correction);
234 }
235 }
236 self.stops.last().map(|s| s.color).unwrap_or([0.0; 4])
237 }
238}
239
240fn mix(mixing: Mixing, a: [f64; 4], b: [f64; 4], k: f64, correction: i32) -> [f64; 4] {
241 let rgb = |c: [f64; 4]| [c[0], c[1], c[2]];
242 let alpha = a[3] + (b[3] - a[3]) * k;
243 let k = if mixing == Mixing::Perceptual {
244 perceptual_weight(oklab(rgb(a))[0], oklab(rgb(b))[0], k, correction)
245 } else {
246 k
247 };
248 let lerp = |x: f64, y: f64| x + (y - x) * k;
249 let [r, g, bl] = match mixing {
250 Mixing::Normal => [lerp(a[0], b[0]), lerp(a[1], b[1]), lerp(a[2], b[2])],
251 Mixing::Linear => {
252 let (x, y) = (rgb(a).map(to_linear), rgb(b).map(to_linear));
253 std::array::from_fn::<f64, 3, _>(|c| from_linear(lerp(x[c], y[c])))
254 }
255 Mixing::Oklab | Mixing::Perceptual => {
256 let (x, y) = (oklab(rgb(a)), oklab(rgb(b)));
257 from_oklab(std::array::from_fn(|c| lerp(x[c], y[c])))
258 }
259 };
260 [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), bl.clamp(0.0, 1.0), alpha]
261}
262
263fn perceptual_weight(lightness_a: f64, lightness_b: f64, k: f64, correction: i32) -> f64 {
268 let r = (0.195 + 0.104 * correction as f64).clamp(0.05, 1.0).powf(lightness_b - lightness_a);
269 let denominator = (1.0 - k) + k * r;
270 if denominator <= 0.0 { k } else { k * r / denominator }
271}
272
273fn to_linear(c: f64) -> f64 {
274 if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
275}
276
277fn from_linear(c: f64) -> f64 {
278 if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.max(0.0).powf(1.0 / 2.4) - 0.055 }
279}
280
281fn oklab(c: [f64; 3]) -> [f64; 3] {
282 let [r, g, b] = c.map(to_linear);
283 let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt();
284 let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt();
285 let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt();
286 [
287 0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
288 1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
289 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
290 ]
291}
292
293fn from_oklab(c: [f64; 3]) -> [f64; 3] {
294 let l = (c[0] + 0.396_337_777_4 * c[1] + 0.215_803_757_3 * c[2]).powi(3);
295 let m = (c[0] - 0.105_561_345_8 * c[1] - 0.063_854_172_8 * c[2]).powi(3);
296 let s = (c[0] - 0.089_484_177_5 * c[1] - 1.291_485_548_0 * c[2]).powi(3);
297 [
298 4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s,
299 -1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s,
300 -0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701_0 * s,
301 ]
302 .map(from_linear)
303}
304
305impl Gradient {
306 pub fn parse(data: &[u8]) -> Result<Self> {
308 let sections = sections(data);
309 let find = |name: &str| {
310 sections
311 .iter()
312 .find(|(n, _)| n == name)
313 .map(|(_, payload)| *payload)
314 .ok_or_else(|| Error::format(format!("GradationFillInfo has no {name}")))
315 };
316 let ramp = Ramp::parse(find("GradationData")?)?;
317 let setting = find("GradationSetting")?;
318 let shape = if word(setting, 4)? == 1 { Shape::Circular } else { Shape::Linear };
319 let r = |i: usize| real(setting, 12 + 8 * i);
320 let fill = find("GradationSettingAdd0001").ok().and_then(|add| word(add, 4).ok()) == Some(1);
321 Ok(Gradient { ramp, fill, shape, ellipse: [r(0)?, r(1)?, r(2)?], start: (r(3)?, r(4)?), end: (r(5)?, r(6)?) })
322 }
323
324 pub fn position(&self, x: f64, y: f64) -> f64 {
326 let t = match self.shape {
327 Shape::Linear => {
328 let (dx, dy) = (self.end.0 - self.start.0, self.end.1 - self.start.1);
329 let length2 = dx * dx + dy * dy;
330 if length2 <= 0.0 { 0.0 } else { ((x - self.start.0) * dx + (y - self.start.1) * dy) / length2 }
331 }
332 Shape::Circular => {
333 let radius = self.ellipse[0] / 2.0;
334 if radius <= 0.0 { 0.0 } else { (x - self.start.0).hypot(y - self.start.1) / radius }
335 }
336 };
337 t.clamp(0.0, 1.0)
338 }
339
340 pub fn is_approximate(&self) -> bool {
342 !self.fill && self.ramp.mixing == Mixing::Perceptual && self.ramp.stops.len() > 1
343 }
344
345 pub fn color(&self, t: f64) -> [f64; 4] {
347 if self.fill { self.ramp.stops[0].color } else { self.ramp.color(t) }
348 }
349
350 pub fn draw(&self, width: u32, height: u32, scale: f64) -> RgbaImage {
352 let mut image = RgbaImage::new(width, height);
353 for y in 0..height {
354 for x in 0..width {
355 let t = self.position((x as f64 + 0.5) / scale, (y as f64 + 0.5) / scale);
356 let c = self.color(t).map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8);
357 image.set_pixel(x, y, c);
358 }
359 }
360 image
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn name(text: &str) -> Vec<u8> {
369 let mut out = (text.len() as u32).to_be_bytes().to_vec();
370 for unit in text.encode_utf16() {
371 out.extend(unit.to_be_bytes());
372 }
373 out
374 }
375
376 fn sample() -> Vec<u8> {
379 let words = |values: &[u32]| values.iter().flat_map(|w| w.to_be_bytes()).collect::<Vec<u8>>();
380 let mut data = words(&[0, 2]);
381 data.extend(name("GradationData"));
382 data.extend(words(&[72, 16, 28, 2, 16]));
383 data.extend(words(&[0, 0, 0, 0xffff_ffff, 0, 0, 0]));
384 data.extend(words(&[0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0, 0, 0x8000, 0]));
385 data.extend(name("GradationSetting"));
386 data.extend(words(&[0, 0, 1]));
387 for value in [100.0f64, 100.0, 0.0, 0.0, 0.0, 100.0, 0.0] {
388 data.extend(value.to_be_bytes());
389 }
390 data.extend(name("GradationSettingAdd0001"));
391 data.extend(words(&[0x14, 0, 0, 0, 0, 1]));
392 let total = data.len() as u32;
393 data[..4].copy_from_slice(&total.to_be_bytes());
394 data
395 }
396
397 #[test]
398 fn parses_and_interpolates() {
399 let gradient = Gradient::parse(&sample()).unwrap();
400 assert_eq!(gradient.shape, Shape::Linear);
401 assert_eq!(gradient.ramp.stops.len(), 2);
402 assert_eq!(gradient.ramp.mixing, Mixing::Normal);
403 assert!(!gradient.fill);
404 assert_eq!(gradient.end, (100.0, 0.0));
405 assert_eq!(gradient.position(50.0, 7.0), 0.5);
406 assert_eq!(gradient.position(-5.0, 0.0), 0.0);
407 let mid = gradient.color(0.5);
409 assert!(mid[0].abs() < 1e-9 && (mid[3] - 0.5).abs() < 1e-3, "{mid:?}");
410 let quarter = gradient.color(0.25);
412 assert!((quarter[3] - (1.0 - 0.178)).abs() < 0.005, "{quarter:?}");
413 }
414
415 #[test]
416 fn mixing_spaces_keep_the_ends() {
417 let (red, blue) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
418 for mixing in [Mixing::Normal, Mixing::Linear, Mixing::Oklab] {
419 let end = mix(mixing, red, blue, 1.0, 3);
420 assert!((end[2] - 1.0).abs() < 1e-6 && end[0].abs() < 1e-6, "{mixing:?} {end:?}");
421 }
422 let middle = mix(Mixing::Linear, red, blue, 0.5, 3);
424 assert!((middle[0] * 255.0 - 187.5).abs() < 1.0, "{middle:?}");
425 }
426
427 #[test]
428 fn perceptual_grey_ramps_follow_csp() {
429 let (black, white) = ([0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]);
432 for (correction, expected) in [(0, 13.0), (3, 55.0)] {
433 let grey = mix(Mixing::Perceptual, black, white, 0.5, correction)[0] * 255.0;
434 assert!((grey - expected).abs() < 4.0, "{correction}: {grey}");
435 let back = mix(Mixing::Perceptual, white, black, 0.5, correction)[0] * 255.0;
436 assert!((back - expected).abs() < 4.0, "{correction}: {back}");
437 }
438 }
439}