Skip to main content

lunar_image/
decode.rs

1use crate::error::DecodeError;
2use crate::filter;
3use crate::format::{self, ChunkType, Header};
4use crate::simd;
5
6/// a decoded image in RGBA format.
7///
8/// contains the image dimensions and raw pixel data (RGBA8 order).
9/// use [`decode`] to load a .li file into this type.
10#[derive(Debug, Clone)]
11pub struct Image {
12	/// image width in pixels.
13	pub width: u32,
14	/// image height in pixels.
15	pub height: u32,
16	/// raw pixel data in RGBA order, `width * height * 4` bytes.
17	pub pixels: Vec<u8>,
18}
19
20impl Image {
21	/// create a new blank image filled with transparent black pixels.
22	#[must_use]
23	pub fn new(width: u32, height: u32) -> Self {
24		Self {
25			width,
26			height,
27			pixels: vec![0; (width as usize) * (height as usize) * 4],
28		}
29	}
30
31	/// get the number of pixels in the image.
32	#[must_use]
33	pub const fn len(&self) -> usize {
34		(self.width as usize) * (self.height as usize)
35	}
36
37	/// check if the image has zero width or height.
38	#[must_use]
39	pub const fn is_empty(&self) -> bool {
40		self.width == 0 || self.height == 0
41	}
42
43	/// get the total byte count of the pixel buffer.
44	#[must_use]
45	pub const fn byte_len(&self) -> usize {
46		self.pixels.len()
47	}
48
49	/// check if a pixel coordinate is within the image bounds.
50	#[must_use]
51	pub const fn contains(&self, x: u32, y: u32) -> bool {
52		x < self.width && y < self.height
53	}
54
55	/// get the RGBA value at a pixel coordinate.
56	///
57	/// # Panics
58	/// panics if the coordinate is out of bounds. use [`try_get_pixel`](Self::try_get_pixel) to
59	/// handle out-of-bounds without panicking.
60	#[must_use]
61	pub fn get_pixel(&self, x: u32, y: u32) -> [u8; 4] {
62		self.try_get_pixel(x, y)
63			.expect("pixel coordinate out of bounds")
64	}
65
66	/// get the RGBA value at a pixel coordinate, or `None` if out of bounds.
67	#[must_use]
68	pub fn try_get_pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
69		if !self.contains(x, y) {
70			return None;
71		}
72		let idx = ((y * self.width + x) as usize) * 4;
73		Some([
74			self.pixels[idx],
75			self.pixels[idx + 1],
76			self.pixels[idx + 2],
77			self.pixels[idx + 3],
78		])
79	}
80
81	/// set the RGBA value at a pixel coordinate.
82	///
83	/// # Panics
84	/// panics if the coordinate is out of bounds. use [`try_set_pixel`](Self::try_set_pixel) to
85	/// handle out-of-bounds without panicking.
86	pub fn set_pixel(&mut self, x: u32, y: u32, rgba: [u8; 4]) {
87		self.try_set_pixel(x, y, rgba)
88			.expect("pixel coordinate out of bounds");
89	}
90
91	/// set the RGBA value at a pixel coordinate. returns `None` if out of bounds.
92	pub fn try_set_pixel(&mut self, x: u32, y: u32, rgba: [u8; 4]) -> Option<()> {
93		if !self.contains(x, y) {
94			return None;
95		}
96		let idx = ((y * self.width + x) as usize) * 4;
97		self.pixels[idx] = rgba[0];
98		self.pixels[idx + 1] = rgba[1];
99		self.pixels[idx + 2] = rgba[2];
100		self.pixels[idx + 3] = rgba[3];
101		Some(())
102	}
103}
104
105/// decode .li format bytes to an RGBA image.
106///
107/// parses the file header and decompresses the pixel data.
108/// returns an error if the file is malformed or incomplete.
109///
110/// # Errors
111/// returns an error if the data is not a valid .li file, if pixel data is missing,
112/// or if decompression fails.
113pub fn decode(data: &[u8]) -> Result<Image, DecodeError> {
114	// Parse header
115	let header = Header::parse(data)?;
116	let expected_bytes = header.expected_pixel_bytes();
117
118	// Walk chunks starting after header
119	let mut offset = format::HEADER_SIZE;
120	let mut pixels: Option<Vec<u8>> = None;
121
122	while offset < data.len() {
123		let chunk_header = format::ChunkHeader::parse(&data[offset..])?;
124		offset += format::CHUNK_HEADER_SIZE;
125
126		let chunk_data_end = offset
127			.checked_add(chunk_header.compressed_size as usize)
128			.filter(|&end| end <= data.len())
129			.ok_or(DecodeError::TruncatedChunk)?;
130		let compressed = &data[offset..chunk_data_end];
131		offset = chunk_data_end;
132
133		match chunk_header.chunk_type {
134			ChunkType::PixelData => {
135				if pixels.is_some() {
136					return Err(DecodeError::MultiplePixelData);
137				}
138				let decompressed = zstd::decode_all(std::io::Cursor::new(compressed))
139					.map_err(DecodeError::ZstdError)?;
140
141				// undo the per-row delta filter first, if present, recovering the raw
142				// planar buffer. filtered data carries one extra byte per plane row.
143				let planar = if header.flags & format::FLAG_FILTERED != 0 {
144					let width = header.width as usize;
145					let height = header.height as usize;
146					let expected_filtered = expected_bytes + filter::overhead_bytes(height, 4);
147					if decompressed.len() != expected_filtered {
148						return Err(DecodeError::SizeMismatch {
149							expected: expected_filtered,
150							actual: decompressed.len(),
151						});
152					}
153					filter::unfilter_planes(&decompressed, width, height, 4).ok_or(
154						DecodeError::SizeMismatch {
155							expected: expected_bytes,
156							actual: decompressed.len(),
157						},
158					)?
159				} else {
160					if decompressed.len() != expected_bytes {
161						return Err(DecodeError::SizeMismatch {
162							expected: expected_bytes,
163							actual: decompressed.len(),
164						});
165					}
166					decompressed
167				};
168
169				// reinterleave if planar flag is set (all files encoded since v1.1)
170				let rgba = if header.flags & format::FLAG_PLANAR != 0 {
171					let n_pixels = header.width as usize * header.height as usize;
172					simd::reinterleave_rgba(&planar, n_pixels)
173				} else {
174					planar
175				};
176				pixels = Some(rgba);
177			}
178			ChunkType::Metadata | ChunkType::IccProfile => {
179				// not yet exposed through the public API; skip without decompressing
180			}
181		}
182	}
183
184	let pixels = pixels.ok_or(DecodeError::MissingPixelData)?;
185
186	Ok(Image {
187		width: header.width,
188		height: header.height,
189		pixels,
190	})
191}