diff --git a/Cargo.lock b/Cargo.lock index 293d58133a..4c5e73796a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -636,6 +636,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + [[package]] name = "codemap" version = "0.1.3" @@ -876,6 +885,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.61", +] + +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.61", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -913,6 +957,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +dependencies = [ + "derive_builder_core", + "syn 2.0.61", +] + [[package]] name = "deunicode" version = "1.4.4" @@ -1654,6 +1729,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -1889,6 +1970,39 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" +[[package]] +name = "jpegxl-rs" +version = "0.10.3+libjxl-0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e697c7532d4a244d91306a19c63763769366fbc61e75d5a08cc602923e478a7a" +dependencies = [ + "byteorder", + "derive_builder", + "half", + "image", + "jpegxl-sys", + "thiserror", +] + +[[package]] +name = "jpegxl-src" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7808dc25b79f6ff27137f91b1b7659eb27f570d401ff464d03fd336041fc5c68" +dependencies = [ + "cmake", +] + +[[package]] +name = "jpegxl-sys" +version = "0.10.3+libjxl-0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8262d19ed55829441dc2a2b1315c8f15e040139443fc1dbc48551890020a5c2" +dependencies = [ + "jpegxl-src", + "pkg-config", +] + [[package]] name = "js-sys" version = "0.3.69" @@ -1992,6 +2106,7 @@ dependencies = [ "globset", "grass", "image", + "jpegxl-rs", "lexical-sort", "minify-html", "nom-bibtex", diff --git a/components/imageproc/src/format.rs b/components/imageproc/src/format.rs index d7c992eab8..ea49d73756 100644 --- a/components/imageproc/src/format.rs +++ b/components/imageproc/src/format.rs @@ -6,12 +6,14 @@ const DEFAULT_Q_JPG: u8 = 75; /// Thumbnail image format #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Format { - /// JPEG, The `u8` argument is JPEG quality (in percent). + /// JPEG, The `u8` argument is JPEG quality (1..100). Jpeg(u8), /// PNG Png, - /// WebP, The `u8` argument is WebP quality (in percent), None meaning lossless. + /// WebP, The `u8` argument is WebP quality (1..100), None meaning lossless. WebP(Option), + /// JPEG XL, The `u8` argument is quality (1..100), None meaning lossless. + JXL(Option), } impl Format { @@ -32,6 +34,7 @@ impl Format { "jpeg" | "jpg" => Ok(Jpeg(jpg_quality)), "png" => Ok(Png), "webp" => Ok(WebP(quality)), + "jxl" => Ok(JXL(quality)), _ => Err(anyhow!("Invalid image format: {}", format)), } } @@ -44,6 +47,7 @@ impl Format { Png => "png", Jpeg(_) => "jpg", WebP(_) => "webp", + JXL(_) => "jxl", } } } @@ -58,6 +62,8 @@ impl Hash for Format { Jpeg(q) => 1001 + q as u16, WebP(None) => 2000, WebP(Some(q)) => 2001 + q as u16, + JXL(None) => 3000, + JXL(Some(q)) => 3001 + q as u16, }; hasher.write_u16(q); diff --git a/components/imageproc/src/meta.rs b/components/imageproc/src/meta.rs index 8ee83c335f..1ba38897c1 100644 --- a/components/imageproc/src/meta.rs +++ b/components/imageproc/src/meta.rs @@ -1,6 +1,7 @@ use errors::{anyhow, Context, Result}; use libs::image::io::Reader as ImgReader; -use libs::image::{ImageFormat, ImageResult}; +use libs::image::ImageFormat; +use libs::jpegxl_rs::decoder_builder; use libs::svg_metadata::Metadata as SvgMetadata; use serde::Serialize; use std::ffi::OsStr; @@ -15,12 +16,23 @@ pub struct ImageMeta { } impl ImageMeta { - pub fn read(path: &Path) -> ImageResult { - let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?; - let format = reader.format(); - let size = reader.into_dimensions()?; + pub fn read(path: &Path) -> Result { + if path.extension().is_some_and(|ext| ext == "jxl") { + Self::read_jxl(path) + } else { + let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?; + let format = reader.format(); + let size = reader.into_dimensions()?; - Ok(Self { size, format }) + Ok(Self { size, format }) + } + } + + fn read_jxl(path: &Path) -> Result { + let input = std::fs::read(path)?; + let decoder = decoder_builder().build()?; + let (meta, _) = decoder.decode(&input)?; + Ok(ImageMeta { size: (meta.width, meta.height), format: None }) } pub fn is_lossy(&self) -> bool { @@ -44,6 +56,9 @@ impl ImageMetaResponse { pub fn new_svg(width: u32, height: u32) -> Self { Self { width, height, format: Some("svg"), mime: Some("text/svg+xml") } } + pub fn new_jxl(width: u32, height: u32) -> Self { + Self { width, height, format: Some("jxl"), mime: Some("image/jxl") } + } } impl From for ImageMetaResponse { @@ -75,6 +90,10 @@ pub fn read_image_metadata>(path: P) -> Result // this is not a typo, this returns the correct values for width and height. .map(|(h, w)| ImageMetaResponse::new_svg(w as u32, h as u32)) } + "jxl" => { + let meta = ImageMeta::read(path)?; + Ok(ImageMetaResponse::new_jxl(meta.size.0, meta.size.1)) + } _ => ImageMeta::read(path).map(ImageMetaResponse::from).with_context(err_context), } } diff --git a/components/imageproc/src/processor.rs b/components/imageproc/src/processor.rs index 577aeb1f3e..72f8a42615 100644 --- a/components/imageproc/src/processor.rs +++ b/components/imageproc/src/processor.rs @@ -4,13 +4,16 @@ use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use config::Config; -use errors::{anyhow, Context, Result}; +use errors::{anyhow, bail, Context, Result}; use libs::ahash::{HashMap, HashSet}; use libs::image::codecs::jpeg::JpegEncoder; use libs::image::imageops::FilterType; -use libs::image::{EncodableLayout, ImageFormat}; +use libs::image::{ColorType, EncodableLayout, ImageFormat}; +use libs::jpegxl_rs::decoder_builder; +use libs::jpegxl_rs::encode::EncoderFrame; +use libs::jpegxl_rs::image::ToDynamic; use libs::rayon::prelude::*; -use libs::{image, webp}; +use libs::{image, jpegxl_rs, webp}; use serde::{Deserialize, Serialize}; use utils::fs as ufs; @@ -39,7 +42,15 @@ impl ImageOp { return Ok(()); } - let img = image::open(&self.input_path)?; + let img = if self.input_path.extension().is_some_and(|ext| ext == "jxl") { + let input = std::fs::read(&self.input_path)?; + let decoder = decoder_builder().build()?; + decoder + .decode_to_image(&input)? + .context("jxl image could not be represented in an Image")? + } else { + image::open(&self.input_path)? + }; let mut img = fix_orientation(&img, &self.input_path).unwrap_or(img); let img = match self.instr.crop_instruction { @@ -71,6 +82,40 @@ impl ImageOp { }; buffered_f.write_all(memory.as_bytes())?; } + Format::JXL(q) => { + let mut encoder = jpegxl_rs::encoder_builder(); + if let Some(q) = q { + if q == 100 { + encoder.uses_original_profile(true); + encoder.lossless(true); + } else { + encoder.set_jpeg_quality(q as f32); + } + } else { + encoder.uses_original_profile(true); + encoder.lossless(true); + } + let frame = EncoderFrame::new(img.as_bytes()); + let frame = match img.color() { + ColorType::L8 => frame.num_channels(1), + ColorType::La8 => { + encoder.has_alpha(true); + frame.num_channels(2) + } + ColorType::Rgb8 => frame.num_channels(3), + ColorType::Rgba8 => { + encoder.has_alpha(true); + frame.num_channels(4) + } + _ => { + bail!("Unsupported pixel type {:?}", img.color()); + } + }; + let mut encoder = encoder.build()?; + buffered_f.write_all( + &encoder.encode_frame::(&frame, img.width(), img.height())?.data, + )?; + } } Ok(()) diff --git a/components/imageproc/tests/resize_image.rs b/components/imageproc/tests/resize_image.rs index 41114abf50..40e32732e1 100644 --- a/components/imageproc/tests/resize_image.rs +++ b/components/imageproc/tests/resize_image.rs @@ -132,6 +132,16 @@ fn resize_image_webp_jpg() { image_op_test("webp.webp", "scale", Some(150), Some(150), "auto", "jpg", 150, 150, 300, 380); } +#[test] +fn resize_image_png_jxl() { + image_op_test("png.png", "scale", Some(150), Some(150), "jxl", "jxl", 150, 150, 300, 380); +} + +#[test] +fn resize_image_jxl_png() { + image_op_test("jxl.jxl", "scale", Some(150), Some(150), "png", "png", 150, 150, 300, 380); +} + #[test] fn read_image_metadata_jpg() { assert_eq!( diff --git a/components/imageproc/tests/test_imgs/jxl.jxl b/components/imageproc/tests/test_imgs/jxl.jxl new file mode 100644 index 0000000000..a5e3db1de3 Binary files /dev/null and b/components/imageproc/tests/test_imgs/jxl.jxl differ diff --git a/components/libs/Cargo.toml b/components/libs/Cargo.toml index 7c23ff3127..568a4371a6 100644 --- a/components/libs/Cargo.toml +++ b/components/libs/Cargo.toml @@ -44,6 +44,7 @@ unicode-segmentation = "1.2" url = "2" walkdir = "2" webp = "0.3" +jpegxl-rs = { version = "0.10.3", features = ["vendored"] } [features] diff --git a/components/libs/src/lib.rs b/components/libs/src/lib.rs index 3992c2ed29..395a5fc7fa 100644 --- a/components/libs/src/lib.rs +++ b/components/libs/src/lib.rs @@ -16,6 +16,7 @@ pub use glob; pub use globset; pub use grass; pub use image; +pub use jpegxl_rs; pub use lexical_sort; pub use minify_html; pub use nom_bibtex; diff --git a/docs/content/documentation/content/image-processing/index.md b/docs/content/documentation/content/image-processing/index.md index 4acffe7615..bad62678a5 100644 --- a/docs/content/documentation/content/image-processing/index.md +++ b/docs/content/documentation/content/image-processing/index.md @@ -34,6 +34,7 @@ resize_image(path, width, height, op, format, quality) - `"jpg"` - `"png"` - `"webp"` + - `"jxl"` The default is `"auto"`, this means that the format is chosen based on input image format. JPEG is chosen for JPEGs and other lossy formats, and PNG is chosen for PNGs and other lossless formats.