Skip to content

Commit 3eda012

Browse files
committed
Add JPEG XL support to image processing.
As discussed in #2421.
1 parent a4f123d commit 3eda012

File tree

9 files changed

+210
-12
lines changed

9 files changed

+210
-12
lines changed

Diff for: Cargo.lock

+115
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: components/imageproc/src/format.rs

+8-2
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ const DEFAULT_Q_JPG: u8 = 75;
66
/// Thumbnail image format
77
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88
pub enum Format {
9-
/// JPEG, The `u8` argument is JPEG quality (in percent).
9+
/// JPEG, The `u8` argument is JPEG quality (1..100).
1010
Jpeg(u8),
1111
/// PNG
1212
Png,
13-
/// WebP, The `u8` argument is WebP quality (in percent), None meaning lossless.
13+
/// WebP, The `u8` argument is WebP quality (1..100), None meaning lossless.
1414
WebP(Option<u8>),
15+
/// JPEG XL, The `u8` argument is quality (1..100), None meaning lossless.
16+
JXL(Option<u8>),
1517
}
1618

1719
impl Format {
@@ -32,6 +34,7 @@ impl Format {
3234
"jpeg" | "jpg" => Ok(Jpeg(jpg_quality)),
3335
"png" => Ok(Png),
3436
"webp" => Ok(WebP(quality)),
37+
"jxl" => Ok(JXL(quality)),
3538
_ => Err(anyhow!("Invalid image format: {}", format)),
3639
}
3740
}
@@ -44,6 +47,7 @@ impl Format {
4447
Png => "png",
4548
Jpeg(_) => "jpg",
4649
WebP(_) => "webp",
50+
JXL(_) => "jxl",
4751
}
4852
}
4953
}
@@ -58,6 +62,8 @@ impl Hash for Format {
5862
Jpeg(q) => 1001 + q as u16,
5963
WebP(None) => 2000,
6064
WebP(Some(q)) => 2001 + q as u16,
65+
JXL(None) => 3000,
66+
JXL(Some(q)) => 3001 + q as u16,
6167
};
6268

6369
hasher.write_u16(q);

Diff for: components/imageproc/src/meta.rs

+25-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use errors::{anyhow, Context, Result};
22
use libs::image::io::Reader as ImgReader;
3-
use libs::image::{ImageFormat, ImageResult};
3+
use libs::image::ImageFormat;
4+
use libs::jpegxl_rs::decoder_builder;
45
use libs::svg_metadata::Metadata as SvgMetadata;
56
use serde::Serialize;
67
use std::ffi::OsStr;
@@ -15,12 +16,23 @@ pub struct ImageMeta {
1516
}
1617

1718
impl ImageMeta {
18-
pub fn read(path: &Path) -> ImageResult<Self> {
19-
let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?;
20-
let format = reader.format();
21-
let size = reader.into_dimensions()?;
19+
pub fn read(path: &Path) -> Result<Self> {
20+
if path.extension().is_some_and(|ext| ext == "jxl") {
21+
Self::read_jxl(path)
22+
} else {
23+
let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?;
24+
let format = reader.format();
25+
let size = reader.into_dimensions()?;
2226

23-
Ok(Self { size, format })
27+
Ok(Self { size, format })
28+
}
29+
}
30+
31+
fn read_jxl(path: &Path) -> Result<Self> {
32+
let input = std::fs::read(path)?;
33+
let decoder = decoder_builder().build()?;
34+
let (meta, _) = decoder.decode(&input)?;
35+
Ok(ImageMeta { size: (meta.width, meta.height), format: None })
2436
}
2537

2638
pub fn is_lossy(&self) -> bool {
@@ -44,6 +56,9 @@ impl ImageMetaResponse {
4456
pub fn new_svg(width: u32, height: u32) -> Self {
4557
Self { width, height, format: Some("svg"), mime: Some("text/svg+xml") }
4658
}
59+
pub fn new_jxl(width: u32, height: u32) -> Self {
60+
Self { width, height, format: Some("jxl"), mime: Some("image/jxl") }
61+
}
4762
}
4863

4964
impl From<ImageMeta> for ImageMetaResponse {
@@ -75,6 +90,10 @@ pub fn read_image_metadata<P: AsRef<Path>>(path: P) -> Result<ImageMetaResponse>
7590
// this is not a typo, this returns the correct values for width and height.
7691
.map(|(h, w)| ImageMetaResponse::new_svg(w as u32, h as u32))
7792
}
93+
"jxl" => {
94+
let meta = ImageMeta::read(path)?;
95+
Ok(ImageMetaResponse::new_jxl(meta.size.0, meta.size.1))
96+
}
7897
_ => ImageMeta::read(path).map(ImageMetaResponse::from).with_context(err_context),
7998
}
8099
}

Diff for: components/imageproc/src/processor.rs

+49-4
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ use std::io::{BufWriter, Write};
44
use std::path::{Path, PathBuf};
55

66
use config::Config;
7-
use errors::{anyhow, Context, Result};
7+
use errors::{anyhow, bail, Context, Result};
88
use libs::ahash::{HashMap, HashSet};
99
use libs::image::codecs::jpeg::JpegEncoder;
1010
use libs::image::imageops::FilterType;
11-
use libs::image::{EncodableLayout, ImageFormat};
11+
use libs::image::{ColorType, EncodableLayout, ImageFormat};
12+
use libs::jpegxl_rs::decoder_builder;
13+
use libs::jpegxl_rs::encode::EncoderFrame;
14+
use libs::jpegxl_rs::image::ToDynamic;
1215
use libs::rayon::prelude::*;
13-
use libs::{image, webp};
16+
use libs::{image, jpegxl_rs, webp};
1417
use serde::{Deserialize, Serialize};
1518
use utils::fs as ufs;
1619

@@ -39,7 +42,15 @@ impl ImageOp {
3942
return Ok(());
4043
}
4144

42-
let img = image::open(&self.input_path)?;
45+
let img = if self.input_path.extension().is_some_and(|ext| ext == "jxl") {
46+
let input = std::fs::read(&self.input_path)?;
47+
let decoder = decoder_builder().build()?;
48+
decoder
49+
.decode_to_image(&input)?
50+
.context("jxl image could not be represented in an Image")?
51+
} else {
52+
image::open(&self.input_path)?
53+
};
4354
let mut img = fix_orientation(&img, &self.input_path).unwrap_or(img);
4455

4556
let img = match self.instr.crop_instruction {
@@ -71,6 +82,40 @@ impl ImageOp {
7182
};
7283
buffered_f.write_all(memory.as_bytes())?;
7384
}
85+
Format::JXL(q) => {
86+
let mut encoder = jpegxl_rs::encoder_builder();
87+
if let Some(q) = q {
88+
if q == 100 {
89+
encoder.uses_original_profile(true);
90+
encoder.lossless(true);
91+
} else {
92+
encoder.set_jpeg_quality(q as f32);
93+
}
94+
} else {
95+
encoder.uses_original_profile(true);
96+
encoder.lossless(true);
97+
}
98+
let frame = EncoderFrame::new(img.as_bytes());
99+
let frame = match img.color() {
100+
ColorType::L8 => frame.num_channels(1),
101+
ColorType::La8 => {
102+
encoder.has_alpha(true);
103+
frame.num_channels(2)
104+
}
105+
ColorType::Rgb8 => frame.num_channels(3),
106+
ColorType::Rgba8 => {
107+
encoder.has_alpha(true);
108+
frame.num_channels(4)
109+
}
110+
_ => {
111+
bail!("Unsupported pixel type {:?}", img.color());
112+
}
113+
};
114+
let mut encoder = encoder.build()?;
115+
buffered_f.write_all(
116+
&encoder.encode_frame::<u8, u8>(&frame, img.width(), img.height())?.data,
117+
)?;
118+
}
74119
}
75120

76121
Ok(())

Diff for: components/imageproc/tests/resize_image.rs

+10
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ fn resize_image_webp_jpg() {
132132
image_op_test("webp.webp", "scale", Some(150), Some(150), "auto", "jpg", 150, 150, 300, 380);
133133
}
134134

135+
#[test]
136+
fn resize_image_png_jxl() {
137+
image_op_test("png.png", "scale", Some(150), Some(150), "jxl", "jxl", 150, 150, 300, 380);
138+
}
139+
140+
#[test]
141+
fn resize_image_jxl_png() {
142+
image_op_test("jxl.jxl", "scale", Some(150), Some(150), "png", "png", 150, 150, 300, 380);
143+
}
144+
135145
#[test]
136146
fn read_image_metadata_jpg() {
137147
assert_eq!(

Diff for: components/imageproc/tests/test_imgs/jxl.jxl

21.5 KB
Binary file not shown.

Diff for: components/libs/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ unicode-segmentation = "1.2"
4444
url = "2"
4545
walkdir = "2"
4646
webp = "0.3"
47+
jpegxl-rs = { version = "0.10.3", features = ["vendored"] }
4748

4849

4950
[features]

Diff for: components/libs/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub use glob;
1616
pub use globset;
1717
pub use grass;
1818
pub use image;
19+
pub use jpegxl_rs;
1920
pub use lexical_sort;
2021
pub use minify_html;
2122
pub use nom_bibtex;

Diff for: docs/content/documentation/content/image-processing/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ resize_image(path, width, height, op, format, quality)
3434
- `"jpg"`
3535
- `"png"`
3636
- `"webp"`
37+
- `"jxl"`
3738

3839
The default is `"auto"`, this means that the format is chosen based on input image format.
3940
JPEG is chosen for JPEGs and other lossy formats, and PNG is chosen for PNGs and other lossless formats.

0 commit comments

Comments
 (0)