Skip to main content

shuyi_assets/bitmap/
convert.rs

1//! Making a one-bit picture from a colour one, ahead of time (game ADR-0085).
2//! A tool runs it (a build script, a command); never the device.
3//!
4//! The picture is first **enlarged** to the size it is shown at: by
5//! doubling pixels when the scale is a whole number, by a smooth
6//! (bilinear) resample when it isn't, so rows and columns aren't doubled
7//! unevenly. Then each [`Method`] makes one style of it, at the enlarged
8//! size, so each dot is one screen pixel:
9//!
10//! 1. each pixel's brightness, optionally through a **curve**;
11//! 2. the **stretch**: the darkest share of the pixels becomes black, the
12//!    lightest share white, the rest spread between;
13//! 3. optionally each grey **rounded** to one of a few levels, so an area
14//!    is one even pattern;
15//! 4. a 4 × 4 **Bayer** ordered dither;
16//! 5. optionally the art's own **dark lines** drawn solid black on top.
17//!
18//! A pixel less than half opaque is see-through: clear in the mask, white
19//! in every style.
20use crate::prelude::*;
21
22use super::{Bitmap, encode};
23
24/// The 4 × 4 Bayer matrix: a pixel of brightness `b` (0 to 1) is white
25/// where `16 b` is over its rank plus a half.
26const BAYER: [[u8; 4]; 4] = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]];
27
28/// A colour picture: rows of RGBA pixels, 4 bytes each, no padding.
29#[derive(Copy, Clone, Debug)]
30pub struct Rgba<'a> {
31    width: u32,
32    height: u32,
33    pixels: &'a [u8],
34}
35
36impl<'a> Rgba<'a> {
37    /// The picture, or `None` if `pixels` isn't exactly `width × height`
38    /// pixels or a side is 0.
39    pub fn new(width: u32, height: u32, pixels: &'a [u8]) -> Option<Self> {
40        let size = u64::from(width) * u64::from(height) * 4;
41        (width > 0 && height > 0 && pixels.len() as u64 == size).then_some(Self {
42            width,
43            height,
44            pixels,
45        })
46    }
47}
48
49/// How one style of a picture is made (the steps at the top of the
50/// module).
51#[derive(Copy, Clone, Debug, PartialEq)]
52pub struct Method {
53    /// Each brightness `b` (0 to 1) becomes `b ^ curve` first: 1 leaves it,
54    /// below 1 lightens the middle greys, above 1 darkens them.
55    pub curve: f32,
56    /// The share of pixels stretched to black at the dark end, and to
57    /// white at the light end (0.03 for 3%); 0 leaves the greys as they
58    /// are.
59    pub stretch: f32,
60    /// Each grey rounded to one of this many evenly spaced levels before
61    /// the dither (black and white among them); 0 or 1 rounds nothing.
62    pub levels: u8,
63    /// The art's own dark lines drawn black on top, if any.
64    pub lines: Option<Lines>,
65}
66
67impl Method {
68    /// The plain method: the brightness stretched by 3% at each end, then
69    /// the Bayer dither (the Playdate's style A).
70    pub const BAYER: Self = Self {
71        curve: 1.0,
72        stretch: 0.03,
73        levels: 0,
74        lines: None,
75    };
76}
77
78/// Which pixels of the art are its dark lines: an opaque art pixel (before
79/// enlarging) that is `contrast` darker than the brightest of its eight
80/// neighbours, or at most `dark` bright. A see-through neighbour counts
81/// as white, so a figure's dark outline is a line; past the picture's
82/// edge there are no neighbours.
83/// Brightnesses are 0 to 1, read before the curve.
84#[derive(Copy, Clone, Debug, PartialEq)]
85pub struct Lines {
86    /// How much darker than its brightest neighbour a line pixel is.
87    pub contrast: f32,
88    /// The brightness at or below which a pixel is a line anyway.
89    pub dark: f32,
90}
91
92/// The one-bit picture file of `image` enlarged `num / den` times (each
93/// side rounded to the nearest pixel), one style per method in order, or
94/// what is wrong.
95pub fn convert(
96    image: &Rgba<'_>,
97    (num, den): (u32, u32),
98    methods: &[Method],
99) -> Result<Vec<u8>, String> {
100    if num == 0 || den == 0 {
101        return Err(format!("the scale {num} / {den} isn't a size"));
102    }
103    if methods.is_empty() {
104        return Err("no method to convert with".to_string());
105    }
106    let enlarge = |v: u32| {
107        let scaled = (u64::from(v) * u64::from(num) + u64::from(den / 2)) / u64::from(den);
108        u16::try_from(scaled.max(1)).ok()
109    };
110    let too_big = || {
111        format!(
112            "{}×{} px is too big for a one-bit picture",
113            image.width, image.height
114        )
115    };
116    let source = (
117        u16::try_from(image.width).map_err(|_| too_big())?,
118        u16::try_from(image.height).map_err(|_| too_big())?,
119    );
120    let size = (
121        enlarge(image.width).ok_or_else(too_big)?,
122        enlarge(image.height).ok_or_else(too_big)?,
123    );
124    let art = Art::of(image);
125    let grey = art.enlarged(size, num % den == 0);
126    let styles: Vec<Vec<u8>> = methods
127        .iter()
128        .map(|method| style(&art, &grey, method))
129        .collect();
130    let mask = grey
131        .opaque
132        .iter()
133        .any(|&o| !o)
134        .then(|| grey.bits(|i| grey.opaque[i]));
135    let refs: Vec<&[u8]> = styles.iter().map(Vec::as_slice).collect();
136    encode(size, source, &refs, mask.as_deref()).map_err(ToString::to_string)
137}
138
139/// The art at its own size: each pixel's brightness and opacity, 0 to 1.
140struct Art {
141    width: usize,
142    height: usize,
143    light: Vec<f32>,
144    alpha: Vec<f32>,
145}
146
147impl Art {
148    fn of(image: &Rgba<'_>) -> Self {
149        let (light, alpha) = image
150            .pixels
151            .as_chunks::<4>()
152            .0
153            .iter()
154            .map(|p| {
155                let [r, g, b, a] = p.map(f32::from);
156                ((0.299 * r + 0.587 * g + 0.114 * b) / 255.0, a / 255.0)
157            })
158            .unzip();
159        Self {
160            width: image.width as usize,
161            height: image.height as usize,
162            light,
163            alpha,
164        }
165    }
166
167    fn opaque(&self, i: usize) -> bool {
168        self.alpha[i] >= 0.5
169    }
170
171    /// The art pixel nearest the centre of enlarged pixel `t` of a side
172    /// `to` long, the art's side being `from`.
173    fn nearest(t: usize, from: usize, to: usize) -> usize {
174        (2 * t + 1) * from / (2 * to)
175    }
176
177    /// The art enlarged to `size`: by doubling pixels if `whole`, else by
178    /// a bilinear resample weighted by opacity, so a see-through
179    /// neighbour doesn't darken an edge.
180    fn enlarged(&self, (width, height): (u16, u16), whole: bool) -> Grey {
181        let (width, height) = (usize::from(width), usize::from(height));
182        let mut light = Vec::with_capacity(width * height);
183        let mut opaque = Vec::with_capacity(width * height);
184        for y in 0..height {
185            for x in 0..width {
186                let (l, a) = if whole {
187                    let i = Self::nearest(y, self.height, height) * self.width
188                        + Self::nearest(x, self.width, width);
189                    (self.light[i], self.alpha[i])
190                } else {
191                    self.bilinear(x, y, width, height)
192                };
193                light.push(l);
194                opaque.push(a >= 0.5);
195            }
196        }
197        Grey {
198            width,
199            height,
200            light,
201            opaque,
202        }
203    }
204
205    /// Enlarged pixel `(x, y)` of a `width` × `height` picture, sampled
206    /// between the four art pixels around its centre: its brightness
207    /// and opacity. Where all four are see-through its brightness is 0
208    /// (it is never drawn).
209    #[allow(
210        clippy::cast_precision_loss,
211        clippy::cast_possible_truncation,
212        clippy::cast_sign_loss
213    )] // small pictures
214    fn bilinear(&self, x: usize, y: usize, width: usize, height: usize) -> (f32, f32) {
215        let axis = |t: usize, to: usize, from: usize| {
216            // The centre of the last pixel is half a pixel inside the art,
217            // so `low` is always one of its pixels.
218            let f = ((t as f32 + 0.5) * from as f32 / to as f32 - 0.5).max(0.0);
219            let low = f.floor() as usize;
220            (low, (low + 1).min(from - 1), f - f.floor())
221        };
222        let (x0, x1, wx) = axis(x, width, self.width);
223        let (y0, y1, wy) = axis(y, height, self.height);
224        let (mut lit, mut alpha) = (0.0, 0.0);
225        for (i, w) in [
226            (y0 * self.width + x0, (1.0 - wx) * (1.0 - wy)),
227            (y0 * self.width + x1, wx * (1.0 - wy)),
228            (y1 * self.width + x0, (1.0 - wx) * wy),
229            (y1 * self.width + x1, wx * wy),
230        ] {
231            lit += self.light[i] * self.alpha[i] * w;
232            alpha += self.alpha[i] * w;
233        }
234        (lit / alpha.max(f32::MIN_POSITIVE), alpha)
235    }
236
237    /// Which art pixels are dark lines (see [`Lines`]).
238    fn lines(&self, lines: Lines) -> Vec<bool> {
239        let at = |x: usize, y: usize| y * self.width + x;
240        let mut out = vec![false; self.light.len()];
241        for y in 0..self.height {
242            for x in 0..self.width {
243                let i = at(x, y);
244                if !self.opaque(i) {
245                    continue;
246                }
247                let mut brightest: f32 = 0.0;
248                for (dx, dy) in [
249                    (-1, -1),
250                    (0, -1),
251                    (1, -1),
252                    (-1, 0),
253                    (1, 0),
254                    (-1, 1),
255                    (0, 1),
256                    (1, 1),
257                ] {
258                    let Some(n) = x
259                        .checked_add_signed(dx)
260                        .zip(y.checked_add_signed(dy))
261                        .filter(|&(nx, ny)| nx < self.width && ny < self.height)
262                        .map(|(nx, ny)| at(nx, ny))
263                    else {
264                        continue;
265                    };
266                    let light = if self.opaque(n) { self.light[n] } else { 1.0 };
267                    brightest = brightest.max(light);
268                }
269                let light = self.light[i];
270                out[i] = light <= lines.dark || brightest - light >= lines.contrast;
271            }
272        }
273        out
274    }
275}
276
277/// The art enlarged: brightness 0 to 1 and whether it is drawn, by pixel.
278struct Grey {
279    width: usize,
280    height: usize,
281    light: Vec<f32>,
282    opaque: Vec<bool>,
283}
284
285impl Grey {
286    /// The rows of a plane whose pixel `i` (row by row) is set if `set(i)`.
287    fn bits(&self, set: impl Fn(usize) -> bool) -> Vec<u8> {
288        #[allow(clippy::cast_possible_truncation)] // a picture's width fits a u16
289        let stride = Bitmap::stride(self.width as u16);
290        let mut rows = vec![0u8; stride * self.height];
291        for y in 0..self.height {
292            for x in 0..self.width {
293                if set(y * self.width + x) {
294                    rows[y * stride + x / 8] |= 0x80 >> (x % 8);
295                }
296            }
297        }
298        rows
299    }
300}
301
302/// One style of `grey` (the enlarged `art`) made by `method`.
303fn style(art: &Art, grey: &Grey, method: &Method) -> Vec<u8> {
304    let mut light: Vec<f32> = grey.light.clone();
305    for l in &mut light {
306        *l = l.powf(method.curve);
307    }
308    stretch(&mut light, &grey.opaque, method.stretch);
309    if method.levels >= 2 {
310        let steps = f32::from(method.levels - 1);
311        for l in &mut light {
312            *l = (*l * steps).round() / steps;
313        }
314    }
315    let lines = method.lines.map(|lines| art.lines(lines));
316    grey.bits(|i| {
317        let (x, y) = (i % grey.width, i / grey.width);
318        if !grey.opaque[i] {
319            return true;
320        }
321        if let Some(lines) = &lines {
322            let at = Art::nearest(y, art.height, grey.height) * art.width
323                + Art::nearest(x, art.width, grey.width);
324            if lines[at] {
325                return false;
326            }
327        }
328        light[i] * 16.0 > f32::from(BAYER[y % 4][x % 4]) + 0.5
329    })
330}
331
332/// Stretches the brightness of the opaque pixels so the darkest `share`
333/// of them is black and the lightest `share` white, clamping the rest; a
334/// picture with no spread is left as it is.
335#[allow(
336    clippy::cast_precision_loss,
337    clippy::cast_possible_truncation,
338    clippy::cast_sign_loss
339)] // small pictures
340fn stretch(light: &mut [f32], opaque: &[bool], share: f32) {
341    let mut sorted: Vec<f32> = light
342        .iter()
343        .zip(opaque)
344        .filter_map(|(&l, &o)| o.then_some(l))
345        .collect();
346    if sorted.is_empty() || share <= 0.0 {
347        return;
348    }
349    sorted.sort_by(f32::total_cmp);
350    let last = (sorted.len() - 1) as f32;
351    let rank = |p: f32| sorted[(last * p.clamp(0.0, 1.0)).round() as usize];
352    let (low, high) = (rank(share), rank(1.0 - share));
353    if high - low <= f32::EPSILON {
354        return;
355    }
356    for l in light {
357        *l = ((*l - low) / (high - low)).clamp(0.0, 1.0);
358    }
359}
360
361/// PNG file `bytes` as a colour picture: its width, height and RGBA
362/// pixels ([`Rgba::new`]), whatever kind of PNG it is (palette, grey,
363/// with or without alpha, 16 bits a channel).
364#[cfg(feature = "png")]
365pub fn png_pixels(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
366    let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
367    let mut wanted = png::Transformations::EXPAND;
368    wanted.insert(png::Transformations::STRIP_16);
369    decoder.set_transformations(wanted);
370    let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
371    let size = reader
372        .output_buffer_size()
373        .ok_or("the picture is too large to decode")?;
374    let mut buf = vec![0; size];
375    let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
376    buf.truncate(info.buffer_size());
377    let rgba = match info.color_type {
378        png::ColorType::Rgba => buf,
379        png::ColorType::Rgb => buf
380            .as_chunks::<3>()
381            .0
382            .iter()
383            .flat_map(|&[r, g, b]| [r, g, b, 255])
384            .collect(),
385        png::ColorType::GrayscaleAlpha => buf
386            .as_chunks::<2>()
387            .0
388            .iter()
389            .flat_map(|&[v, a]| [v, v, v, a])
390            .collect(),
391        png::ColorType::Grayscale => buf.iter().flat_map(|&v| [v, v, v, 255]).collect(),
392        png::ColorType::Indexed => return Err("a palette the decoder didn't expand".to_string()),
393    };
394    Ok((info.width, info.height, rgba))
395}
396
397#[cfg(test)]
398#[cfg(feature = "png")]
399mod png_tests {
400    use super::*;
401
402    /// A 2 × 1 PNG of `kind` from the bytes of its two pixels.
403    fn png_file(
404        kind: png::ColorType,
405        depth: png::BitDepth,
406        pixels: &[u8],
407        palette: Option<&[u8]>,
408    ) -> Vec<u8> {
409        let mut out = Vec::new();
410        {
411            let mut encoder = png::Encoder::new(&mut out, 2, 1);
412            encoder.set_color(kind);
413            encoder.set_depth(depth);
414            if let Some(palette) = palette {
415                encoder.set_palette(palette.to_vec());
416            }
417            let mut writer = encoder.write_header().unwrap();
418            writer.write_image_data(pixels).unwrap();
419        }
420        out
421    }
422
423    #[test]
424    fn every_kind_of_png_reads_as_rgba() {
425        use png::{BitDepth::Eight, BitDepth::Sixteen, ColorType};
426        let rgba = [10, 20, 30, 40, 50, 60, 70, 80];
427        let cases = [
428            (png_file(ColorType::Rgba, Eight, &rgba, None), rgba),
429            (
430                png_file(ColorType::Rgb, Eight, &[10, 20, 30, 50, 60, 70], None),
431                [10, 20, 30, 255, 50, 60, 70, 255],
432            ),
433            (
434                png_file(ColorType::GrayscaleAlpha, Eight, &[9, 99, 200, 7], None),
435                [9, 9, 9, 99, 200, 200, 200, 7],
436            ),
437            (
438                png_file(ColorType::Grayscale, Eight, &[9, 200], None),
439                [9, 9, 9, 255, 200, 200, 200, 255],
440            ),
441            (
442                png_file(ColorType::Grayscale, Sixteen, &[9, 1, 200, 2], None),
443                [9, 9, 9, 255, 200, 200, 200, 255],
444            ),
445            (
446                png_file(
447                    ColorType::Indexed,
448                    Eight,
449                    &[1, 0],
450                    Some(&[1, 2, 3, 4, 5, 6]),
451                ),
452                [4, 5, 6, 255, 1, 2, 3, 255],
453            ),
454        ];
455        for (file, expected) in cases {
456            assert_eq!(png_pixels(&file), Ok((2, 1, expected.to_vec())));
457        }
458        assert!(png_pixels(b"not a png").is_err());
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    /// No stretch, no curve, no lines: the dither alone.
467    const PLAIN: Method = Method {
468        curve: 1.0,
469        stretch: 0.0,
470        levels: 0,
471        lines: None,
472    };
473
474    /// An opaque grey pixel of brightness `v` (0 to 255).
475    fn grey(v: u8) -> [u8; 4] {
476        [v, v, v, 255]
477    }
478
479    const CLEAR: [u8; 4] = [0, 0, 0, 0];
480
481    /// The file of the `width`-wide picture of `pixels` enlarged `scale`.
482    fn file(width: u32, pixels: &[[u8; 4]], scale: (u32, u32), methods: &[Method]) -> Vec<u8> {
483        let bytes: Vec<u8> = pixels.iter().flatten().copied().collect();
484        let height = u32::try_from(pixels.len()).unwrap() / width;
485        let image = Rgba::new(width, height, &bytes).unwrap();
486        convert(&image, scale, methods).unwrap()
487    }
488
489    /// Style `n`'s rows as strings, `#` black, `.` white, ` ` see-through.
490    fn rows(file: &[u8], n: usize) -> Vec<String> {
491        let bitmap = Bitmap::parse(file).unwrap();
492        (0..bitmap.height())
493            .map(|y| {
494                (0..bitmap.width())
495                    .map(|x| match bitmap.pixel(n, x, y) {
496                        Some(true) => '.',
497                        Some(false) => '#',
498                        None => ' ',
499                    })
500                    .collect()
501            })
502            .collect()
503    }
504
505    #[test]
506    fn half_grey_is_the_bayer_checker() {
507        // 128 / 255 is just over a half: 8 of every 16 pixels white.
508        let f = file(2, &[grey(128); 4], (2, 1), &[PLAIN]);
509        assert_eq!(rows(&f, 0), [".#.#", "#.#.", ".#.#", "#.#."]);
510        let bitmap = Bitmap::parse(&f).unwrap();
511        assert_eq!((bitmap.width(), bitmap.height()), (4, 4));
512        assert_eq!(bitmap.source_size(), (2, 2));
513        assert_eq!(bitmap.mask(), None);
514    }
515
516    #[test]
517    fn each_grey_has_its_share_of_white() {
518        // Brightness k / 16 lights the pixels of rank below k.
519        let f = file(1, &[grey(64)], (4, 1), &[PLAIN]);
520        assert_eq!(rows(&f, 0), [".#.#", "####", ".#.#", "####"]);
521        let f = file(1, &[grey(0)], (4, 1), &[PLAIN]);
522        assert_eq!(rows(&f, 0), ["####"; 4]);
523        let f = file(1, &[grey(255)], (4, 1), &[PLAIN]);
524        assert_eq!(rows(&f, 0), ["...."; 4]);
525    }
526
527    #[test]
528    fn a_whole_scale_doubles_pixels() {
529        let f = file(2, &[grey(0), grey(255)], (2, 1), &[PLAIN]);
530        assert_eq!(rows(&f, 0), ["##..", "##.."]);
531        let f = file(2, &[grey(0), grey(255)], (6, 2), &[PLAIN]);
532        assert_eq!(rows(&f, 0), ["###...", "###...", "###..."]);
533    }
534
535    #[test]
536    fn a_scale_between_whole_numbers_resamples_smoothly() {
537        // 1.25 times: the third of five columns falls half on black, half
538        // on white, so it is a half grey, not a doubled column.
539        let f = file(
540            4,
541            &[grey(0), grey(0), grey(255), grey(255)],
542            (5, 4),
543            &[PLAIN],
544        );
545        assert_eq!(rows(&f, 0), ["##..."]);
546        let f = file(
547            4,
548            &[grey(0), grey(0), grey(255), grey(255)].repeat(4),
549            (5, 4),
550            &[PLAIN],
551        );
552        assert_eq!(rows(&f, 0), ["##...", "###..", "##...", "###..", "##..."]);
553        assert_eq!(Bitmap::parse(&f).unwrap().source_size(), (4, 4));
554    }
555
556    #[test]
557    fn see_through_pixels_are_masked_and_dont_darken_their_neighbours() {
558        let f = file(2, &[grey(255), CLEAR], (2, 1), &[PLAIN]);
559        assert_eq!(rows(&f, 0), ["..  ", "..  "]);
560        let bitmap = Bitmap::parse(&f).unwrap();
561        assert_eq!(bitmap.mask(), Some(&[0b1100_0000, 0b1100_0000][..]));
562        assert_eq!(bitmap.style(0), Some(&[0xf0, 0xf0][..]));
563        // Resampled, the white pixel next to the see-through one stays
564        // white rather than greying towards its black.
565        let f = file(4, &[grey(255), grey(255), CLEAR, CLEAR], (5, 4), &[PLAIN]);
566        assert_eq!(rows(&f, 0), ["...  "]);
567    }
568
569    #[test]
570    fn the_stretch_makes_the_darkest_black_and_the_lightest_white() {
571        let pixels = [grey(100), grey(120), grey(140), grey(160)];
572        let stretched = Method {
573            stretch: 0.03,
574            ..PLAIN
575        };
576        let f = file(4, &pixels, (1, 1), &[PLAIN, stretched]);
577        // Unstretched, all four are middle greys: about half on.
578        assert_eq!(rows(&f, 0), [".#.#"]);
579        // Stretched: 100 → black, 120 → 1/3, 140 → 2/3, 160 → white.
580        assert_eq!(rows(&f, 1), ["##.."]);
581        assert_eq!(Method::BAYER, stretched);
582        // A flat picture has nothing to stretch.
583        let f = file(1, &[grey(128)], (4, 1), &[stretched]);
584        assert_eq!(rows(&f, 0), [".#.#", "#.#.", ".#.#", "#.#."]);
585    }
586
587    #[test]
588    fn the_curve_lightens_and_the_levels_round() {
589        let curved = Method {
590            curve: 0.5,
591            ..PLAIN
592        };
593        // 64 / 255 is about a quarter; its square root about a half.
594        let f = file(1, &[grey(64)], (4, 1), &[curved]);
595        assert_eq!(rows(&f, 0), [".#.#", "#.#.", ".#.#", "#.#."]);
596        let rounded = Method { levels: 3, ..PLAIN };
597        // 0.6 rounds to the middle of three levels: a half.
598        let f = file(1, &[grey(153)], (4, 1), &[rounded]);
599        assert_eq!(rows(&f, 0), [".#.#", "#.#.", ".#.#", "#.#."]);
600        let f = file(1, &[grey(200)], (4, 1), &[rounded]);
601        assert_eq!(rows(&f, 0), ["...."; 4]);
602    }
603
604    #[test]
605    fn dark_lines_are_drawn_on_top() {
606        let lined = Method {
607            lines: Some(Lines {
608                contrast: 0.3,
609                dark: 0.15,
610            }),
611            ..PLAIN
612        };
613        // A grey dot on white is a line; the white around it isn't.
614        let mut pixels = [grey(255); 9];
615        pixels[4] = grey(150);
616        let f = file(3, &pixels, (2, 1), &[PLAIN, lined]);
617        // Dithered alone, the dot is half dots.
618        assert_eq!(rows(&f, 0)[2], "...#..");
619        let dot = ["......", "......", "..##..", "..##..", "......", "......"];
620        assert_eq!(rows(&f, 1), dot);
621        // A middle grey among middle greys isn't, nor one at the
622        // picture's edge; a dark one always is.
623        let mut pixels = [grey(128); 9];
624        pixels[0] = grey(30);
625        let f = file(3, &pixels, (1, 1), &[lined]);
626        assert_eq!(rows(&f, 0), ["##.", "#.#", ".#."]);
627        // Next to a see-through pixel (white ground), a middle grey is the
628        // outline.
629        let f = file(2, &[grey(128), CLEAR], (1, 1), &[lined]);
630        assert_eq!(rows(&f, 0), ["# "]);
631    }
632
633    #[test]
634    fn every_method_is_a_style_sharing_one_mask() {
635        let methods = [PLAIN, Method::BAYER, Method { levels: 5, ..PLAIN }];
636        let f = file(2, &[grey(10), CLEAR, grey(240), grey(90)], (1, 1), &methods);
637        let bitmap = Bitmap::parse(&f).unwrap();
638        assert_eq!(bitmap.styles(), 3);
639        assert_eq!(bitmap.mask(), Some(&[0b1000_0000, 0b1100_0000][..]));
640    }
641
642    #[test]
643    fn bad_input_is_refused() {
644        let px = grey(0);
645        assert!(Rgba::new(1, 1, &px).is_some());
646        assert!(Rgba::new(2, 1, &px).is_none());
647        assert!(Rgba::new(0, 0, &[]).is_none());
648        let image = Rgba::new(1, 1, &px).unwrap();
649        assert!(convert(&image, (0, 1), &[PLAIN]).is_err());
650        assert!(convert(&image, (1, 0), &[PLAIN]).is_err());
651        assert!(convert(&image, (1, 1), &[]).is_err());
652        let wide = vec![0u8; 70_000 * 4];
653        let image = Rgba::new(70_000, 1, &wide).unwrap();
654        assert!(convert(&image, (1, 1), &[PLAIN]).is_err());
655        let image = Rgba::new(1, 1, &px).unwrap();
656        assert!(convert(&image, (70_000, 1), &[PLAIN]).is_err());
657        // A side never rounds to nothing.
658        let f = convert(&image, (1, 4), &[PLAIN]).unwrap();
659        assert_eq!(Bitmap::parse(&f).unwrap().width(), 1);
660    }
661
662    /// Art of opaque greys, each a brightness 0 to 1 already.
663    fn art(width: usize, light: &[f32]) -> Art {
664        Art {
665            width,
666            height: light.len() / width,
667            light: light.to_vec(),
668            alpha: vec![1.0; light.len()],
669        }
670    }
671
672    fn close(a: f32, b: f32) -> bool {
673        (a - b).abs() < 1e-5
674    }
675
676    #[test]
677    fn the_resample_weighs_the_four_pixels_around_each_centre() {
678        // 2 × 2 art enlarged to 5 × 5: column 1's centre falls a tenth of
679        // the way from art column 0 to 1, row 3's nine tenths.
680        let a = art(2, &[0.0, 1.0, 0.5, 0.25]);
681        let (light, alpha) = a.bilinear(1, 3, 5, 5);
682        let expected = 0.9 * 0.1 * 0.0 + 0.1 * 0.1 * 1.0 + 0.9 * 0.9 * 0.5 + 0.1 * 0.9 * 0.25;
683        assert!(close(light, expected), "{light}");
684        assert!(close(alpha, 1.0));
685        let (light, _) = a.bilinear(3, 1, 5, 5);
686        let expected = 0.1 * 0.9 * 0.0 + 0.9 * 0.9 * 1.0 + 0.1 * 0.1 * 0.5 + 0.9 * 0.1 * 0.25;
687        assert!(close(light, expected), "{light}");
688        // The edges hold the art's edge pixels.
689        assert!(close(a.bilinear(0, 0, 5, 5).0, 0.0));
690        assert!(close(a.bilinear(4, 4, 5, 5).0, 0.25));
691        // A half see-through neighbour: half the opacity, the opaque
692        // pixel's brightness.
693        let mut half = art(2, &[0.6, 0.0]);
694        half.alpha = vec![1.0, 0.0];
695        let (light, alpha) = half.bilinear(1, 0, 4, 1);
696        assert!(close(alpha, 0.75), "{alpha}");
697        assert!(close(light, 0.6), "{light}");
698        let (light, alpha) = half.bilinear(2, 0, 4, 1);
699        assert!(close(alpha, 0.25), "{alpha}");
700        assert!(close(light, 0.6), "{light}");
701    }
702
703    #[test]
704    fn a_pixel_less_than_half_opaque_is_see_through() {
705        let f = file(
706            2,
707            &[[255, 255, 255, 100], [255, 255, 255, 160]],
708            (1, 1),
709            &[PLAIN],
710        );
711        assert_eq!(rows(&f, 0), [" ."]);
712    }
713
714    #[test]
715    fn each_of_the_eight_neighbours_can_make_a_line() {
716        let lined = Method {
717            lines: Some(Lines {
718                contrast: 0.3,
719                dark: 0.15,
720            }),
721            ..PLAIN
722        };
723        // A middle grey centre (white undithered at its place) is a line
724        // when any one neighbour is white.
725        for n in [0, 1, 2, 3, 5, 6, 7, 8] {
726            let mut pixels = [grey(128); 9];
727            pixels[n] = grey(255);
728            let f = file(3, &pixels, (1, 1), &[PLAIN, lined]);
729            assert_eq!(rows(&f, 0)[1].chars().nth(1), Some('.'), "neighbour {n}");
730            assert_eq!(rows(&f, 1)[1].chars().nth(1), Some('#'), "neighbour {n}");
731        }
732    }
733
734    #[test]
735    fn lines_land_on_the_nearest_art_pixel_when_resampled() {
736        let lined = Method {
737            lines: Some(Lines {
738                contrast: 0.3,
739                dark: 0.15,
740            }),
741            ..PLAIN
742        };
743        // Four white columns but the third, a grey line, enlarged to five:
744        // the line covers the screen columns whose centres are in art
745        // column 2, the third and fourth of five.
746        let pixels = [grey(255), grey(255), grey(150), grey(255)];
747        let f = file(4, &pixels, (5, 4), &[lined]);
748        assert_eq!(rows(&f, 0), ["..##."]);
749        assert_eq!(Art::nearest(2, 4, 5), 2);
750        assert_eq!(Art::nearest(4, 4, 5), 3);
751        assert_eq!(Art::nearest(5, 3, 6), 2);
752    }
753
754    #[test]
755    fn rows_wider_than_a_byte_are_packed_in_order() {
756        // 5 × 2 art doubled: rows of 10 pixels, 2 bytes each.
757        let (b, w) = (grey(0), grey(255));
758        let f = file(5, &[w, b, w, b, b, b, b, b, b, w], (2, 1), &[PLAIN]);
759        assert_eq!(
760            rows(&f, 0),
761            ["..##..####", "..##..####", "########..", "########.."]
762        );
763        let bitmap = Bitmap::parse(&f).unwrap();
764        assert_eq!(
765            bitmap.style(0),
766            Some(
767                &[
768                    0b1100_1100,
769                    0b0000_0000,
770                    0b1100_1100,
771                    0,
772                    0,
773                    0b1100_0000,
774                    0,
775                    0b1100_0000
776                ][..]
777            )
778        );
779    }
780
781    #[test]
782    fn the_stretch_takes_the_share_from_each_end() {
783        let mut light: Vec<f32> = (0..100u8).map(|i| f32::from(i) / 100.0).collect();
784        let opaque = vec![true; 100];
785        stretch(&mut light, &opaque, 0.03);
786        // The 4th darkest (0.03) is black, the 4th lightest (0.96) white.
787        assert!(close(light[3], 0.0) && close(light[2], 0.0));
788        assert!(close(light[96], 1.0) && close(light[99], 1.0));
789        assert!(close(light[95], 0.92 / 0.93), "{}", light[95]);
790        assert!(close(light[50], 0.47 / 0.93), "{}", light[50]);
791        // See-through pixels don't count.
792        let mut light = vec![0.0, 0.4, 0.6, 1.0];
793        stretch(&mut light, &[false, true, true, false], 0.03);
794        assert!(close(light[1], 0.0) && close(light[2], 1.0));
795    }
796
797    #[test]
798    fn a_grey_exactly_on_a_rank_is_black_there() {
799        // 33 levels: 135 / 255 rounds to 17 / 32, and 16 × 17 / 32 is 8.5,
800        // the second pixel's rank (8) plus a half.
801        let rounded = Method {
802            levels: 33,
803            ..PLAIN
804        };
805        let f = file(1, &[grey(135)], (4, 1), &[rounded]);
806        assert_eq!(rows(&f, 0), [".#.#", "#.#.", ".#.#", "#.#."]);
807    }
808}