diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 96da5d9a9c..3c6a25c737 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - features: ['', default, rayon, avif, bmp, dds, exr, ff, gif, hdr, ico, jpeg, png, pnm, qoi, tga, tiff, webp] + features: ['', default, rayon, avif, bmp, dds, exr, ff, gif, hdr, ico, jpeg, pcx, png, pnm, qoi, tga, tiff, webp] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable diff --git a/Cargo.toml b/Cargo.toml index 4a6108a26c..7cd5fbdf60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ exr = { version = "1.5.0", optional = true } gif = { version = "0.13", optional = true } image-webp = { version = "0.2.0", optional = true } mp4parse = { version = "0.17.0", optional = true } +pcx = { version = "0.2.3", optional = true } png = { version = "0.17.6", optional = true } qoi = { version = "0.4", optional = true } ravif = { version = "0.11.11", default-features = false, optional = true } @@ -77,6 +78,7 @@ gif = ["dep:gif", "dep:color_quant"] hdr = [] ico = ["bmp", "png"] jpeg = ["dep:zune-core", "dep:zune-jpeg"] +pcx = ["dep:pcx"] # Note that the PCX dependency uses the WTFPL license png = ["dep:png"] pnm = [] qoi = ["dep:qoi"] diff --git a/README.md b/README.md index e3eb168058..4fff85b666 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ image format encoders and decoders. | ICO | Yes | Yes | | JPEG | Yes | Yes | | EXR | Yes | Yes | +| PCX | Yes | --- | | PNG | Yes | Yes | | PNM | Yes | Yes | | QOI | Yes | Yes | diff --git a/deny.toml b/deny.toml index 5bb4a46f3e..951f2a2fab 100644 --- a/deny.toml +++ b/deny.toml @@ -24,6 +24,10 @@ allow = [ "Unicode-DFS-2016", ] +[[licenses.exceptions]] +allow = ["WTFPL"] +name = "pcx" +version = "*" [advisories] yanked = "deny" diff --git a/src/codecs/pcx.rs b/src/codecs/pcx.rs new file mode 100644 index 0000000000..b7c9a507b7 --- /dev/null +++ b/src/codecs/pcx.rs @@ -0,0 +1,157 @@ +//! Decoding and Encoding of PCX Images +//! +//! PCX (PiCture eXchange) Format is an obsolete image format from the 1980s. +//! +//! # Related Links +//! * - The PCX format on Wikipedia + +extern crate pcx; + +use std::io::{self, BufRead, Cursor, Read, Seek}; +use std::iter; +use std::marker::PhantomData; +use std::mem; + +use crate::color::{ColorType, ExtendedColorType}; +use crate::error::{ImageError, ImageResult}; +use crate::image::ImageDecoder; + +/// Decoder for PCX images. +pub struct PCXDecoder +where + R: BufRead + Seek, +{ + dimensions: (u32, u32), + inner: pcx::Reader, +} + +impl PCXDecoder +where + R: BufRead + Seek, +{ + /// Create a new `PCXDecoder`. + pub fn new(r: R) -> Result, ImageError> { + let inner = pcx::Reader::new(r).map_err(ImageError::from_pcx_decode)?; + let dimensions = (u32::from(inner.width()), u32::from(inner.height())); + + Ok(PCXDecoder { dimensions, inner }) + } +} + +impl ImageError { + fn from_pcx_decode(err: io::Error) -> ImageError { + ImageError::IoError(err) + } +} + +/// Wrapper struct around a `Cursor>` +#[allow(dead_code)] +#[deprecated] +pub struct PCXReader(Cursor>, PhantomData); +#[allow(deprecated)] +impl Read for PCXReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.0.read(buf) + } + + fn read_to_end(&mut self, buf: &mut Vec) -> io::Result { + if self.0.position() == 0 && buf.is_empty() { + mem::swap(buf, self.0.get_mut()); + Ok(buf.len()) + } else { + self.0.read_to_end(buf) + } + } +} + +impl ImageDecoder for PCXDecoder { + fn dimensions(&self) -> (u32, u32) { + self.dimensions + } + + fn color_type(&self) -> ColorType { + ColorType::Rgb8 + } + + fn original_color_type(&self) -> ExtendedColorType { + if self.inner.is_paletted() { + return ExtendedColorType::Unknown(self.inner.header.bit_depth); + } + + match ( + self.inner.header.number_of_color_planes, + self.inner.header.bit_depth, + ) { + (1, 1) => ExtendedColorType::L1, + (1, 2) => ExtendedColorType::L2, + (1, 4) => ExtendedColorType::L4, + (1, 8) => ExtendedColorType::L8, + (3, 1) => ExtendedColorType::Rgb1, + (3, 2) => ExtendedColorType::Rgb2, + (3, 4) => ExtendedColorType::Rgb4, + (3, 8) => ExtendedColorType::Rgb8, + (4, 1) => ExtendedColorType::Rgba1, + (4, 2) => ExtendedColorType::Rgba2, + (4, 4) => ExtendedColorType::Rgba4, + (4, 8) => ExtendedColorType::Rgba8, + (_, _) => unreachable!(), + } + } + + fn read_image(mut self, buf: &mut [u8]) -> ImageResult<()> { + assert_eq!(u64::try_from(buf.len()), Ok(self.total_bytes())); + + let height = self.inner.height() as usize; + let width = self.inner.width() as usize; + + match self.inner.palette_length() { + // No palette to interpret, so we can just write directly to buf + None => { + for i in 0..height { + let offset = i * 3 * width; + self.inner + .next_row_rgb(&mut buf[offset..offset + (width * 3)]) + .map_err(ImageError::from_pcx_decode)?; + } + } + + // We need to convert from the palette colours to RGB values inline, + // but the pcx crate can't give us the palette first. Work around it + // by taking the paletted image into a buffer, then converting it to + // RGB8 after. + Some(palette_length) => { + let mut pal_buf: Vec = iter::repeat(0).take(height * width).collect(); + + for i in 0..height { + let offset = i * width; + self.inner + .next_row_paletted(&mut pal_buf[offset..offset + width]) + .map_err(ImageError::from_pcx_decode)?; + } + + let mut palette: Vec = + iter::repeat(0).take(3 * palette_length as usize).collect(); + self.inner + .read_palette(&mut palette[..]) + .map_err(ImageError::from_pcx_decode)?; + + for i in 0..height { + for j in 0..width { + let pixel = pal_buf[i * width + j] as usize; + let offset = i * width * 3 + j * 3; + + buf[offset] = palette[pixel * 3]; + buf[offset + 1] = palette[pixel * 3 + 1]; + buf[offset + 2] = palette[pixel * 3 + 2]; + } + } + } + } + + Ok(()) + } + + fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { + (*self).read_image(buf) + } +} diff --git a/src/image.rs b/src/image.rs index 131e95707c..81aebbd083 100644 --- a/src/image.rs +++ b/src/image.rs @@ -66,6 +66,9 @@ pub enum ImageFormat { /// An Image in QOI Format Qoi, + + /// An Image in PCX Format + Pcx, } impl ImageFormat { @@ -104,6 +107,7 @@ impl ImageFormat { "pbm" | "pam" | "ppm" | "pgm" => ImageFormat::Pnm, "ff" => ImageFormat::Farbfeld, "qoi" => ImageFormat::Qoi, + "pcx" => ImageFormat::Pcx, _ => return None, }) } @@ -179,6 +183,7 @@ impl ImageFormat { // Qoi's MIME type is being worked on. // See: https://github.com/phoboslab/qoi/issues/167 "image/x-qoi" => Some(ImageFormat::Qoi), + "image/vnd.zbrush.pcx" | "image/x-pcx" => Some(ImageFormat::Pcx), _ => None, } } @@ -226,6 +231,7 @@ impl ImageFormat { ImageFormat::Qoi => "image/x-qoi", // farbfeld's MIME type taken from https://www.wikidata.org/wiki/Q28206109 ImageFormat::Farbfeld => "application/octet-stream", + ImageFormat::Pcx => "image/vnd.zbrush.pcx", } } @@ -250,6 +256,7 @@ impl ImageFormat { ImageFormat::Farbfeld => true, ImageFormat::Avif => true, ImageFormat::Qoi => true, + ImageFormat::Pcx => true, } } @@ -274,6 +281,7 @@ impl ImageFormat { ImageFormat::OpenExr => true, ImageFormat::Dds => false, ImageFormat::Qoi => true, + ImageFormat::Pcx => false, } } @@ -305,6 +313,7 @@ impl ImageFormat { // According to: https://aomediacodec.github.io/av1-avif/#mime-registration ImageFormat::Avif => &["avif"], ImageFormat::Qoi => &["qoi"], + ImageFormat::Pcx => &["pcx"], } } @@ -327,6 +336,7 @@ impl ImageFormat { ImageFormat::Farbfeld => cfg!(feature = "ff"), ImageFormat::Avif => cfg!(feature = "avif"), ImageFormat::Qoi => cfg!(feature = "qoi"), + ImageFormat::Pcx => cfg!(feature = "pcx"), ImageFormat::Dds => false, } } @@ -350,6 +360,7 @@ impl ImageFormat { ImageFormat::OpenExr => cfg!(feature = "exr"), ImageFormat::Qoi => cfg!(feature = "qoi"), ImageFormat::Hdr => cfg!(feature = "hdr"), + ImageFormat::Pcx => false, ImageFormat::Dds => false, } } @@ -372,6 +383,7 @@ impl ImageFormat { ImageFormat::Qoi, ImageFormat::Dds, ImageFormat::Hdr, + ImageFormat::Pcx, ] .iter() .copied() @@ -1647,6 +1659,7 @@ mod tests { assert_eq!(from_path("./a.Ppm").unwrap(), ImageFormat::Pnm); assert_eq!(from_path("./a.pgm").unwrap(), ImageFormat::Pnm); assert_eq!(from_path("./a.AViF").unwrap(), ImageFormat::Avif); + assert_eq!(from_path("./a.PCX").unwrap(), ImageFormat::Pcx); assert!(from_path("./a.txt").is_err()); assert!(from_path("./a").is_err()); } @@ -1802,7 +1815,7 @@ mod tests { fn image_formats_are_recognized() { use ImageFormat::*; const ALL_FORMATS: &[ImageFormat] = &[ - Avif, Png, Jpeg, Gif, WebP, Pnm, Tiff, Tga, Dds, Bmp, Ico, Hdr, Farbfeld, OpenExr, + Avif, Png, Jpeg, Gif, WebP, Pnm, Tiff, Tga, Dds, Bmp, Ico, Hdr, Farbfeld, OpenExr, Pcx, ]; for &format in ALL_FORMATS { let mut file = Path::new("file.nothing").to_owned(); diff --git a/src/image_reader/free_functions.rs b/src/image_reader/free_functions.rs index 7a9bb34400..7e32159a9d 100644 --- a/src/image_reader/free_functions.rs +++ b/src/image_reader/free_functions.rs @@ -124,7 +124,7 @@ pub(crate) fn write_buffer_impl( } } -static MAGIC_BYTES: [(&[u8], ImageFormat); 23] = [ +static MAGIC_BYTES: [(&[u8], ImageFormat); 25] = [ (b"\x89PNG\r\n\x1a\n", ImageFormat::Png), (&[0xff, 0xd8, 0xff], ImageFormat::Jpeg), (b"GIF89a", ImageFormat::Gif), @@ -148,6 +148,8 @@ static MAGIC_BYTES: [(&[u8], ImageFormat); 23] = [ (b"\0\0\0\x1cftypavif", ImageFormat::Avif), (&[0x76, 0x2f, 0x31, 0x01], ImageFormat::OpenExr), // = &exr::meta::magic_number::BYTES (b"qoif", ImageFormat::Qoi), + (&[0x0a, 0x02], ImageFormat::Pcx), + (&[0x0a, 0x05], ImageFormat::Pcx), ]; /// Guess image format from memory block diff --git a/src/image_reader/image_reader_type.rs b/src/image_reader/image_reader_type.rs index 479316f191..364f389588 100644 --- a/src/image_reader/image_reader_type.rs +++ b/src/image_reader/image_reader_type.rs @@ -177,6 +177,8 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { ImageFormat::Farbfeld => Box::new(farbfeld::FarbfeldDecoder::new(reader)?), #[cfg(feature = "qoi")] ImageFormat::Qoi => Box::new(qoi::QoiDecoder::new(reader)?), + #[cfg(feature = "pcx")] + ImageFormat::Pcx => Box::new(pcx::PCXDecoder::new(reader)?), format => { return Err(ImageError::Unsupported( ImageFormatHint::Exact(format).into(), diff --git a/src/lib.rs b/src/lib.rs index 32b25fb924..af7296af8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -214,6 +214,7 @@ pub mod flat; /// | ICO | Yes | Yes | /// | JPEG | Yes | Yes | /// | EXR | Yes | Yes | +/// | PCX | Yes | --- | /// | PNG | Yes | Yes | /// | PNM | Yes | Yes | /// | QOI | Yes | Yes | @@ -263,6 +264,8 @@ pub mod codecs { pub mod jpeg; #[cfg(feature = "exr")] pub mod openexr; + #[cfg(feature = "pcx")] + pub mod pcx; #[cfg(feature = "png")] pub mod png; #[cfg(feature = "pnm")] diff --git a/tests/images/pcx/images/cga_bw.pcx b/tests/images/pcx/images/cga_bw.pcx new file mode 100644 index 0000000000..d135810db0 Binary files /dev/null and b/tests/images/pcx/images/cga_bw.pcx differ diff --git a/tests/images/pcx/images/cga_fsd.pcx b/tests/images/pcx/images/cga_fsd.pcx new file mode 100644 index 0000000000..242429a608 Binary files /dev/null and b/tests/images/pcx/images/cga_fsd.pcx differ diff --git a/tests/images/pcx/images/cga_rgbi.pcx b/tests/images/pcx/images/cga_rgbi.pcx new file mode 100644 index 0000000000..e341f80450 Binary files /dev/null and b/tests/images/pcx/images/cga_rgbi.pcx differ diff --git a/tests/images/pcx/images/cga_tst1.pcx b/tests/images/pcx/images/cga_tst1.pcx new file mode 100644 index 0000000000..6f492d91be Binary files /dev/null and b/tests/images/pcx/images/cga_tst1.pcx differ diff --git a/tests/images/pcx/images/gmarbles.pcx b/tests/images/pcx/images/gmarbles.pcx new file mode 100644 index 0000000000..e9ad59f201 Binary files /dev/null and b/tests/images/pcx/images/gmarbles.pcx differ diff --git a/tests/images/pcx/images/marbles.pcx b/tests/images/pcx/images/marbles.pcx new file mode 100644 index 0000000000..24a4724864 Binary files /dev/null and b/tests/images/pcx/images/marbles.pcx differ diff --git a/tests/reference/pcx/images/cga_bw.pcx.620c687c.png b/tests/reference/pcx/images/cga_bw.pcx.620c687c.png new file mode 100644 index 0000000000..ed2cbeaf60 Binary files /dev/null and b/tests/reference/pcx/images/cga_bw.pcx.620c687c.png differ diff --git a/tests/reference/pcx/images/cga_fsd.pcx.33a4fc15.png b/tests/reference/pcx/images/cga_fsd.pcx.33a4fc15.png new file mode 100644 index 0000000000..74d796197a Binary files /dev/null and b/tests/reference/pcx/images/cga_fsd.pcx.33a4fc15.png differ diff --git a/tests/reference/pcx/images/cga_rgbi.pcx.a1de5299.png b/tests/reference/pcx/images/cga_rgbi.pcx.a1de5299.png new file mode 100644 index 0000000000..6e6a19b8de Binary files /dev/null and b/tests/reference/pcx/images/cga_rgbi.pcx.a1de5299.png differ diff --git a/tests/reference/pcx/images/cga_tst1.pcx.2a36d106.png b/tests/reference/pcx/images/cga_tst1.pcx.2a36d106.png new file mode 100644 index 0000000000..61582e4690 Binary files /dev/null and b/tests/reference/pcx/images/cga_tst1.pcx.2a36d106.png differ diff --git a/tests/reference/pcx/images/gmarbles.pcx.1359bee5.png b/tests/reference/pcx/images/gmarbles.pcx.1359bee5.png new file mode 100644 index 0000000000..79b6d69cd5 Binary files /dev/null and b/tests/reference/pcx/images/gmarbles.pcx.1359bee5.png differ diff --git a/tests/reference/pcx/images/marbles.pcx.96101fd7.png b/tests/reference/pcx/images/marbles.pcx.96101fd7.png new file mode 100644 index 0000000000..fb57c64c04 Binary files /dev/null and b/tests/reference/pcx/images/marbles.pcx.96101fd7.png differ diff --git a/tests/reference_images.rs b/tests/reference_images.rs index 26740670e1..2da7d622d4 100644 --- a/tests/reference_images.rs +++ b/tests/reference_images.rs @@ -18,7 +18,7 @@ where { let base: PathBuf = BASE_PATH.iter().collect(); let decoders = &[ - "tga", "tiff", "png", "gif", "bmp", "ico", "hdr", "pbm", "webp", + "tga", "tiff", "png", "gif", "bmp", "ico", "hdr", "pbm", "webp", "pcx", ]; for decoder in decoders { let mut path = base.clone(); diff --git a/tests/truncate_images.rs b/tests/truncate_images.rs index de2bbefddd..354f78d734 100644 --- a/tests/truncate_images.rs +++ b/tests/truncate_images.rs @@ -16,7 +16,7 @@ where { let base: PathBuf = BASE_PATH.iter().collect(); let decoders = &[ - "tga", "tiff", "png", "gif", "bmp", "ico", "jpg", "hdr", "farbfeld", "exr", + "tga", "tiff", "png", "gif", "bmp", "ico", "jpg", "hdr", "farbfeld", "exr", "pcx", ]; for decoder in decoders { let mut path = base.clone(); @@ -99,3 +99,8 @@ fn truncate_farbfeld() { fn truncate_exr() { truncate_images("exr"); } + +#[test] +fn truncate_pcx() { + truncate_images("pcx"); +}