From eefcdb5c18d7b7e6bc81d86809c317fc922f69b6 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 5 Aug 2026 22:58:33 -0700 Subject: [PATCH] Add color metadata to images --- src/chroma_subsampling.rs | 33 ----- src/color_type.rs | 22 ++++ src/error.rs | 13 ++ src/image.rs | 234 +++++++++++++++++++++++++++++++++- src/image_metadata.rs | 4 + src/lib.rs | 8 +- src/metadata.rs | 4 + src/subcommand/serve/tests.rs | 12 ++ src/templates/image.rs | 4 + src/templates/package.rs | 16 +++ src/test.rs | 65 +++++++++- tests/download.rs | 2 +- tests/metadata.rs | 8 +- 13 files changed, 374 insertions(+), 51 deletions(-) create mode 100644 src/color_type.rs diff --git a/src/chroma_subsampling.rs b/src/chroma_subsampling.rs index c0fe2c6d..494cfc20 100644 --- a/src/chroma_subsampling.rs +++ b/src/chroma_subsampling.rs @@ -23,36 +23,3 @@ pub(crate) enum ChromaSubsampling { #[strum(serialize = "4:4:4")] Yuv444, } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn display() { - #[track_caller] - fn case(subsampling: ChromaSubsampling, expected: &str) { - assert_eq!(subsampling.to_string(), expected); - } - - case(ChromaSubsampling::Yuv400, "4:0:0"); - case(ChromaSubsampling::Yuv420, "4:2:0"); - case(ChromaSubsampling::Yuv422, "4:2:2"); - case(ChromaSubsampling::Yuv440, "4:4:0"); - case(ChromaSubsampling::Yuv444, "4:4:4"); - } - - #[test] - fn serialize() { - #[track_caller] - fn case(subsampling: ChromaSubsampling, expected: &str) { - assert_eq!(serde_json::to_string(&subsampling).unwrap(), expected); - } - - case(ChromaSubsampling::Yuv400, r#""4:0:0""#); - case(ChromaSubsampling::Yuv420, r#""4:2:0""#); - case(ChromaSubsampling::Yuv422, r#""4:2:2""#); - case(ChromaSubsampling::Yuv440, r#""4:4:0""#); - case(ChromaSubsampling::Yuv444, r#""4:4:4""#); - } -} diff --git a/src/color_type.rs b/src/color_type.rs new file mode 100644 index 00000000..9ee2839e --- /dev/null +++ b/src/color_type.rs @@ -0,0 +1,22 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Decode, Default, Display, Encode, PartialEq, Serialize)] +pub(crate) enum ColorType { + #[n(0)] + #[serde(rename = "cmyk")] + #[strum(serialize = "CMYK")] + Cmyk, + #[n(1)] + #[serde(rename = "grayscale")] + #[strum(serialize = "grayscale")] + Grayscale, + #[n(2)] + #[serde(rename = "indexed")] + #[strum(serialize = "indexed")] + Indexed, + #[default] + #[n(3)] + #[serde(rename = "rgb")] + #[strum(serialize = "RGB")] + Rgb, +} diff --git a/src/error.rs b/src/error.rs index 34afd16b..e7300303 100644 --- a/src/error.rs +++ b/src/error.rs @@ -236,6 +236,12 @@ pub enum Error { backtrace: Option, path: RelativePath, }, + #[snafu(display("unsupported colorspace {colorspace:?} in image `{path}`"))] + ImageColorspace { + backtrace: Option, + colorspace: zune_jpeg::zune_core::colorspace::ColorSpace, + path: DisplayPath, + }, #[snafu(display("failed to decode JPEG image `{path}`"))] ImageDecodeJpeg { backtrace: Option, @@ -254,6 +260,13 @@ pub enum Error { path: DisplayPath, source: ExifError, }, + #[snafu(display("unsupported chroma subsampling {horizontal}×{vertical} in image `{path}`"))] + ImageSampleRatio { + backtrace: Option, + horizontal: usize, + path: DisplayPath, + vertical: usize, + }, #[snafu(display("internal error, this may indicate a bug in filepack: {message}"))] Internal { backtrace: Option, diff --git a/src/image.rs b/src/image.rs index e7f7d498..9371fb99 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,14 +1,23 @@ use super::*; +#[skip_serializing_none] #[derive(Clone, Debug, Decode, DeserializeFromStr, Encode, PartialEq, Serialize)] pub(crate) struct Image { #[n(0)] - pub(crate) dimensions: Dimensions, + pub(crate) alpha: bool, #[n(1)] - pub(crate) filename: ComponentBuf, + pub(crate) bit_depth: u64, #[n(2)] - pub(crate) orientation: Orientation, + pub(crate) chroma_subsampling: Option, #[n(3)] + pub(crate) color_type: ColorType, + #[n(4)] + pub(crate) dimensions: Dimensions, + #[n(5)] + pub(crate) filename: ComponentBuf, + #[n(6)] + pub(crate) orientation: Orientation, + #[n(7)] #[serde(rename = "type")] pub(crate) ty: ImageType, } @@ -44,7 +53,41 @@ impl Image { let info = decoder.info().unwrap(); + let colorspace = decoder.input_colorspace().unwrap(); + + let color_type = match colorspace { + ColorSpace::CMYK | ColorSpace::YCCK => ColorType::Cmyk, + ColorSpace::Luma => ColorType::Grayscale, + ColorSpace::YCbCr => ColorType::Rgb, + colorspace => return Err(error::ImageColorspace { colorspace, path }.build()), + }; + + let chroma_subsampling = if color_type == ColorType::Grayscale { + ChromaSubsampling::Yuv400 + } else { + match info.sample_ratio { + SampleRatios::H => ChromaSubsampling::Yuv422, + SampleRatios::HV => ChromaSubsampling::Yuv420, + SampleRatios::None => ChromaSubsampling::Yuv444, + SampleRatios::V => ChromaSubsampling::Yuv440, + SampleRatios::Generic(horizontal, vertical) => { + return Err( + error::ImageSampleRatio { + horizontal, + path, + vertical, + } + .build(), + ); + } + } + }; + Ok(ImageMetadata { + alpha: false, + bit_depth: 8, + chroma_subsampling: Some(chroma_subsampling), + color_type, dimensions: Dimensions { height: info.height.into(), width: info.width.into(), @@ -68,7 +111,19 @@ impl Image { Orientation::new() }; + let (color_type, alpha) = match info.color_type { + png::ColorType::Grayscale => (ColorType::Grayscale, false), + png::ColorType::GrayscaleAlpha => (ColorType::Grayscale, true), + png::ColorType::Indexed => (ColorType::Indexed, false), + png::ColorType::Rgb => (ColorType::Rgb, false), + png::ColorType::Rgba => (ColorType::Rgb, true), + }; + Ok(ImageMetadata { + alpha: alpha || info.trns.is_some(), + bit_depth: (info.bit_depth as u8).into(), + chroma_subsampling: None, + color_type, dimensions: Dimensions { height: info.height.into(), width: info.width.into(), @@ -94,10 +149,21 @@ impl Image { } pub(crate) fn populate(&mut self, root: &Utf8Path) -> Result { - let info = self.decode(root)?; + let ImageMetadata { + alpha, + bit_depth, + chroma_subsampling, + color_type, + dimensions, + orientation, + } = self.decode(root)?; - self.dimensions = info.dimensions; - self.orientation = info.orientation; + self.alpha = alpha; + self.bit_depth = bit_depth; + self.chroma_subsampling = chroma_subsampling; + self.color_type = color_type; + self.dimensions = dimensions; + self.orientation = orientation; Ok(()) } @@ -120,6 +186,10 @@ impl FromStr for Image { }; Ok(Self { + alpha: false, + bit_depth: 0, + chroma_subsampling: None, + color_type: ColorType::default(), dimensions: Dimensions::default(), filename, orientation: Orientation::new(), @@ -135,6 +205,10 @@ impl Item for Image { .value("type", self.ty) .value("dimensions", self.dimensions) .value("orientation", self.orientation) + .value("color type", self.color_type) + .value("bit depth", format!("{}-bit", self.bit_depth)) + .optional("chroma subsampling", self.chroma_subsampling) + .value("alpha", self.alpha) .build() } @@ -154,6 +228,10 @@ mod tests { #[test] fn formats() { let foo = Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, @@ -164,6 +242,10 @@ mod tests { }; let bar = Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions::default(), filename: "bar.jpg".parse().unwrap(), orientation: Orientation::new(), @@ -171,6 +253,10 @@ mod tests { }; let baz = Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 3, width: 4, @@ -196,6 +282,10 @@ mod tests { assert_eq!( "foo.jpg".parse::().unwrap(), Image { + alpha: false, + bit_depth: 0, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 0, width: 0, @@ -288,6 +378,10 @@ mod tests { assert_eq!( case("foo.jpg", &jpeg_with_exif(2, 1, &exif(6))).unwrap(), Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: Some(ChromaSubsampling::Yuv444), + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, @@ -304,6 +398,10 @@ mod tests { assert_eq!( case("foo.png", &png_with_exif(2, 1, &exif(5))).unwrap(), Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, @@ -317,6 +415,106 @@ mod tests { }, ); + let image = case("foo.jpg", &jpeg_grayscale(1, 1)).unwrap(); + assert!(!image.alpha); + assert_eq!(image.bit_depth, 8); + assert_eq!(image.chroma_subsampling, Some(ChromaSubsampling::Yuv400)); + assert_eq!(image.color_type, ColorType::Grayscale); + + assert_eq!( + case("foo.jpg", &jpeg_with_sampling(1, 1, 0x22)) + .unwrap() + .chroma_subsampling, + Some(ChromaSubsampling::Yuv420), + ); + + assert_matches_regex!( + case("foo.jpg", &jpeg_with_sampling(1, 1, 0x41)) + .unwrap_err() + .to_string(), + r"^unsupported chroma subsampling 4×1 in image `.*foo\.jpg`$", + ); + + let image = case( + "foo.png", + &png( + 1, + 1, + png::ColorType::Rgba, + png::BitDepth::Sixteen, + None, + None, + ), + ) + .unwrap(); + assert!(image.alpha); + assert_eq!(image.bit_depth, 16); + assert_eq!(image.chroma_subsampling, None); + assert_eq!(image.color_type, ColorType::Rgb); + + let image = case( + "foo.png", + &png( + 1, + 1, + png::ColorType::Indexed, + png::BitDepth::One, + None, + None, + ), + ) + .unwrap(); + assert!(!image.alpha); + assert_eq!(image.bit_depth, 1); + assert_eq!(image.color_type, ColorType::Indexed); + + assert!( + case( + "foo.png", + &png( + 1, + 1, + png::ColorType::Indexed, + png::BitDepth::One, + Some(&[0]), + None, + ), + ) + .unwrap() + .alpha + ); + + let image = case( + "foo.png", + &png( + 1, + 1, + png::ColorType::GrayscaleAlpha, + png::BitDepth::Eight, + None, + None, + ), + ) + .unwrap(); + assert!(image.alpha); + assert_eq!(image.color_type, ColorType::Grayscale); + + let image = case( + "foo.png", + &png( + 1, + 1, + png::ColorType::Grayscale, + png::BitDepth::Two, + None, + None, + ), + ) + .unwrap(); + assert!(!image.alpha); + assert_eq!(image.bit_depth, 2); + assert_eq!(image.color_type, ColorType::Grayscale); + assert_matches_regex!( case("foo.png", b"bar").unwrap_err().to_string(), r"^failed to decode PNG image `.*foo\.png`$", @@ -346,6 +544,10 @@ mod tests { fn serialize() { assert_eq!( serde_json::to_string(&Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: Some(ChromaSubsampling::Yuv420), + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, @@ -358,7 +560,25 @@ mod tests { ty: ImageType::Jpeg, }) .unwrap(), - r#"{"dimensions":{"height":1,"width":2},"filename":"foo.jpg","orientation":{"mirrored":true,"rotation":90},"type":"jpeg"}"#, + r#"{"alpha":false,"bit_depth":8,"chroma_subsampling":"4:2:0","color_type":"rgb","dimensions":{"height":1,"width":2},"filename":"foo.jpg","orientation":{"mirrored":true,"rotation":90},"type":"jpeg"}"#, + ); + + assert_eq!( + serde_json::to_string(&Image { + alpha: true, + bit_depth: 16, + chroma_subsampling: None, + color_type: ColorType::Rgb, + dimensions: Dimensions { + height: 1, + width: 2, + }, + filename: "foo.png".parse().unwrap(), + orientation: Orientation::new(), + ty: ImageType::Png, + }) + .unwrap(), + r#"{"alpha":true,"bit_depth":16,"color_type":"rgb","dimensions":{"height":1,"width":2},"filename":"foo.png","orientation":{"mirrored":false,"rotation":0},"type":"png"}"#, ); } } diff --git a/src/image_metadata.rs b/src/image_metadata.rs index 631bdab3..b56e9cc0 100644 --- a/src/image_metadata.rs +++ b/src/image_metadata.rs @@ -2,6 +2,10 @@ use super::*; #[derive(Debug, PartialEq)] pub(crate) struct ImageMetadata { + pub(crate) alpha: bool, + pub(crate) bit_depth: u64, + pub(crate) chroma_subsampling: Option, + pub(crate) color_type: ColorType, pub(crate) dimensions: Dimensions, pub(crate) orientation: Orientation, } diff --git a/src/lib.rs b/src/lib.rs index fb95c135..3978efac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ use { chroma_subsampling::ChromaSubsampling, codec::Codec, color_info::ColorInfo, + color_type::ColorType, component::Component, component_error::ComponentError, context::Context, @@ -206,7 +207,7 @@ use { url::Url, usized::IntoU64, walkdir::WalkDir, - zune_jpeg::JpegDecoder, + zune_jpeg::{JpegDecoder, SampleRatios, zune_core::colorspace::ColorSpace}, }; pub use self::{ @@ -247,8 +248,8 @@ use { std::assert_matches, tempfile::TempDir, test::{ - assert_cbor, assert_cbor_eq, assert_encoding, exif, flac, jpeg_with_exif, mp3, mp3_frame, - png_with_exif, tempdir, + assert_cbor, assert_cbor_eq, assert_encoding, exif, flac, jpeg_grayscale, jpeg_with_exif, + jpeg_with_sampling, mp3, mp3_frame, png, png_with_exif, tempdir, }, unindent::unindent, webm_builder::WebmBuilder, @@ -291,6 +292,7 @@ mod checked_url; mod chroma_subsampling; mod codec; mod color_info; +mod color_type; mod component; mod component_buf; mod component_error; diff --git a/src/metadata.rs b/src/metadata.rs index 51515ce6..80a45cc6 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -396,6 +396,10 @@ mod tests { fn encoding() { assert_encoding(Metadata { artwork: Some(Image { + alpha: true, + bit_depth: 8, + chroma_subsampling: Some(ChromaSubsampling::Yuv420), + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 1, diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index 50ed9bfa..23f05f43 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -1548,6 +1548,10 @@ fn package_item_image() { let metadata = Metadata { media: Some(Media::Image { items: vec![Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, @@ -1583,6 +1587,10 @@ fn package_item_image_out_of_range() { .metadata(&Metadata { media: Some(Media::Image { items: vec![Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 1, @@ -1924,6 +1932,10 @@ fn package_page_renders_image_media() { let metadata = Metadata { media: Some(Media::Image { items: vec![Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, diff --git a/src/templates/image.rs b/src/templates/image.rs index 56125d49..10c520e2 100644 --- a/src/templates/image.rs +++ b/src/templates/image.rs @@ -40,6 +40,10 @@ mod tests { metadata: Metadata { media: Some(Media::Image { items: vec![Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, diff --git a/src/templates/package.rs b/src/templates/package.rs index 710da994..a41b2946 100644 --- a/src/templates/package.rs +++ b/src/templates/package.rs @@ -343,6 +343,10 @@ mod tests { media: Some(Media::Image { items: vec![ Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, @@ -352,12 +356,20 @@ mod tests { ty: ImageType::Png, }, Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions::default(), filename: "bar.jpg".parse().unwrap(), orientation: Orientation::new(), ty: ImageType::Jpeg, }, Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, @@ -437,6 +449,10 @@ mod tests { fingerprint: test::FINGERPRINT.parse().unwrap(), metadata: Some(Metadata { artwork: Some(Image { + alpha: false, + bit_depth: 8, + chroma_subsampling: None, + color_type: ColorType::Rgb, dimensions: Dimensions { height: 1, width: 2, diff --git a/src/test.rs b/src/test.rs index 437cde40..7245f261 100644 --- a/src/test.rs +++ b/src/test.rs @@ -132,12 +132,24 @@ pub(crate) fn flac(comments: &[&str], samples: u32) -> Vec { bytes } -pub(crate) fn jpeg_with_exif(width: u32, height: u32, exif: &[u8]) -> Vec { +pub(crate) fn jpeg(width: u32, height: u32) -> Vec { let mut buffer = io::Cursor::new(Vec::new()); ::image::DynamicImage::new_rgb8(width, height) .write_to(&mut buffer, ::image::ImageFormat::Jpeg) .unwrap(); - let buffer = buffer.into_inner(); + buffer.into_inner() +} + +pub(crate) fn jpeg_grayscale(width: u32, height: u32) -> Vec { + let mut buffer = io::Cursor::new(Vec::new()); + ::image::DynamicImage::new_luma8(width, height) + .write_to(&mut buffer, ::image::ImageFormat::Jpeg) + .unwrap(); + buffer.into_inner() +} + +pub(crate) fn jpeg_with_exif(width: u32, height: u32, exif: &[u8]) -> Vec { + let buffer = jpeg(width, height); let mut app1 = b"Exif\0\0".to_vec(); app1.extend_from_slice(exif); @@ -150,6 +162,13 @@ pub(crate) fn jpeg_with_exif(width: u32, height: u32, exif: &[u8]) -> Vec { spliced } +pub(crate) fn jpeg_with_sampling(width: u32, height: u32, sampling: u8) -> Vec { + let mut bytes = jpeg(width, height); + let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap(); + bytes[sof + 11] = sampling; + bytes +} + pub(crate) fn mp3(tags: &[&str], frames: u32) -> Vec { fn syncsafe(n: usize) -> [u8; 4] { let n = u32::try_from(n).unwrap(); @@ -190,22 +209,56 @@ pub(crate) fn mp3_frame() -> Vec { bytes } -pub(crate) fn png_with_exif(width: u32, height: u32, exif: &[u8]) -> Vec { +pub(crate) fn png( + width: u32, + height: u32, + color_type: png::ColorType, + bit_depth: png::BitDepth, + trns: Option<&[u8]>, + exif: Option<&[u8]>, +) -> Vec { let mut buffer = Vec::new(); let mut encoder = png::Encoder::new(&mut buffer, width, height); - encoder.set_color(png::ColorType::Rgb); + encoder.set_color(color_type); + encoder.set_depth(bit_depth); + + if color_type == png::ColorType::Indexed { + encoder.set_palette(vec![0; 3]); + } + + if let Some(trns) = trns { + encoder.set_trns(trns.to_vec()); + } let mut writer = encoder.write_header().unwrap(); - writer.write_chunk(png::chunk::eXIf, exif).unwrap(); + + if let Some(exif) = exif { + writer.write_chunk(png::chunk::eXIf, exif).unwrap(); + } + + let samples = u32::try_from(color_type.samples()).unwrap(); + let row = (width * samples * u32::from(bit_depth as u8)).div_ceil(8); + writer - .write_image_data(&vec![0; usize::try_from(width * height * 3).unwrap()]) + .write_image_data(&vec![0; usize::try_from(row * height).unwrap()]) .unwrap(); writer.finish().unwrap(); buffer } +pub(crate) fn png_with_exif(width: u32, height: u32, exif: &[u8]) -> Vec { + png( + width, + height, + png::ColorType::Rgb, + png::BitDepth::Eight, + None, + Some(exif), + ) +} + pub(crate) fn tempdir() -> (TempDir, Utf8PathBuf) { let tempdir = tempfile::Builder::new() .prefix("filepack-test-tempdir") diff --git a/tests/download.rs b/tests/download.rs index 099384b9..6a7fe579 100644 --- a/tests/download.rs +++ b/tests/download.rs @@ -382,7 +382,7 @@ fn download_retrieves_package_with_metadata() { .assert_file("out/README.md", "baz") .success() .args(["verify", "out"]) - .stderr("successfully verified 5 files totaling 257 bytes\n") + .stderr("successfully verified 5 files totaling 263 bytes\n") .success(); server.terminate().success(); diff --git a/tests/metadata.rs b/tests/metadata.rs index 6b94108b..d9adabeb 100644 --- a/tests/metadata.rs +++ b/tests/metadata.rs @@ -69,6 +69,9 @@ fn create_extracts_artwork_dimensions() { r#" { "artwork": { + "alpha": false, + "bit_depth": 8, + "color_type": "rgb", "dimensions": { "height": 2, "width": 2 @@ -109,6 +112,9 @@ fn create_extracts_image_dimensions() { "type": "image", "items": [ { + "alpha": false, + "bit_depth": 8, + "color_type": "rgb", "dimensions": { "height": 1, "width": 2 @@ -473,7 +479,7 @@ fn create_succeeds_with_valid_metadata() { .arg("create") .success() .arg("verify") - .stderr("successfully verified 6 files totaling 254 bytes\n") + .stderr("successfully verified 6 files totaling 260 bytes\n") .success(); }