From eb5869b60091b7a9c5f12dc1939b35ce2ec749de Mon Sep 17 00:00:00 2001 From: gered Date: Thu, 16 Mar 2023 21:18:49 -0400 Subject: [PATCH] initial and semi-incomplete png loading support - no filters other than filter 0 right now - no png file saving - inefficiencies - maybe some bugs, not a full test suite yet --- ggdt/Cargo.toml | 2 + ggdt/src/graphics/bitmap/mod.rs | 1 + ggdt/src/graphics/bitmap/png.rs | 401 ++++++++++++++++++++++++ ggdt/src/graphics/prelude.rs | 1 + ggdt/test-assets/test_image_indexed.png | Bin 0 -> 8532 bytes ggdt/test-assets/test_image_rgba.png | Bin 0 -> 14617 bytes ggdt/test-assets/test_indexed.png | Bin 0 -> 597 bytes ggdt/test-assets/test_rgba.png | Bin 0 -> 352 bytes 8 files changed, 405 insertions(+) create mode 100644 ggdt/src/graphics/bitmap/png.rs create mode 100644 ggdt/test-assets/test_image_indexed.png create mode 100644 ggdt/test-assets/test_image_rgba.png create mode 100644 ggdt/test-assets/test_indexed.png create mode 100644 ggdt/test-assets/test_rgba.png 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 0000000000000000000000000000000000000000..62baf0ed50bb434bfe838f4858747a8aba64c289 GIT binary patch literal 8532 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 MdZxNfI?f6I13?e87ytkO literal 0 HcmV?d00001 diff --git a/ggdt/test-assets/test_image_rgba.png b/ggdt/test-assets/test_image_rgba.png new file mode 100644 index 0000000000000000000000000000000000000000..bdf604ebf39393340a83c3f566ad5e6ed901a66c GIT binary patch literal 14617 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 P00000NkvXXu0mjfO#*!M literal 0 HcmV?d00001 diff --git a/ggdt/test-assets/test_indexed.png b/ggdt/test-assets/test_indexed.png new file mode 100644 index 0000000000000000000000000000000000000000..5b249a9b05fe1e6af9376979a89e2d6ad27dfadf GIT binary patch literal 597 zcmYk3Ur5tY6vw~A>Mqk#E7K7C!Cr!hMG;XWx~q-0G_~B=Vk_6vdJq}Rhoa$ch%v=- z1{L9cFhj_g{ehfJEb?JK1pX*#4~>BgYhXmMmjw|AZg)Y{J)H0N-1Bhm=iWQk({--C zc7H8^dZ)v7NqR#yjWtpa9NN_j7@|F$F1y?UDH%wmsv^x@E#1{6WVnT>${&+uxm+$5 zi@97bo6Tl2nYp>SL?RK7$5mAghr_{O(BtuRbaYrO7PHxGGMNNW2EG8FfK^}_m zL>f_qe)_4xsiIH?r9AW%q@O|4FfxKrKfEq*jy3YH5KogbO_(eps$(#XfQCHf-_W8) zT9hII@^j(}DIf{-?*eQUY#D4GY!)m5HV&qOg~5VgKCo_V;V2049}RILg-BAORD!M} zo&F0Hg4Zj7n(p>S{V9c#75OYaNcjSoH!96Z`s_inOo z!l6H$c=bBky|Hn`K7DFk9|#QHdpvgH>*j4sY^rskeTKb^-Px0hz1{llY};vVedb#8 T{mjXm@=Q4GUA7lz0>i%n+NbyW literal 0 HcmV?d00001 diff --git a/ggdt/test-assets/test_rgba.png b/ggdt/test-assets/test_rgba.png new file mode 100644 index 0000000000000000000000000000000000000000..0912507e85d4f3d6b80fcf59e775127d1df98fd7 GIT binary patch literal 352 zcmV-m0iXVfP)xSg}$Q(}0Q9fE2p{xeEsb^uPr$Iy2VXjTz?8=d=5FVOBVw`DQh!0;s0P zW8YahM4|5iLMSSvdHyt(VaPGU^_rY3>Nu9n8g~mB2BGg!P0r=$eD}%s`VDQn*G=;* z;|O3(=3;Gfj