Skip to main content

rasterrocket_encode/
ppm.rs

1//! Netpbm P6 (binary PPM) encoder.
2//!
3//! PPM stores RGB 8-bit pixels.  Pixel modes that are not natively RGB are
4//! converted on the fly:
5//!
6//! | Mode | Conversion |
7//! |------|-----------|
8//! | `Rgb8` | verbatim |
9//! | `Bgr8` / `Xbgr8` | channel swap |
10//! | `Cmyk8` | `R = 255−C−K`, `G = 255−M−K`, `B = 255−Y−K` (clamped) |
11//! | `DeviceN8` | CMYK portion only (same formula) |
12//! | `Gray8` / `Mono8` | not supported — use [`write_pgm`][crate::write_pgm] |
13//! | `Mono1` | not supported |
14//!
15//! The output is a standard `P6` header followed by raw RGB bytes.
16
17use std::io::{self, Write};
18
19use color::{Pixel, PixelMode, convert::cmyk_to_rgb};
20use raster::Bitmap;
21
22use crate::EncodeError;
23
24/// Write `bitmap` to `out` as a binary PPM (`P6`) image.
25///
26/// The sink `out` is consumed; wrap in `std::io::BufWriter` if buffering is
27/// needed.
28///
29/// # Errors
30///
31/// Returns [`EncodeError::UnsupportedMode`] for grayscale or 1-bit modes —
32/// use [`write_pgm`][crate::write_pgm] for those.
33/// Returns [`EncodeError::Io`] on any I/O failure.
34pub fn write_ppm<P: Pixel, W: Write>(bitmap: &Bitmap<P>, mut out: W) -> Result<(), EncodeError> {
35    match P::MODE {
36        PixelMode::Mono1 | PixelMode::Mono8 => {
37            return Err(EncodeError::UnsupportedMode(
38                "grayscale/mono bitmap: use write_pgm instead",
39            ));
40        }
41        PixelMode::Rgb8
42        | PixelMode::Bgr8
43        | PixelMode::Xbgr8
44        | PixelMode::Cmyk8
45        | PixelMode::DeviceN8 => {}
46    }
47
48    write_ppm_header(&mut out, bitmap.width, bitmap.height)?;
49    write_ppm_pixels::<P, W>(bitmap, &mut out)?;
50    out.flush()?;
51    Ok(())
52}
53
54/// Write the P6 header.
55fn write_ppm_header<W: Write>(out: &mut W, width: u32, height: u32) -> io::Result<()> {
56    writeln!(out, "P6")?;
57    writeln!(out, "{width} {height}")?;
58    writeln!(out, "255")?;
59    Ok(())
60}
61
62/// Write all pixel rows, converting to RGB in place.
63fn write_ppm_pixels<P: Pixel, W: Write>(bitmap: &Bitmap<P>, out: &mut W) -> io::Result<()> {
64    // Pre-allocate one row of output RGB bytes.
65    let w = bitmap.width as usize;
66    let mut rgb_row = vec![0u8; w * 3];
67
68    for y in 0..bitmap.height {
69        let src = bitmap.row_bytes(y);
70        convert_row_to_rgb::<P>(src, &mut rgb_row, w);
71        out.write_all(&rgb_row)?;
72    }
73    Ok(())
74}
75
76/// Convert one source row (any supported pixel mode) into RGB bytes.
77///
78/// `dst` must be at least `width * 3` bytes long.
79/// `src` must be at least `width * P::BYTES` bytes long.
80#[inline]
81fn convert_row_to_rgb<P: Pixel>(src: &[u8], dst: &mut [u8], width: usize) {
82    match P::MODE {
83        PixelMode::Rgb8 => {
84            // Copy exactly 3 bytes per pixel — stride padding excluded via width.
85            dst[..width * 3].copy_from_slice(&src[..width * 3]);
86        }
87        PixelMode::Bgr8 => {
88            // Source: [B, G, R] → dest: [R, G, B].
89            for (i, chunk) in src[..width * 3].chunks_exact(3).enumerate() {
90                dst[i * 3] = chunk[2]; // R ← src[2]
91                dst[i * 3 + 1] = chunk[1]; // G ← src[1]
92                dst[i * 3 + 2] = chunk[0]; // B ← src[0]
93            }
94        }
95        PixelMode::Xbgr8 => {
96            // Source: [X, B, G, R] (little-endian 32-bit word) → dest: [R, G, B].
97            for (i, chunk) in src[..width * 4].chunks_exact(4).enumerate() {
98                dst[i * 3] = chunk[3]; // R ← src[3]
99                dst[i * 3 + 1] = chunk[2]; // G ← src[2]
100                dst[i * 3 + 2] = chunk[1]; // B ← src[1]
101            }
102        }
103        PixelMode::Cmyk8 => {
104            // Source: [C, M, Y, K] → dest: [R, G, B].
105            for (i, chunk) in src[..width * 4].chunks_exact(4).enumerate() {
106                let (r, g, b) = cmyk_to_rgb(chunk[0], chunk[1], chunk[2], chunk[3]);
107                dst[i * 3] = r;
108                dst[i * 3 + 1] = g;
109                dst[i * 3 + 2] = b;
110            }
111        }
112        PixelMode::DeviceN8 => {
113            // Source: [C, M, Y, K, spot0..3] — use only CMYK (bytes 0..4).
114            for (i, chunk) in src[..width * 8].chunks_exact(8).enumerate() {
115                let (r, g, b) = cmyk_to_rgb(chunk[0], chunk[1], chunk[2], chunk[3]);
116                dst[i * 3] = r;
117                dst[i * 3 + 1] = g;
118                dst[i * 3 + 2] = b;
119            }
120        }
121        PixelMode::Mono1 | PixelMode::Mono8 => {
122            // `write_ppm` rejects mono modes up front, so this branch is
123            // unreachable. Use `unreachable!` (not `debug_assert!`) so the
124            // contract holds in release builds — otherwise a mono row would
125            // silently produce all-black output instead of panicking.
126            unreachable!("convert_row_to_rgb: mono modes are screened by write_ppm");
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use color::{Cmyk8, DeviceN8, Rgb8, Rgba8};
135    use raster::Bitmap;
136
137    fn make_rgb_bitmap(w: u32, h: u32, fill: [u8; 3]) -> Bitmap<Rgb8> {
138        let mut bmp = Bitmap::new(w, h, 1, false);
139        for y in 0..h {
140            let row = bmp.row_bytes_mut(y);
141            for chunk in row.chunks_exact_mut(3) {
142                chunk.copy_from_slice(&fill);
143            }
144        }
145        bmp
146    }
147
148    /// Parse the P6 header and return the byte offset of the first pixel.
149    fn header_len(out: &[u8]) -> usize {
150        // Header format: "P6\n{w} {h}\n255\n" — find the third newline.
151        let mut newlines = 0usize;
152        for (i, &b) in out.iter().enumerate() {
153            if b == b'\n' {
154                newlines += 1;
155                if newlines == 3 {
156                    return i + 1;
157                }
158            }
159        }
160        panic!("malformed PPM header");
161    }
162
163    #[test]
164    fn rgb_ppm_header_and_pixels() {
165        let bmp = make_rgb_bitmap(2, 1, [255, 128, 0]);
166        let mut out = Vec::new();
167        write_ppm::<Rgb8, _>(&bmp, &mut out).unwrap();
168
169        let expected_header = b"P6\n2 1\n255\n";
170        assert!(
171            out.starts_with(expected_header),
172            "header mismatch: {:?}",
173            &out[..expected_header.len().min(out.len())]
174        );
175
176        let pixels = &out[expected_header.len()..];
177        assert_eq!(pixels.len(), 6, "2 pixels × 3 bytes");
178        assert_eq!(&pixels[..3], &[255, 128, 0]);
179        assert_eq!(&pixels[3..6], &[255, 128, 0]);
180    }
181
182    #[test]
183    fn rgba8_xbgr_ppm_channel_swap() {
184        // Rgba8 has MODE=Xbgr8; memory layout is [X/A, B, G, R].
185        let mut bmp: Bitmap<Rgba8> = Bitmap::new(1, 1, 1, false);
186        // [A=255, B=10, G=20, R=30]
187        bmp.row_bytes_mut(0).copy_from_slice(&[255, 10, 20, 30]);
188        let mut out = Vec::new();
189        write_ppm::<Rgba8, _>(&bmp, &mut out).unwrap();
190        let hlen = header_len(&out);
191        assert_eq!(
192            &out[hlen..],
193            &[30, 20, 10],
194            "Xbgr8 must become RGB (channels swapped)"
195        );
196    }
197
198    #[test]
199    fn cmyk_black_converts_to_rgb_black() {
200        let mut bmp: Bitmap<Cmyk8> = Bitmap::new(1, 1, 1, false);
201        // CMYK (0, 0, 0, 255) = pure black.
202        bmp.row_bytes_mut(0).copy_from_slice(&[0, 0, 0, 255]);
203        let mut out = Vec::new();
204        write_ppm::<Cmyk8, _>(&bmp, &mut out).unwrap();
205        let hlen = header_len(&out);
206        assert_eq!(&out[hlen..], &[0, 0, 0], "CMYK black → RGB (0,0,0)");
207    }
208
209    #[test]
210    fn cmyk_white_converts_to_rgb_white() {
211        let mut bmp: Bitmap<Cmyk8> = Bitmap::new(1, 1, 1, false);
212        // CMYK (0, 0, 0, 0) = pure white.
213        bmp.row_bytes_mut(0).copy_from_slice(&[0, 0, 0, 0]);
214        let mut out = Vec::new();
215        write_ppm::<Cmyk8, _>(&bmp, &mut out).unwrap();
216        let hlen = header_len(&out);
217        assert_eq!(&out[hlen..], &[255, 255, 255], "CMYK white → RGB white");
218    }
219
220    #[test]
221    fn devicen_uses_only_cmyk_portion() {
222        let mut bmp: Bitmap<DeviceN8> = Bitmap::new(1, 1, 1, false);
223        // DeviceN8: CMYK=(0,0,0,0) → white; spot channels ignored.
224        bmp.row_bytes_mut(0)
225            .copy_from_slice(&[0, 0, 0, 0, 99, 99, 99, 99]);
226        let mut out = Vec::new();
227        write_ppm::<DeviceN8, _>(&bmp, &mut out).unwrap();
228        let hlen = header_len(&out);
229        assert_eq!(
230            &out[hlen..],
231            &[255, 255, 255],
232            "DeviceN spot channels must be ignored"
233        );
234    }
235
236    #[test]
237    fn stride_padding_not_written() {
238        // width=1, pad=4 → stride=4 for Rgb8 (3 bytes/px rounded up to 4).
239        let bmp: Bitmap<Rgb8> = Bitmap::new(1, 1, 4, false);
240        let mut out = Vec::new();
241        write_ppm::<Rgb8, _>(&bmp, &mut out).unwrap();
242        let hlen = header_len(&out);
243        // Must be exactly 3 pixel bytes, not 4 (stride).
244        assert_eq!(
245            out.len() - hlen,
246            3,
247            "stride padding must not appear in PPM output"
248        );
249    }
250
251    #[test]
252    fn mono8_returns_unsupported_error() {
253        use color::Gray8;
254        let bmp: Bitmap<Gray8> = Bitmap::new(1, 1, 1, false);
255        let mut out = Vec::new();
256        let result = write_ppm::<Gray8, _>(&bmp, &mut out);
257        assert!(
258            matches!(result, Err(EncodeError::UnsupportedMode(_))),
259            "Gray8 should return UnsupportedMode"
260        );
261    }
262
263    #[test]
264    fn cmyk_to_rgb_clamped() {
265        // cyan=200, black=100 → 255-200-100 = -45 → clamped to 0.
266        let (r, g, b) = cmyk_to_rgb(200, 0, 0, 100);
267        assert_eq!(r, 0, "negative result must clamp to 0");
268        assert_eq!(g, 155, "magenta=0, k=100: 255-0-100=155");
269        assert_eq!(b, 155, "yellow=0, k=100: 255-0-100=155");
270    }
271
272    #[test]
273    fn cmyk_to_rgb_asymmetric_channels() {
274        // Distinct non-zero values per ink so each channel's `255 - chan - k`
275        // formula is independently constrained.  K=0 isolates the C/M/Y math.
276        let (r, g, b) = cmyk_to_rgb(10, 50, 200, 0);
277        assert_eq!(r, 245, "cyan=10, k=0: 255-10=245");
278        assert_eq!(g, 205, "magenta=50, k=0: 255-50=205");
279        assert_eq!(b, 55, "yellow=200, k=0: 255-200=55");
280
281        // Non-zero K with all four channels distinct.
282        let (r, g, b) = cmyk_to_rgb(40, 80, 120, 30);
283        assert_eq!(r, 185, "255-40-30=185");
284        assert_eq!(g, 145, "255-80-30=145");
285        assert_eq!(b, 105, "255-120-30=105");
286    }
287
288    #[test]
289    fn cmyk_ppm_multi_pixel_layout() {
290        // 2×2 with four distinct CMYK pixels — covers per-pixel CMYK→RGB
291        // conversion across multiple rows and per-row destination stride.
292        let mut bmp: Bitmap<Cmyk8> = Bitmap::new(2, 2, 1, false);
293        // Row 0: [(10, 0, 0, 0), (0, 20, 0, 0)] → [(245,255,255), (255,235,255)]
294        bmp.row_bytes_mut(0)
295            .copy_from_slice(&[10, 0, 0, 0, 0, 20, 0, 0]);
296        // Row 1: [(0, 0, 30, 0), (40, 50, 60, 0)] → [(255,255,225), (215,205,195)]
297        bmp.row_bytes_mut(1)
298            .copy_from_slice(&[0, 0, 30, 0, 40, 50, 60, 0]);
299        let mut out = Vec::new();
300        write_ppm::<Cmyk8, _>(&bmp, &mut out).unwrap();
301        let hlen = header_len(&out);
302        let pixels = &out[hlen..];
303        assert_eq!(pixels.len(), 12, "2×2 × 3 RGB bytes");
304        assert_eq!(&pixels[0..3], &[245, 255, 255], "row 0 px 0");
305        assert_eq!(&pixels[3..6], &[255, 235, 255], "row 0 px 1");
306        assert_eq!(&pixels[6..9], &[255, 255, 225], "row 1 px 0");
307        assert_eq!(&pixels[9..12], &[215, 205, 195], "row 1 px 1");
308    }
309
310    #[test]
311    fn rgba8_xbgr_ppm_multi_pixel_channel_swap() {
312        // 2×1 fixture verifying the Xbgr8 → RGB channel swap applies
313        // independently to each pixel (the 1×1 test couldn't observe per-pixel
314        // advance through the source and destination buffers).
315        let mut bmp: Bitmap<Rgba8> = Bitmap::new(2, 1, 1, false);
316        // [A=255, B=10, G=20, R=30]  [A=240, B=11, G=22, R=33]
317        bmp.row_bytes_mut(0)
318            .copy_from_slice(&[255, 10, 20, 30, 240, 11, 22, 33]);
319        let mut out = Vec::new();
320        write_ppm::<Rgba8, _>(&bmp, &mut out).unwrap();
321        let hlen = header_len(&out);
322        assert_eq!(&out[hlen..], &[30, 20, 10, 33, 22, 11], "two pixels RGB");
323    }
324
325    #[test]
326    fn devicen_ignores_spot_channels_per_pixel() {
327        // 2×1 DeviceN8 (8 bytes/pixel = CMYK + 4 spot) verifying that the
328        // spot bytes are skipped per pixel and only the CMYK portion drives
329        // the RGB output.
330        let mut bmp: Bitmap<DeviceN8> = Bitmap::new(2, 1, 1, false);
331        // px0 CMYK=(10, 0, 0, 0) → (245, 255, 255); spot=99..
332        // px1 CMYK=(0, 40, 80, 0) → (255, 215, 175); spot=88..
333        bmp.row_bytes_mut(0)
334            .copy_from_slice(&[10, 0, 0, 0, 99, 99, 99, 99, 0, 40, 80, 0, 88, 88, 88, 88]);
335        let mut out = Vec::new();
336        write_ppm::<DeviceN8, _>(&bmp, &mut out).unwrap();
337        let hlen = header_len(&out);
338        assert_eq!(
339            &out[hlen..],
340            &[245, 255, 255, 255, 215, 175],
341            "spot bytes must be skipped per pixel; only CMYK drives the RGB output"
342        );
343    }
344}