kenwood_thd75/sdcard/
capture.rs

1//! Parser for BMP screen capture files.
2//!
3//! The TH-D75 saves screenshots as standard BMP bitmap files.
4//! Per User Manual Chapter 19 and Operating Tips ยง5.14:
5//!
6//! - Format: 240x180 pixels, 24-bit RGB (uncompressed).
7//! - Files are stored in `/KENWOOD/TH-D75/CAPTURE/*.bmp`.
8//! - Maximum 255 files per directory.
9//!
10//! # Location
11//!
12//! `/KENWOOD/TH-D75/CAPTURE/*.bmp`
13//!
14//! # Details
15//!
16//! This parser validates the BMP and DIB headers, verifies the
17//! dimensions and bit depth match the TH-D75 display, and extracts
18//! the raw BGR pixel data. BMP files store rows bottom-up by default.
19
20use super::{SdCardError, read_u16_le, read_u32_le};
21
22/// Expected screen width in pixels.
23const EXPECTED_WIDTH: u32 = 240;
24
25/// Expected screen height in pixels.
26const EXPECTED_HEIGHT: u32 = 180;
27
28/// Expected bits per pixel.
29const EXPECTED_BPP: u16 = 24;
30
31/// BMP file header size (14 bytes).
32const BMP_HEADER_SIZE: usize = 14;
33
34/// Minimum DIB (BITMAPINFOHEADER) size (40 bytes).
35const MIN_DIB_HEADER_SIZE: u32 = 40;
36
37/// Minimum BMP file size: file header + DIB header.
38const MIN_BMP_SIZE: usize = BMP_HEADER_SIZE + MIN_DIB_HEADER_SIZE as usize;
39
40/// BMP compression type for uncompressed (`BI_RGB`).
41const BI_RGB: u32 = 0;
42
43/// A parsed TH-D75 screen capture.
44///
45/// Contains the validated image metadata and raw BGR pixel data
46/// as stored in the BMP file (bottom-up row order).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ScreenCapture {
49    /// Image width in pixels. Expected: 240 for TH-D75.
50    pub width: u32,
51    /// Image height in pixels. Expected: 180 for TH-D75.
52    pub height: u32,
53    /// Bits per pixel. Expected: 24 for TH-D75.
54    pub bits_per_pixel: u16,
55    /// Raw BGR pixel data in bottom-up row order.
56    ///
57    /// Each pixel is 3 bytes: blue, green, red. Rows are stored
58    /// from the bottom of the image to the top, as is standard
59    /// for BMP files. Row padding (to 4-byte alignment) is stripped.
60    pub pixels: Vec<u8>,
61}
62
63/// Read a little-endian `i32` from a byte slice at the given offset.
64fn read_i32_le(data: &[u8], offset: usize) -> i32 {
65    i32::from_le_bytes([
66        data[offset],
67        data[offset + 1],
68        data[offset + 2],
69        data[offset + 3],
70    ])
71}
72
73/// Parse a BMP screen capture file from raw bytes.
74///
75/// Validates the BMP file header, DIB header, dimensions, and bit
76/// depth. Extracts the raw BGR pixel data with row padding removed.
77///
78/// # Errors
79///
80/// Returns [`SdCardError::FileTooSmall`] if the data is shorter than
81/// the minimum BMP header size (54 bytes).
82///
83/// Returns [`SdCardError::InvalidBmpHeader`] if the BM magic bytes,
84/// DIB header size, or compression type is invalid.
85///
86/// Returns [`SdCardError::UnexpectedImageFormat`] if the width,
87/// height, or bit depth does not match the expected TH-D75 screen
88/// dimensions (240x180, 24-bit).
89pub fn parse(data: &[u8]) -> Result<ScreenCapture, SdCardError> {
90    if data.len() < MIN_BMP_SIZE {
91        return Err(SdCardError::FileTooSmall {
92            expected: MIN_BMP_SIZE,
93            actual: data.len(),
94        });
95    }
96
97    // Validate BM magic bytes.
98    if &data[0..2] != b"BM" {
99        return Err(SdCardError::InvalidBmpHeader {
100            detail: "missing BM magic bytes".to_owned(),
101        });
102    }
103
104    // Pixel data offset from file header.
105    let pixel_offset = read_u32_le(data, 10) as usize;
106
107    // DIB header size (at offset 14).
108    let dib_size = read_u32_le(data, 14);
109    if dib_size < MIN_DIB_HEADER_SIZE {
110        return Err(SdCardError::InvalidBmpHeader {
111            detail: format!("DIB header size {dib_size} too small (minimum {MIN_DIB_HEADER_SIZE})"),
112        });
113    }
114
115    // Image dimensions. Height can be negative (top-down), but TH-D75
116    // uses standard bottom-up, so we read as signed and take absolute value.
117    let raw_width = read_i32_le(data, 18);
118    let raw_height = read_i32_le(data, 22);
119
120    let Ok(width) = u32::try_from(raw_width) else {
121        return Err(SdCardError::InvalidBmpHeader {
122            detail: format!("invalid width {raw_width}"),
123        });
124    };
125    if width == 0 {
126        return Err(SdCardError::InvalidBmpHeader {
127            detail: "width is zero".to_owned(),
128        });
129    }
130
131    let height = raw_height.unsigned_abs();
132
133    let bits_per_pixel = read_u16_le(data, 28);
134
135    // Compression (offset 30).
136    let compression = read_u32_le(data, 30);
137    if compression != BI_RGB {
138        return Err(SdCardError::InvalidBmpHeader {
139            detail: format!("unsupported compression type {compression} (expected 0 for BI_RGB)"),
140        });
141    }
142
143    // Validate TH-D75 expected format.
144    if width != EXPECTED_WIDTH || height != EXPECTED_HEIGHT || bits_per_pixel != EXPECTED_BPP {
145        return Err(SdCardError::UnexpectedImageFormat {
146            width,
147            height,
148            bits_per_pixel,
149        });
150    }
151
152    // Calculate row stride with padding to 4-byte boundary.
153    let bytes_per_row = u32::from(bits_per_pixel) / 8 * width;
154    let row_stride = (bytes_per_row + 3) & !3;
155
156    let pixel_data_size = row_stride as usize * height as usize;
157    let required_size = pixel_offset + pixel_data_size;
158
159    if data.len() < required_size {
160        return Err(SdCardError::FileTooSmall {
161            expected: required_size,
162            actual: data.len(),
163        });
164    }
165
166    // Extract pixel data, stripping row padding if present.
167    let pixels = if row_stride == bytes_per_row {
168        data[pixel_offset..pixel_offset + pixel_data_size].to_vec()
169    } else {
170        let mut pixels = Vec::with_capacity(bytes_per_row as usize * height as usize);
171        for row in 0..height as usize {
172            let row_start = pixel_offset + row * row_stride as usize;
173            let row_end = row_start + bytes_per_row as usize;
174            pixels.extend_from_slice(&data[row_start..row_end]);
175        }
176        pixels
177    };
178
179    Ok(ScreenCapture {
180        width,
181        height,
182        bits_per_pixel,
183        pixels,
184    })
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    /// Build a minimal valid BMP file with the given parameters.
192    fn build_bmp(width: u32, height: u32, bpp: u16) -> Vec<u8> {
193        let bytes_per_row = u32::from(bpp) / 8 * width;
194        let row_stride = (bytes_per_row + 3) & !3;
195        let pixel_data_size = row_stride * height;
196        let file_size = 54 + pixel_data_size;
197
198        let mut buf = Vec::with_capacity(file_size as usize);
199
200        // BMP file header (14 bytes)
201        buf.extend_from_slice(b"BM");
202        buf.extend_from_slice(&file_size.to_le_bytes());
203        buf.extend_from_slice(&0u16.to_le_bytes()); // reserved1
204        buf.extend_from_slice(&0u16.to_le_bytes()); // reserved2
205        buf.extend_from_slice(&54u32.to_le_bytes()); // pixel data offset
206
207        // DIB header (BITMAPINFOHEADER, 40 bytes)
208        buf.extend_from_slice(&40u32.to_le_bytes()); // header size
209        #[allow(clippy::cast_possible_wrap)]
210        buf.extend_from_slice(&(width as i32).to_le_bytes());
211        #[allow(clippy::cast_possible_wrap)]
212        buf.extend_from_slice(&(height as i32).to_le_bytes());
213        buf.extend_from_slice(&1u16.to_le_bytes()); // planes
214        buf.extend_from_slice(&bpp.to_le_bytes());
215        buf.extend_from_slice(&BI_RGB.to_le_bytes()); // compression
216        buf.extend_from_slice(&pixel_data_size.to_le_bytes()); // image size
217        buf.extend_from_slice(&2835u32.to_le_bytes()); // x pixels per meter
218        buf.extend_from_slice(&2835u32.to_le_bytes()); // y pixels per meter
219        buf.extend_from_slice(&0u32.to_le_bytes()); // colors used
220        buf.extend_from_slice(&0u32.to_le_bytes()); // important colors
221
222        // Pixel data (fill with a recognisable pattern).
223        for row in 0..height {
224            for col in 0..width {
225                #[allow(clippy::cast_possible_truncation)]
226                let b = ((row + col) % 256) as u8;
227                #[allow(clippy::cast_possible_truncation)]
228                let g = ((row * 2 + col) % 256) as u8;
229                #[allow(clippy::cast_possible_truncation)]
230                let r = ((row + col * 2) % 256) as u8;
231                buf.push(b);
232                buf.push(g);
233                buf.push(r);
234            }
235            // Padding bytes to reach row_stride.
236            let padding = row_stride - bytes_per_row;
237            buf.extend(std::iter::repeat_n(0u8, padding as usize));
238        }
239
240        buf
241    }
242
243    #[test]
244    fn parse_valid_d75_capture() {
245        let bmp = build_bmp(240, 180, 24);
246        let cap = parse(&bmp).unwrap();
247
248        assert_eq!(cap.width, 240);
249        assert_eq!(cap.height, 180);
250        assert_eq!(cap.bits_per_pixel, 24);
251        // 240 * 180 * 3 = 129600 bytes of pixel data (no padding needed: 240*3=720, divisible by 4)
252        assert_eq!(cap.pixels.len(), 240 * 180 * 3);
253    }
254
255    #[test]
256    fn pixel_data_correct() {
257        let bmp = build_bmp(240, 180, 24);
258        let cap = parse(&bmp).unwrap();
259
260        // Verify first pixel (row 0, col 0): b=0, g=0, r=0
261        assert_eq!(cap.pixels[0], 0); // blue
262        assert_eq!(cap.pixels[1], 0); // green
263        assert_eq!(cap.pixels[2], 0); // red
264
265        // Verify second pixel (row 0, col 1): b=1, g=1, r=2
266        assert_eq!(cap.pixels[3], 1);
267        assert_eq!(cap.pixels[4], 1);
268        assert_eq!(cap.pixels[5], 2);
269    }
270
271    #[test]
272    fn too_short_returns_error() {
273        let data = b"BM\x00\x00";
274        let err = parse(data).unwrap_err();
275        assert!(matches!(err, SdCardError::FileTooSmall { .. }));
276    }
277
278    #[test]
279    fn empty_returns_error() {
280        let err = parse(b"").unwrap_err();
281        assert!(matches!(err, SdCardError::FileTooSmall { .. }));
282    }
283
284    #[test]
285    fn wrong_magic_bytes() {
286        let mut bmp = build_bmp(240, 180, 24);
287        bmp[0..2].copy_from_slice(b"XX");
288        let err = parse(&bmp).unwrap_err();
289        assert!(matches!(err, SdCardError::InvalidBmpHeader { .. }));
290    }
291
292    #[test]
293    fn wrong_dimensions_rejected() {
294        let bmp = build_bmp(320, 240, 24);
295        let err = parse(&bmp).unwrap_err();
296        assert!(matches!(err, SdCardError::UnexpectedImageFormat { .. }));
297    }
298
299    #[test]
300    fn wrong_bit_depth_rejected() {
301        let bmp = build_bmp(240, 180, 32);
302        let err = parse(&bmp).unwrap_err();
303        assert!(matches!(err, SdCardError::UnexpectedImageFormat { .. }));
304    }
305
306    #[test]
307    fn compressed_bmp_rejected() {
308        let mut bmp = build_bmp(240, 180, 24);
309        // Set compression to 1 (BI_RLE8) at offset 30.
310        bmp[30..34].copy_from_slice(&1u32.to_le_bytes());
311        let err = parse(&bmp).unwrap_err();
312        assert!(matches!(err, SdCardError::InvalidBmpHeader { .. }));
313    }
314
315    #[test]
316    fn truncated_pixel_data_rejected() {
317        let mut bmp = build_bmp(240, 180, 24);
318        // Truncate to just the header.
319        bmp.truncate(60);
320        let err = parse(&bmp).unwrap_err();
321        assert!(matches!(err, SdCardError::FileTooSmall { .. }));
322    }
323}