diff --git a/ggdt/Cargo.toml b/ggdt/Cargo.toml index f3a4e6e..8152c3a 100644 --- a/ggdt/Cargo.toml +++ b/ggdt/Cargo.toml @@ -16,6 +16,8 @@ thiserror = "=1.0.30" rand = "0.8.5" num-traits = "0.2.14" bitflags = "1.3" +flate2 = "1.0.25" +crc32fast = "1.3.2" [target.'cfg(not(windows))'.dependencies] sdl2 = { git = "https://github.com/Rust-SDL2/rust-sdl2/", rev = "819ab438ac971a922d6ee1da558822002d343b4e", features = ["static-link", "bundled", "use-pkgconfig", "unsafe_textures"] } diff --git a/ggdt/src/graphics/bitmap/mod.rs b/ggdt/src/graphics/bitmap/mod.rs index c42e4b2..6b5205c 100644 --- a/ggdt/src/graphics/bitmap/mod.rs +++ b/ggdt/src/graphics/bitmap/mod.rs @@ -9,6 +9,7 @@ pub mod gif; pub mod iff; pub mod indexed; pub mod pcx; +pub mod png; pub mod primitives; pub mod rgb; diff --git a/ggdt/src/graphics/bitmap/png.rs b/ggdt/src/graphics/bitmap/png.rs new file mode 100644 index 0000000..cf8fde0 --- /dev/null +++ b/ggdt/src/graphics/bitmap/png.rs @@ -0,0 +1,401 @@ +use std::fs::File; +use std::hash::Hasher; +use std::io; +use std::io::{BufReader, BufWriter, Seek}; +use std::path::Path; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use thiserror::Error; + +use crate::graphics::bitmap::Bitmap; +use crate::graphics::bitmap::indexed::IndexedBitmap; +use crate::graphics::bitmap::rgb::RgbaBitmap; +use crate::graphics::palette::Palette; +use crate::graphics::Pixel; +use crate::prelude::{PaletteError, PaletteFormat, to_argb32, to_rgb32}; +use crate::utils::bytes::ReadFixedLengthByteArray; + +const PNG_HEADER: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +#[derive(Error, Debug)] +pub enum PngError { + #[error("Bad or unsupported PNG file: {0}")] + BadFile(String), + + #[error("PNG palette data error")] + BadPalette(#[from] PaletteError), + + #[error("Unsupported IHDR color format: {0}")] + UnsupportedColorType(u8), + + #[error("PNG I/O error")] + IOError(#[from] std::io::Error), +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum ColorFormat { + Grayscale = 0, + RGB = 2, + IndexedColor = 3, + GrayscaleAlpha = 4, + RGBA = 6, +} + +impl ColorFormat { + pub fn from(value: u8) -> Result { + use ColorFormat::*; + match value { + 0 => Ok(Grayscale), + 2 => Ok(RGB), + 3 => Ok(IndexedColor), + 4 => Ok(GrayscaleAlpha), + 6 => Ok(RGBA), + _ => Err(PngError::UnsupportedColorType(value)), + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +struct ChunkHeader { + size: u32, + name: [u8; 4], +} + +impl ChunkHeader { + pub fn read(reader: &mut T) -> Result { + Ok(ChunkHeader { + size: reader.read_u32::()?, + name: reader.read_bytes()?, + }) + } + + pub fn write(&self, writer: &mut T) -> Result<(), PngError> { + writer.write_u32::(self.size)?; + writer.write(&self.name)?; + Ok(()) + } +} + +#[derive(Debug, Copy, Clone)] +struct ImageHeaderChunk { + width: u32, + height: u32, + bpp: u8, + format: ColorFormat, + compression: u8, + filter: u8, + interlace: u8, +} + +impl ImageHeaderChunk { + pub fn read(reader: &mut T) -> Result { + Ok(ImageHeaderChunk { + width: reader.read_u32::()?, + height: reader.read_u32::()?, + bpp: reader.read_u8()?, + format: ColorFormat::from(reader.read_u8()?)?, + compression: reader.read_u8()?, + filter: reader.read_u8()?, + interlace: reader.read_u8()?, + }) + } + + pub fn write(&self, writer: &mut T) -> Result<(), PngError> { + writer.write_u32::(self.width)?; + writer.write_u32::(self.height)?; + writer.write_u8(self.bpp)?; + writer.write_u8(self.format as u8)?; + writer.write_u8(self.compression)?; + writer.write_u8(self.filter)?; + writer.write_u8(self.interlace)?; + Ok(()) + } +} + +fn read_chunk_data(reader: &mut T, chunk_header: &ChunkHeader) -> Result, PngError> { + let mut chunk_bytes = vec![0u8; chunk_header.size as usize]; + reader.read_exact(&mut chunk_bytes)?; + + let mut hasher = crc32fast::Hasher::new(); + hasher.write(&chunk_header.name); + hasher.write(&chunk_bytes); + let actual_checksum = hasher.finalize(); + let expected_checksum = reader.read_u32::()?; + if actual_checksum != expected_checksum { + return Err(PngError::BadFile(format!("Chunk checksum verification failed for chunk {:?}", chunk_header))); + } + Ok(chunk_bytes) +} + +fn find_chunk(reader: &mut T, chunk_name: [u8; 4]) -> Result { + loop { + let chunk_header = match ChunkHeader::read(reader) { + Ok(chunk_header) => chunk_header, + Err(err) => return Err(err), + }; + + if chunk_header.name == chunk_name { + return Ok(chunk_header); + } + } +} + +trait PixelReader { + fn next_pixel(&mut self, reader: &mut T) -> Result; +} + +struct PixelDecoder { + bitmap: Bitmap, + header: ImageHeaderChunk, + palette: Option, + x: u32, + y: u32, + filter: u8, + num_pixels_read: usize, +} + +impl PixelReader for PixelDecoder { + fn next_pixel(&mut self, reader: &mut T) -> Result { + match self.header.format { + ColorFormat::IndexedColor => { + Ok(reader.read_u8()?) + } + _ => return Err(PngError::BadFile(format!("Unsupported color format: {:?}", self.header.format))), + } + } +} + +impl PixelReader for PixelDecoder { + fn next_pixel(&mut self, reader: &mut T) -> Result { + match self.header.format { + ColorFormat::IndexedColor => { + let color = reader.read_u8()?; + if let Some(palette) = &self.palette { + Ok(palette[color]) + } else { + return Err(PngError::BadFile(String::from("No palette to map indexed-color format pixels to RGBA format destination"))); + } + } + ColorFormat::RGB => { + let r = reader.read_u8()?; + let g = reader.read_u8()?; + let b = reader.read_u8()?; + Ok(to_rgb32(r, g, b)) + } + ColorFormat::RGBA => { + let r = reader.read_u8()?; + let g = reader.read_u8()?; + let b = reader.read_u8()?; + let a = reader.read_u8()?; + Ok(to_argb32(a, r, g, b)) + } + _ => return Err(PngError::BadFile(format!("Unsupported color format: {:?}", self.header.format))), + } + } +} + +impl PixelDecoder +where + Self: PixelReader, + PixelType: Pixel +{ + pub fn new(header: ImageHeaderChunk, palette: Option) -> Self { + PixelDecoder { + bitmap: Bitmap::internal_new(header.width, header.height).unwrap(), + header, + palette, + x: 0, + y: 0, + filter: 0, + num_pixels_read: 0, + } + } + + pub fn decode(&mut self, data: &[u8]) -> Result<(), PngError> { + let mut decoder = flate2::read::ZlibDecoder::new(data); + + while self.y < self.bitmap.height { + while self.x < self.bitmap.width { + if self.x == 0 { + self.filter = decoder.read_u8()?; + } + + // TODO: handle filters + + let pixel = self.next_pixel(&mut decoder)?; + // TODO: we can make this a bit more efficient ... + unsafe { self.bitmap.set_pixel_unchecked(self.x as i32, self.y as i32, pixel); } + self.num_pixels_read += 1; + + self.x += 1; + } + self.x = 0; + self.y += 1; + } + + Ok(()) + } + + pub fn finalize(self) -> Result<(Bitmap, Option), PngError> { + if self.num_pixels_read != self.bitmap.pixels.len() { + return Err(PngError::BadFile(String::from("PNG file did not contain enough pixel data for the full image. Possibly corrupt or truncated?"))); + } else { + Ok((self.bitmap, self.palette)) + } + } +} + +fn load_png_bytes( + reader: &mut Reader +) -> Result<(Bitmap, Option), PngError> +where + Reader: ReadBytesExt + Seek, + PixelType: Pixel, + PixelDecoder: PixelReader +{ + let header: [u8; 8] = reader.read_bytes()?; + if header != PNG_HEADER { + return Err(PngError::BadFile(String::from("Unexpected 8-byte header, probably not a PNG file"))); + } + + // get the IHDR chunk first + + let chunk_header = match find_chunk(reader, *b"IHDR") { + Ok(header) => header, + Err(PngError::IOError(io_error)) if io_error.kind() == io::ErrorKind::UnexpectedEof => { + return Err(PngError::BadFile(String::from("No IHDR chunk found, probably not a PNG file"))); + } + Err(err) => return Err(err), + }; + let chunk_bytes = read_chunk_data(reader, &chunk_header)?; + let ihdr = ImageHeaderChunk::read(&mut chunk_bytes.as_slice())?; + + // file format validations based on the limited subset of PNGs we will be supporting + + if ihdr.bpp != 8 { + return Err(PngError::BadFile(String::from("Unsupported color bit depth."))); + } + if ihdr.format != ColorFormat::IndexedColor + && ihdr.format != ColorFormat::RGB + && ihdr.format != ColorFormat::RGBA { + return Err(PngError::BadFile(String::from("Unsupported pixel color format."))); + } + if ihdr.compression != 0 { + return Err(PngError::BadFile(String::from("Unsupported compression method."))); + } + if ihdr.filter != 0 { + return Err(PngError::BadFile(String::from("Unsupported filter method."))); + } + if ihdr.interlace != 0 { + return Err(PngError::BadFile(String::from("Interlaced images are not supported."))); + } + + // if this is an indexed-color PNG, we expect to find a PLTE chunk next (or at least before the IDAT chunks) + + let palette = if ihdr.format == ColorFormat::IndexedColor { + let chunk_header = match find_chunk(reader, *b"PLTE") { + Ok(header) => header, + Err(PngError::IOError(io_error)) if io_error.kind() == io::ErrorKind::UnexpectedEof => { + return Err(PngError::BadFile(String::from("No PLTE chunk found in an indexed-color PNG"))); + } + Err(err) => return Err(err), + }; + + let chunk_bytes = read_chunk_data(reader, &chunk_header)?; + let num_colors = (chunk_header.size / 3) as usize; + Some(Palette::load_num_colors_from_bytes( + &mut chunk_bytes.as_slice(), + PaletteFormat::Normal, + num_colors, + )?) + } else { + None + }; + + // now we're just looking for IDAT chunks. keep reading these chunks only, ignoring all others. + // TODO: some way to read and decompress this data on the fly, without needing to read it all in? + // it looks like chunk boundaries just arbitrarily cut off the deflate stream (that is, each + // chunk is not a separate deflate stream with just more data). so we'd need some deflate + // decompressor that can stream its input (compressed) byte stream too ... + + let mut pixel_decoder = PixelDecoder::new(ihdr, palette); + let mut buffer = Vec::new(); + loop { + let chunk_header = match find_chunk(reader, *b"IDAT") { + Ok(header) => header, + Err(PngError::IOError(io_error)) if io_error.kind() == io::ErrorKind::UnexpectedEof => break, + Err(err) => return Err(err), + }; + + buffer.append(&mut read_chunk_data(reader, &chunk_header)?); + } + + pixel_decoder.decode(&buffer)?; + Ok(pixel_decoder.finalize()?) +} + +impl IndexedBitmap { + pub fn load_png_bytes( + reader: &mut T, + ) -> Result<(IndexedBitmap, Option), PngError> { + load_png_bytes(reader) + } + + pub fn load_png_file(path: &Path) -> Result<(IndexedBitmap, Option), PngError> { + let f = File::open(path)?; + let mut reader = BufReader::new(f); + Self::load_png_bytes(&mut reader) + } + + pub fn to_png_bytes( + &self, + writer: &mut T, + palette: &Palette, + ) -> Result<(), PngError> { + todo!() + } + + pub fn to_png_file(&self, path: &Path, palette: &Palette) -> Result<(), PngError> { + let f = File::create(path)?; + let mut writer = BufWriter::new(f); + self.to_png_bytes(&mut writer, palette) + } +} + +impl RgbaBitmap { + pub fn load_png_bytes( + reader: &mut T, + ) -> Result<(RgbaBitmap, Option), PngError> { + load_png_bytes(reader) + } + + pub fn load_png_file(path: &Path) -> Result<(RgbaBitmap, Option), PngError> { + let f = File::open(path)?; + let mut reader = BufReader::new(f); + Self::load_png_bytes(&mut reader) + } + + pub fn to_png_bytes( + &self, + writer: &mut T, + ) -> Result<(), PngError> { + todo!() + } + + pub fn to_png_file(&self, path: &Path) -> Result<(), PngError> { + let f = File::create(path)?; + let mut writer = BufWriter::new(f); + self.to_png_bytes(&mut writer) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + #[test] + pub fn foo() -> Result<(), PngError> { + let _ = IndexedBitmap::load_png_file(Path::new("./test-assets/test_indexed.png"))?; + Ok(()) + } +} \ No newline at end of file diff --git a/ggdt/src/graphics/prelude.rs b/ggdt/src/graphics/prelude.rs index 23d558c..5c3eb96 100644 --- a/ggdt/src/graphics/prelude.rs +++ b/ggdt/src/graphics/prelude.rs @@ -12,6 +12,7 @@ pub use crate::graphics::{ primitives::*, }, pcx::*, + png::*, primitives::*, rgb::{ *, diff --git a/ggdt/test-assets/test_image_indexed.png b/ggdt/test-assets/test_image_indexed.png new file mode 100644 index 0000000..62baf0e Binary files /dev/null and b/ggdt/test-assets/test_image_indexed.png differ diff --git a/ggdt/test-assets/test_image_rgba.png b/ggdt/test-assets/test_image_rgba.png new file mode 100644 index 0000000..bdf604e Binary files /dev/null and b/ggdt/test-assets/test_image_rgba.png differ diff --git a/ggdt/test-assets/test_indexed.png b/ggdt/test-assets/test_indexed.png new file mode 100644 index 0000000..5b249a9 Binary files /dev/null and b/ggdt/test-assets/test_indexed.png differ diff --git a/ggdt/test-assets/test_rgba.png b/ggdt/test-assets/test_rgba.png new file mode 100644 index 0000000..0912507 Binary files /dev/null and b/ggdt/test-assets/test_rgba.png differ