Skip to main content

n3_lib/
images.rs

1//! Uncompressed BMP/TGA storage, preservation and RGBA conversion.
2
3use crate::binary::{Error, Reader, Result, Writer};
4use crate::records::{hex, unhex};
5use serde::{Deserialize, Serialize};
6
7/// Complete image storage including metadata, palette, padding and footer.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct ImageRecord {
10    /// Original pre-pixel bytes as hex.
11    pub header: String,
12    /// Original encoded pixels including row padding as hex.
13    pub data: String,
14    /// Original post-pixel suffix as hex.
15    pub trailing: String,
16}
17/// Decoded top-row-first RGBA pixels.
18#[derive(Debug, Clone, PartialEq)]
19pub struct DecodedImage {
20    /// Pixel width.
21    pub width: u32,
22    /// Pixel height.
23    pub height: u32,
24    /// Four bytes per pixel in R/G/B/A order.
25    pub rgba: Vec<u8>,
26}
27struct Layout {
28    width: usize,
29    height: usize,
30    bits: usize,
31    offset: usize,
32    stride: usize,
33    top: bool,
34    right: bool,
35    palette: usize,
36}
37fn error(message: &str) -> Error {
38    Error {
39        offset: 0,
40        message: message.into(),
41    }
42}
43/// Parse BI_RGB BMP bytes without dropping original metadata or pixel padding.
44pub fn parse_bmp(bytes: &[u8]) -> Result<ImageRecord> {
45    parse_image("bmp", bytes)
46}
47/// Write and validate an edited BMP document.
48pub fn write_bmp(image: &ImageRecord) -> Result<Vec<u8>> {
49    write_image("bmp", image)
50}
51/// Decode BMP pixels into top-row-first RGBA.
52pub fn decode_bmp(image: &ImageRecord) -> Result<DecodedImage> {
53    decode_image("bmp", image)
54}
55/// Construct a top-down 32-bit BMP from RGBA pixels.
56pub fn create_bmp(width: u32, height: u32, rgba: &[u8]) -> Result<ImageRecord> {
57    create_image("bmp", width, height, rgba)
58}
59/// Parse uncompressed truecolour TGA bytes without dropping identifier or footer.
60pub fn parse_tga(bytes: &[u8]) -> Result<ImageRecord> {
61    parse_image("tga", bytes)
62}
63/// Write and validate an edited TGA document.
64pub fn write_tga(image: &ImageRecord) -> Result<Vec<u8>> {
65    write_image("tga", image)
66}
67/// Decode TGA pixels into top-row-first RGBA.
68pub fn decode_tga(image: &ImageRecord) -> Result<DecodedImage> {
69    decode_image("tga", image)
70}
71/// Construct a top-left 32-bit TGA from RGBA pixels.
72pub fn create_tga(width: u32, height: u32, rgba: &[u8]) -> Result<ImageRecord> {
73    create_image("tga", width, height, rgba)
74}
75
76fn layout(format: &str, bytes: &[u8]) -> Result<Layout> {
77    let mut reader = Reader::new(bytes);
78    reader.raw(if format == "bmp" { 54 } else { 18 })?;
79    let word = |at| u16::from_le_bytes([bytes[at], bytes[at + 1]]);
80    let dword = |at| u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]);
81    let (width, height, bits, offset, stride, top, right, palette) = if format == "bmp" {
82        let dib = dword(14) as usize;
83        let offset = dword(10) as usize;
84        let width = dword(18) as i32;
85        let signed_height = dword(22) as i32;
86        let height = signed_height.unsigned_abs() as usize;
87        let bits = word(28) as usize;
88        if word(0) != 0x4d42
89            || dib < 40
90            || offset < 14 + dib
91            || word(26) != 1
92            || dword(30) != 0
93            || !matches!(bits, 8 | 24 | 32)
94            || width <= 0
95        {
96            return Err(error("Only BI_RGB 8/24/32-bit BMP is supported"));
97        }
98        let palette = 14 + dib;
99        if bits == 8 {
100            let colors = if dword(46) == 0 {
101                256
102            } else {
103                dword(46) as usize
104            };
105            if colors > 256 || palette + colors * 4 > offset {
106                return Err(error("Invalid BMP palette"));
107            }
108        }
109        let width = width as usize;
110        (
111            width,
112            height,
113            bits,
114            offset,
115            (width * bits).div_ceil(32) * 4,
116            signed_height < 0,
117            false,
118            palette,
119        )
120    } else if format == "tga" {
121        let (width, height, bits) = (word(12) as usize, word(14) as usize, bytes[16] as usize);
122        if bytes[1] != 0 || bytes[2] != 2 || !matches!(bits, 24 | 32) || bytes[17] & 0xc0 != 0 {
123            return Err(error("Only uncompressed true-color TGA is supported"));
124        }
125        (
126            width,
127            height,
128            bits,
129            18 + bytes[0] as usize,
130            width * (bits / 8),
131            bytes[17] & 32 != 0,
132            bytes[17] & 16 != 0,
133            0,
134        )
135    } else {
136        return Err(error("Unsupported image format"));
137    };
138    if width == 0
139        || height == 0
140        || width > 16384
141        || height > 16384
142        || width * height > 16_777_216
143        || offset > bytes.len()
144        || stride * height > bytes.len() - offset
145    {
146        return Err(error("Truncated image or pixel budget exceeded"));
147    }
148    Ok(Layout {
149        width,
150        height,
151        bits,
152        offset,
153        stride,
154        top,
155        right,
156        palette,
157    })
158}
159/// Parse an uncompressed image without dropping original storage metadata.
160pub(crate) fn parse_image(format: &str, bytes: &[u8]) -> Result<ImageRecord> {
161    let info = layout(format, bytes)?;
162    let end = info.offset + info.stride * info.height;
163    Ok(ImageRecord {
164        header: hex(&bytes[..info.offset]),
165        data: hex(&bytes[info.offset..end]),
166        trailing: hex(&bytes[end..]),
167    })
168}
169/// Write and validate new or edited image storage.
170pub(crate) fn write_image(format: &str, image: &ImageRecord) -> Result<Vec<u8>> {
171    let mut writer = Writer::default();
172    writer.raw(&unhex(&image.header)?)?;
173    writer.raw(&unhex(&image.data)?)?;
174    writer.raw(&unhex(&image.trailing)?)?;
175    let bytes = writer.finish();
176    let parsed = parse_image(format, &bytes)?;
177    if parsed.header != image.header.to_lowercase() || parsed.data != image.data.to_lowercase() {
178        return Err(error("Image header and payload size mismatch"));
179    }
180    Ok(bytes)
181}
182/// Decode orientation, palettes and original pixels into top-row-first RGBA.
183pub(crate) fn decode_image(format: &str, image: &ImageRecord) -> Result<DecodedImage> {
184    let bytes = write_image(format, image)?;
185    let info = layout(format, &bytes)?;
186    let mut rgba = vec![0; info.width * info.height * 4];
187    for y in 0..info.height {
188        for x in 0..info.width {
189            let row = if info.top { y } else { info.height - 1 - y };
190            let column = if info.right { info.width - 1 - x } else { x };
191            let mut at = info.offset + row * info.stride + column * (info.bits / 8);
192            if info.bits == 8 {
193                at = info.palette + bytes[at] as usize * 4;
194                if at + 4 > info.offset {
195                    return Err(error("BMP palette index out of range"));
196                }
197            }
198            rgba[(y * info.width + x) * 4..(y * info.width + x) * 4 + 4].copy_from_slice(&[
199                bytes[at + 2],
200                bytes[at + 1],
201                bytes[at],
202                if info.bits == 32 { bytes[at + 3] } else { 255 },
203            ]);
204        }
205    }
206    Ok(DecodedImage {
207        width: info.width as u32,
208        height: info.height as u32,
209        rgba,
210    })
211}
212/// Construct top-down BMP or top-left TGA storage from 32-bit RGBA pixels.
213pub(crate) fn create_image(
214    format: &str,
215    width: u32,
216    height: u32,
217    rgba: &[u8],
218) -> Result<ImageRecord> {
219    if width == 0
220        || height == 0
221        || width > 16384
222        || height > 16384
223        || width as usize * height as usize > 16_777_216
224        || rgba.len() != width as usize * height as usize * 4
225    {
226        return Err(error("RGBA dimensions mismatch"));
227    }
228    let mut header = if format == "bmp" {
229        vec![0; 54]
230    } else if format == "tga" {
231        vec![0; 18]
232    } else {
233        return Err(error("Unsupported image format"));
234    };
235    if format == "bmp" {
236        header[0..2].copy_from_slice(b"BM");
237        header[2..6].copy_from_slice(&(54 + rgba.len() as u32).to_le_bytes());
238        header[10..14].copy_from_slice(&54_u32.to_le_bytes());
239        header[14..18].copy_from_slice(&40_u32.to_le_bytes());
240        header[18..22].copy_from_slice(&width.to_le_bytes());
241        header[22..26].copy_from_slice(&(-(height as i32)).to_le_bytes());
242        header[26..28].copy_from_slice(&1_u16.to_le_bytes());
243        header[28..30].copy_from_slice(&32_u16.to_le_bytes());
244        header[34..38].copy_from_slice(&(rgba.len() as u32).to_le_bytes());
245    } else {
246        header[2] = 2;
247        header[12..14].copy_from_slice(&(width as u16).to_le_bytes());
248        header[14..16].copy_from_slice(&(height as u16).to_le_bytes());
249        header[16] = 32;
250        header[17] = 40;
251    }
252    let mut pixels = rgba.to_vec();
253    for pixel in pixels.as_chunks_mut::<4>().0 {
254        pixel.swap(0, 2);
255    }
256    Ok(ImageRecord {
257        header: hex(&header),
258        data: hex(&pixels),
259        trailing: String::new(),
260    })
261}
262#[cfg(test)]
263mod tests {
264    use super::*;
265    #[test]
266    fn pixel_roundtrip() -> Result<()> {
267        for format in ["bmp", "tga"] {
268            let pixels = [255, 0, 0, 255, 0, 0, 255, 128];
269            let image = create_image(format, 2, 1, &pixels)?;
270            assert_eq!(decode_image(format, &image)?.rgba, pixels);
271            let bytes = write_image(format, &image)?;
272            assert_eq!(write_image(format, &parse_image(format, &bytes)?)?, bytes);
273        }
274        Ok(())
275    }
276}