Skip to main content

csp_format/
tone.rs

1//! The tone layer effect (トーン, section `EffectTone` of
2//! `Layer.LayerEffectInfo`): the layer drawn as a black-and-white
3//! halftone screen.
4//!
5//! The 76-byte section is big endian:
6//!
7//! ```text
8//! +0  u32  on (1) / off (0)
9//! +4  f64  resolution the line count refers to (the canvas's in the corpus)
10//! +12 u32  dot shape: 1 round, 2 square, 4 lines (others not decoded)
11//! +16 u32  density: 0 from the image, 2 fixed (+20)
12//! +20 u32  fixed density in percent
13//! +24 f64  lines per inch
14//! +32 u32  1 (meaning unknown)
15//! +40 u32  angle in degrees
16//! +44 u32  30, 10, 100 (no visible effect at 100 instead of 30)
17//! +52 i32  ×2: 0 in most layers; other values change the dots (not decoded)
18//! ```
19//!
20//! Measured against CSP's exports of two files made for it
21//! (`docs/validation/V6-made-files.md`; 98–99.9 % of the pixels agree
22//! for the decoded settings):
23//!
24//! - The screen is a lattice anchored at the canvas origin. Its cell
25//!   vectors are the period (resolution / lines) along the angle (y down),
26//!   each coordinate rounded to whole pixels: 30 px at 45° gives (21, 21),
27//!   at 30° (26, 15). A dot is centred in every cell; the cell corners are
28//!   where the holes of dark areas are.
29//! - Each pixel is compared, at its top-left corner, with the threshold
30//!   of its place in the cell: the share of the cell that is covered
31//!   before that place. Round dots grow as circles and turn into round
32//!   holes past half (the PostScript "round" spot function); square dots
33//!   grow as squares; lines grow across the second cell vector.
34//! - The result is two-valued. With density from the image, a pixel is
35//!   drawn where its alpha is above the threshold: black where its
36//!   darkness × alpha is too, white otherwise. The darkness is
37//!   1 − (0.114 R + 0.299 G + 0.587 B): the BT.601 weights, but on the
38//!   channels in another order (flat red, green, blue and yellow measured).
39//!   With a fixed density, the dots are circles (no holes past half),
40//!   black, and drawn wherever the layer has pixels; the rest is
41//!   transparent.
42
43use crate::binary::{be_f64_at, be_u32_at};
44use crate::image::RgbaImage;
45
46/// Settings of a switched-on tone effect.
47#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
48pub struct Tone {
49    /// Lines per inch.
50    pub lines: f64,
51    /// The resolution `lines` refers to (dpi).
52    pub resolution: f64,
53    /// Screen angle in degrees.
54    pub angle: f64,
55    /// Dot shape: 1 round, 2 square, 4 lines.
56    pub shape: u32,
57    /// Where the density comes from: 0 the image, 2 `density`.
58    pub mode: u32,
59    /// The fixed density, 0..1.
60    pub density: f64,
61    /// Words 13 and 14, not decoded (0 in most layers).
62    pub unknown: (u32, u32),
63}
64
65/// Dot shapes whose drawing was measured.
66const ROUND: u32 = 1;
67const SQUARE: u32 = 2;
68const LINES: u32 = 4;
69/// Density modes.
70const FROM_IMAGE: u32 = 0;
71const FIXED: u32 = 2;
72
73impl Tone {
74    /// The settings when the section switches the tone on.
75    pub fn parse(section: &[u8]) -> Option<Tone> {
76        if section.len() < 60 || be_u32_at(section, 0) == 0 {
77            return None;
78        }
79        let tone = Tone {
80            resolution: be_f64_at(section, 4),
81            shape: be_u32_at(section, 12),
82            mode: be_u32_at(section, 16),
83            density: be_u32_at(section, 20) as f64 / 100.0,
84            lines: be_f64_at(section, 24),
85            angle: be_u32_at(section, 40) as f64,
86            unknown: (be_u32_at(section, 52), be_u32_at(section, 56)),
87        };
88        (tone.lines > 0.0 && tone.resolution > 0.0 && tone.resolution / tone.lines >= 1.0).then_some(tone)
89    }
90
91    /// Whether the drawing of these settings is not known exactly: other
92    /// shapes are drawn as round dots, other modes take the density from
93    /// the image, and lines matched only at 0° (96 %).
94    pub fn is_approximate(&self) -> bool {
95        !matches!(self.shape, ROUND | SQUARE) || !matches!(self.mode, FROM_IMAGE | FIXED) || self.unknown != (0, 0)
96    }
97
98    /// The cell vectors in pixels.
99    fn lattice(&self) -> [(f64, f64); 2] {
100        let period = self.resolution / self.lines;
101        let (sin, cos) = self.angle.to_radians().sin_cos();
102        let a = ((period * cos).round(), (period * sin).round());
103        // A cell needs two independent vectors of at least a pixel.
104        let a = if a.0 == 0.0 && a.1 == 0.0 { (1.0, 0.0) } else { a };
105        [a, (-a.1, a.0)]
106    }
107
108    /// Draw `image` (top-left at canvas `origin`, full size) as the tone.
109    pub fn draw(&self, image: &RgbaImage, origin: (i64, i64)) -> RgbaImage {
110        let [a, b] = self.lattice();
111        let det = a.0 * b.1 - a.1 * b.0;
112        // Canvas point → cell coordinates (s, r).
113        let inverse = [b.1 / det, -b.0 / det, -a.1 / det, a.0 / det];
114        let fixed = self.mode == FIXED;
115        let shape = match self.shape {
116            _ if fixed => Shape::Circle,
117            SQUARE => Shape::Square,
118            LINES => Shape::Lines,
119            _ => Shape::Round,
120        };
121        let table = Thresholds::new(shape);
122        let mut out = RgbaImage::new(image.width, image.height);
123        for y in 0..image.height {
124            let cy = (origin.1 + y as i64) as f64;
125            for x in 0..image.width {
126                let p = image.pixel(x, y);
127                if p[3] == 0 {
128                    continue;
129                }
130                let cx = (origin.0 + x as i64) as f64;
131                let s = inverse[0] * cx + inverse[1] * cy;
132                let r = inverse[2] * cx + inverse[3] * cy;
133                let t = table.at(s - s.floor(), r - r.floor());
134                let pixel = if fixed {
135                    (self.density > t).then_some([0, 0, 0, 255])
136                } else if p[3] as f64 / 255.0 > t {
137                    let alpha = p[3] as f64 / 255.0;
138                    let light = (0.114 * p[0] as f64 + 0.299 * p[1] as f64 + 0.587 * p[2] as f64) / 255.0;
139                    Some(if (1.0 - light) * alpha > t { [0, 0, 0, 255] } else { [255, 255, 255, 255] })
140                } else {
141                    None
142                };
143                if let Some(pixel) = pixel {
144                    out.set_pixel(x, y, pixel);
145                }
146            }
147        }
148        out
149    }
150}
151
152#[derive(Clone, Copy)]
153enum Shape {
154    Round,
155    Square,
156    Lines,
157    Circle,
158}
159
160/// Thresholds over a cell sampled on a fine grid: the share of the cell
161/// whose spot value is above the value at each place.
162struct Thresholds {
163    values: Vec<f32>,
164}
165
166const GRID: usize = 256;
167
168impl Thresholds {
169    fn new(shape: Shape) -> Self {
170        let spot = |s: f64, r: f64| -> f64 {
171            // u, v in -1..1 with the dot's centre at 0.
172            let (u, v) = ((2.0 * s - 1.0).abs(), (2.0 * r - 1.0).abs());
173            match shape {
174                Shape::Round if u + v <= 1.0 => 1.0 - (u * u + v * v) / 2.0,
175                Shape::Round => ((u - 1.0).powi(2) + (v - 1.0).powi(2)) / 2.0,
176                Shape::Square => -u.max(v),
177                Shape::Lines => -v,
178                Shape::Circle => -(u * u + v * v),
179            }
180        };
181        let centre = |i: usize| (i as f64 + 0.5) / GRID as f64;
182        let spots: Vec<f64> = (0..GRID * GRID).map(|i| spot(centre(i % GRID), centre(i / GRID))).collect();
183        let mut order: Vec<usize> = (0..spots.len()).collect();
184        order.sort_by(|&i, &j| spots[j].total_cmp(&spots[i]));
185        let mut values = vec![0f32; spots.len()];
186        let count = spots.len() as f32;
187        // Places with equal spot values share the threshold of the first.
188        let mut start = 0;
189        while start < order.len() {
190            let mut end = start + 1;
191            while end < order.len() && spots[order[end]] == spots[order[start]] {
192                end += 1;
193            }
194            for &i in &order[start..end] {
195                values[i] = start as f32 / count;
196            }
197            start = end;
198        }
199        Thresholds { values }
200    }
201
202    /// The threshold at cell coordinates (s, r) in 0..1.
203    fn at(&self, s: f64, r: f64) -> f64 {
204        let i = ((s * GRID as f64) as usize).min(GRID - 1);
205        let j = ((r * GRID as f64) as usize).min(GRID - 1);
206        self.values[j * GRID + i] as f64
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn section(lines: f64, angle: u32, shape: u32, mode: u32, density: u32) -> Vec<u8> {
215        let mut out = Vec::new();
216        out.extend_from_slice(&1u32.to_be_bytes());
217        out.extend_from_slice(&600f64.to_be_bytes());
218        for word in [shape, mode, density] {
219            out.extend_from_slice(&word.to_be_bytes());
220        }
221        out.extend_from_slice(&lines.to_be_bytes());
222        for word in [1, 0, angle, 30, 0, 0, 0, 0, 0, 0, 0] {
223            out.extend_from_slice(&u32::to_be_bytes(word));
224        }
225        out
226    }
227
228    fn coverage(image: &RgbaImage) -> f64 {
229        let black = image.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] == 0 && p[3] == 255).count();
230        black as f64 / (image.width * image.height) as f64
231    }
232
233    #[test]
234    fn grey_becomes_dots_of_its_darkness() {
235        let tone = Tone::parse(&section(20.0, 45, 1, 0, 20)).unwrap();
236        assert_eq!(tone.lattice(), [(21.0, 21.0), (-21.0, 21.0)]);
237        let mut image = RgbaImage::new(84, 84);
238        image.fill([191, 191, 191, 255]);
239        let drawn = tone.draw(&image, (0, 0));
240        assert!((coverage(&drawn) - 0.25).abs() < 0.02, "{}", coverage(&drawn));
241        // A dot is centred in the cell (0, 21) and the corner (0, 0) is white.
242        assert_eq!(drawn.pixel(0, 21), [0, 0, 0, 255]);
243        assert_eq!(drawn.pixel(0, 0), [255, 255, 255, 255]);
244    }
245
246    #[test]
247    fn darkness_is_weighted_by_alpha_and_the_rest_is_white_or_transparent() {
248        let tone = Tone::parse(&section(60.0, 45, 1, 0, 20)).unwrap();
249        let mut image = RgbaImage::new(70, 70);
250        // Grey 128 at alpha 0.6: drawn on 60 %, black on about 30 %.
251        image.fill([128, 128, 128, 153]);
252        let drawn = tone.draw(&image, (0, 0));
253        let white = drawn.pixels.as_chunks::<4>().0.iter().filter(|p| **p == [255, 255, 255, 255]).count();
254        assert!((coverage(&drawn) - 0.3).abs() < 0.03, "{}", coverage(&drawn));
255        assert!((white as f64 / 4900.0 - 0.3).abs() < 0.03);
256        // Pure red is dark: its weight is 0.114.
257        image.fill([255, 0, 0, 255]);
258        assert!((coverage(&tone.draw(&image, (0, 0))) - 0.886).abs() < 0.03);
259    }
260
261    #[test]
262    fn fixed_density_ignores_the_colour_and_leaves_the_rest_transparent() {
263        let tone = Tone::parse(&section(20.0, 0, 1, 2, 30)).unwrap();
264        let mut image = RgbaImage::new(60, 60);
265        image.fill([255, 255, 255, 40]);
266        let drawn = tone.draw(&image, (0, 0));
267        assert!((coverage(&drawn) - 0.3).abs() < 0.02);
268        assert_eq!(drawn.pixel(0, 0)[3], 0);
269        assert_eq!(drawn.pixel(15, 15), [0, 0, 0, 255]);
270    }
271
272    #[test]
273    fn switched_off_tone_is_none() {
274        let mut data = section(20.0, 45, 1, 0, 20);
275        data[3] = 0;
276        assert!(Tone::parse(&data).is_none());
277    }
278}