Skip to main content

csp_format/
gradient.rs

1//! Gradient layers (`Layer.GradationFillInfo`, R10): CSP saves no pixels
2//! for them and draws the gradient when it opens the file.
3//!
4//! Big endian, the same section layout as `LayerEffectInfo`: `u32 total,
5//! u32 2`, then sections of `u32 n, UTF-16BE name (n units), payload`:
6//!
7//! ```text
8//! GradationData
9//!   u32 size (bytes after this field)
10//!   u32 header size (16 or 24, counted from here)  u32 stop size (28)
11//!   u32 stop count  u32 16  [i32 mixing, i32 luminance correction when
12//!   the header is 24 bytes; 16-byte headers mix normally]
13//!   stop × count:
14//!     u32 R, G, B, A (16-bit value repeated in both halves)
15//!     u32 flags (1 main colour, 2 sub colour, 0x10 curved to the next stop)
16//!     u32 position (0..=0x8000)   u32 points in the curve (flag 0x10)
17//!   curve × (stops with flag 0x10): f64 x, y pairs (0..1)
18//! GradationSetting
19//!   u32 ?  u32 shape (0 linear, 1 circular)  u32 ?
20//!   f64 ellipse width, height, rotation (degrees; circular shapes)
21//!   f64 start x, y, end x, y (canvas pixels)
22//! GradationSettingAdd0001
23//!   u32 0x14  u32 fill (1: a fill layer, drawn in the first stop's colour)
24//!   u32 R, G, B  u32 1
25//! ```
26//!
27//! Between two stops the mix follows a curve (`curve.rs`): the
28//! stop's own control points when it has flag 0x10, otherwise CSP's
29//! default S curve through (0, 0), (0.25, 0.1), (0.75, 0.9), (1, 1).
30//! Colours mix in straight (not premultiplied) sRGB for the normal mode,
31//! in Oklab for 知覚的 (Photoshop互換) and in linear light for リニア
32//! (Photoshop互換); alpha mixes linearly. A fully transparent stop takes
33//! the colour of the stop it mixes with, so only the alpha fades. 知覚的
34//! is CSP's own pigment-like mix, approximated (see [`Mixing::Perceptual`]).
35//! Outside the start and end the end colours continue. A circular
36//! gradient is a circle around the start whose radius is half the
37//! ellipse width (CSP ignores the height and rotation it saves). Measured
38//! in `docs/validation/V6b-generated-layers.md`.
39
40use crate::curve::Spline;
41use crate::error::{Error, Result};
42use crate::image::RgbaImage;
43
44/// One colour stop.
45#[derive(Debug, Clone, PartialEq)]
46pub struct Stop {
47    /// Straight RGBA, 0..1.
48    pub color: [f64; 4],
49    /// 1 main colour, 2 sub colour, 0x10 curved to the next stop (`curve` holds the curve).
50    pub flags: u32,
51    /// 0..1 along the gradient.
52    pub position: f64,
53    /// Control points (x, y) shaping the change to the next stop.
54    pub curve: Vec<(f64, f64)>,
55}
56
57/// How colours mix between stops (`GradationData` mixing word).
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum Mixing {
61    /// 通常: straight sRGB values.
62    Normal,
63    /// 知覚的: a pigment-like mix where the darker colour weighs more
64    /// (black to white is about a fifth of the way at mid with no
65    /// luminance correction, a third with 3). Approximated as Oklab with
66    /// that weighting: grey ramps within a few levels, colour pairs off by
67    /// up to a few tens, because CSP also darkens some pairs in between.
68    Perceptual,
69    /// 知覚的 (Photoshop互換): Oklab.
70    Oklab,
71    /// リニア (Photoshop互換): linear light.
72    Linear,
73}
74
75/// The stops of a gradient (also the colours of a gradient map).
76#[derive(Debug, Clone, PartialEq)]
77pub struct Ramp {
78    /// The stops, by position.
79    pub stops: Vec<Stop>,
80    /// How colours mix between stops.
81    pub mixing: Mixing,
82    /// Luminance correction (0 none … 3 high); only used by 知覚的.
83    pub correction: i32,
84    curves: Vec<Spline>,
85}
86
87/// CSP's curve between stops that store none.
88const DEFAULT_CURVE: [(f64, f64); 4] = [(0.0, 0.0), (0.25, 0.1), (0.75, 0.9), (1.0, 1.0)];
89
90/// Gradient shape.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum Shape {
94    /// 直線.
95    Linear,
96    /// 円 (an ellipse).
97    Circular,
98}
99
100/// A gradient or fill layer (`Layer.GradationFillInfo`).
101#[derive(Debug, Clone, PartialEq)]
102pub struct Gradient {
103    /// The colours.
104    pub ramp: Ramp,
105    /// A fill layer (べた塗り): the first stop's colour everywhere.
106    pub fill: bool,
107    /// Linear or circular.
108    pub shape: Shape,
109    /// Ellipse width and height (pixels) and rotation (degrees).
110    pub ellipse: [f64; 3],
111    /// Start point in layer pixels.
112    pub start: (f64, f64),
113    /// End point in layer pixels.
114    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
129/// The sections (name, payload) of a GradationFillInfo blob. Payload
130/// sizes differ by section and version, so a section runs to the next
131/// name (`u32 n` and n UTF-16 units spelling `Gradation…`).
132fn 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    /// Parse a `GradationData` payload (starting at its size word).
170    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    /// Straight RGBA (0..1) at position `t`.
216    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
263/// The share of the second colour in a 知覚的 mix at `k`: each colour
264/// weighs by its darkness, the lighter one `r` times the darker per unit
265/// of Oklab lightness between them, with `r` growing with the luminance
266/// correction (fitted to CSP's black-to-white ramps, corrections 0 to 3).
267fn 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    /// Parses `GradationFillInfo`.
307    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    /// Position (0..1, clamped) of the canvas point `(x, y)`.
325    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    /// True when CSP's look is only approximated (the 知覚的 mix).
341    pub fn is_approximate(&self) -> bool {
342        !self.fill && self.ramp.mixing == Mixing::Perceptual && self.ramp.stops.len() > 1
343    }
344
345    /// Straight RGBA (0..1) at position `t`.
346    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    /// The gradient at `scale` into a `width × height` image (pixel centres).
351    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    /// A two-stop linear gradient from opaque black (x = 0) to transparent
377    /// white (x = 100), shaped like the files CSP writes.
378    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        // The transparent end keeps the black: only the alpha fades.
408        let mid = gradient.color(0.5);
409        assert!(mid[0].abs() < 1e-9 && (mid[3] - 0.5).abs() < 1e-3, "{mid:?}");
410        // The default S curve: a quarter of the way is 17.8 % of the change.
411        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        // Linear light: the middle of red and blue is 0.5 linear = 188/255.
423        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        // CSP, black to white at the middle: 13 with no correction, 55
430        // with correction 3; white to black mirrors it.
431        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}