File formats: JPEG XL (.jxl) images open and place

File › Open and File › Place refused a JPEG XL file: Open said it was
not a format VectorCraft reads, and the dialogs did not list .jxl.

A read-only JPEG XL format joins the format table. Open, Place, drops,
copied files and Glass's Load Texture tell it by its first bytes (a bare
codestream or the ISOBMFF container). jxl-oxide 0.12.6 (pure Rust, MIT
OR Apache-2.0, without its default rayon feature) decodes the first
frame, which is stored as a PNG. Orientation is applied, and 16-bit
and floating-point samples become 8 bits. Colors are converted to sRGB
in XYB-encoded images and when the header names the color space or the
ICC profile describes one jxl-oxide knows; other images with an ICC
profile keep their samples. Premultiplied colors are divided by alpha
before that conversion. CMYK files are refused.

The decoder gets only a container's image boxes. It reads the header
first, and an image past MAX_RASTER_ALLOC gets the existing "too large
to draw" error before any pixels are decoded. Its image buffers are
capped at 16 times the image's RGBA size plus 16 MiB, and at most
MAX_RASTER_ALLOC. VectorCraft doesn't write JPEG XL yet.

Docs: docs/mcp.md, docs/file-format-parity.md, docs/target-app-parity.md,
README.
This commit is contained in:
dotCooCoo
2026-10-16 19:52:26 -07:00
parent 8bc6532928
commit d202910faa
30 changed files with 739 additions and 11 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Image into Clipboard",
[],
None,
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste",
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD, PSB or JPEG XL bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste",
has_doc,
import_image
),
+222
View File
@@ -0,0 +1,222 @@
//! Reading JPEG XL images (`.jxl`, ISO/IEC 18181) for File › Open and File › Place, with the
//! jxl-oxide decoder: the bare codestream and the ISOBMFF container. The first frame of an
//! animation is read, with the orientation its header gives applied.
//!
//! Colors come back as sRGB whenever the decoder converts them without a color management
//! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC
//! profiles that describe one of them. Any other image with an embedded ICC profile comes back
//! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn. 16-bit and floating-point samples become 8 bits, alpha comes back straight,
//! and CMYK images are refused. The file is untrusted: the decoder is given only a container's
//! image boxes, the image's size is checked from its header before any pixels are decoded, and the
//! decoder's image buffers are capped.
use jxl_color::ColorTransform;
use jxl_oxide::color::{ColourEncoding, TransferFunction};
use jxl_oxide::{AllocTracker, ColorEncodingWithProfile, EnumColourEncoding, HdrType, InitializeResult, JxlImage, NullCms, RenderingIntent};
/// A bare JPEG XL codestream starts with these bytes.
const CODESTREAM: [u8; 2] = [0xff, 0x0a];
/// The signature box an ISOBMFF JPEG XL container starts with.
const CONTAINER: [u8; 12] = [0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
/// How many bytes the decoder is given first to read the header. Each later try gives it twice as
/// many bytes, and each try decodes the embedded ICC profile from its start again.
const HEAD: usize = 4096;
/// The most the decoder's buffers may take while it reads the header. Its `AllocTracker` counts the
/// image buffers only: an embedded ICC profile is decoded into buffers of its own, which jxl-color
/// limits to 256 MiB.
const HEADER_BUFFERS: usize = 16 << 20;
/// The most the decoder's buffers may take for the pixels: this many times the image's size as
/// RGBA, plus [`HEADER_BUFFERS`], and never more than the `max_alloc` of [`decode`], the limit the
/// `image` crate's decoders get in `raster_image`. The decoder keeps each channel as 32-bit
/// samples: a lossless 16-bit RGBA image of 1024 × 768 pixels took 12 times its size as RGBA.
const DECODER_BUFFERS: u64 = 16;
const TRUNCATED: &str = "the JPEG XL file is cut short";
/// Is `bytes` a JPEG XL image (a bare codestream or a container)?
pub fn is_jxl(bytes: &[u8]) -> bool {
bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER)
}
/// The first frame of a JPEG XL image as straight RGBA. Fails, without decoding the pixels, when
/// the image would take more than `max_alloc` bytes as RGBA.
pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<image::RgbaImage, String> {
let pixel_boxes = pixel_boxes(bytes);
let bytes = pixel_boxes.as_deref().unwrap_or(bytes);
// The decoder reads the header first, with buffers for the header alone.
let (head, _) = header(bytes, HEADER_BUFFERS)?;
let (w, h) = (head.width(), head.height());
let rgba = u64::from(w) * u64::from(h) * 4;
if rgba > max_alloc {
return Err(format!("an image of {w} × {h} pixels is too large to draw"));
}
if head.pixel_format().has_black() {
return Err("CMYK JPEG XL images can't be read".into());
}
// Then it reads the file again until the first frame is loaded, with buffers for the pixels.
let budget = rgba.saturating_mul(DECODER_BUFFERS).saturating_add(HEADER_BUFFERS as u64).min(max_alloc);
let budget = usize::try_from(budget).unwrap_or(usize::MAX);
let (mut image, mut at) = header(bytes, budget)?;
let mut end = at;
while image.num_loaded_keyframes() == 0 {
if end == bytes.len() {
return Err(TRUNCATED.into());
}
end = end.saturating_mul(2).max(HEAD).min(bytes.len());
at += image.feed_bytes(&bytes[at..end]).map_err(unreadable)?;
}
let metadata = &image.image_header().metadata;
let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true);
let intent = RenderingIntent::Relative;
let srgb = if metadata.grayscale() { EnumColourEncoding::gray_srgb(intent) } else { EnumColourEncoding::srgb(intent) };
// Premultiplied colors are divided by alpha in the encoding they are stored in, and converted
// to sRGB after that. XYB-encoded images come from the decoder in linear sRGB (tone mapped when
// they are HDR), which the sRGB transfer curve alone turns into sRGB.
let (request, convert) = match (to_srgb_without_cms(metadata.xyb_encoded, image.original_icc()), associated) {
(false, _) => (None, None),
(true, false) => (Some(srgb), None),
(true, true) if metadata.xyb_encoded => (Some(EnumColourEncoding { tf: TransferFunction::Linear, ..srgb }), Some(ToSrgb::Curve)),
(true, true) => {
let stored = match (&metadata.colour_encoding, image.original_icc()) {
(ColourEncoding::Enum(e), _) => ColorEncodingWithProfile::new(e.clone()),
(_, Some(icc)) => ColorEncodingWithProfile::with_icc(icc).map_err(unreadable)?,
(_, None) => return Err(unreadable("no color encoding")),
};
let mut builder = ColorTransform::builder();
builder.from_pq(image.hdr_type() == Some(HdrType::Pq));
let to = ColorEncodingWithProfile::new(srgb);
let t = builder.build(&stored, &to, &metadata.opsin_inverse_matrix, &metadata.tone_mapping, &NullCms).map_err(unreadable)?;
(None, Some(ToSrgb::Transform(t)))
}
};
if let Some(encoding) = request {
image.request_color_encoding(encoding);
}
let render = image.render_frame(0).map_err(unreadable)?;
let mut stream = render.stream();
let channels = stream.channels() as usize;
if !(1..=4).contains(&channels) {
return Err(unreadable(format!("{channels} channels")));
}
let mut out = image::RgbaImage::new(stream.width(), stream.height());
let width = out.width() as usize;
let mut row = vec![0f32; width * channels];
let mut colors = [vec![0f32; width], vec![0f32; width], vec![0f32; width]];
for line in out.chunks_exact_mut((width * 4).max(4)) {
if stream.write_to_buffer(&mut row) != row.len() {
return Err(TRUNCATED.into());
}
to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?;
}
Ok(out)
}
/// How colors whose alpha was divided out become sRGB.
pub(crate) enum ToSrgb {
/// A conversion from the encoding the colors are stored in.
Transform(ColorTransform),
/// The sRGB transfer curve, for colors in linear sRGB.
Curve,
}
/// One row of samples, `channels` to a pixel (gray or RGB, then alpha when there are 2 or 4), as
/// straight RGBA bytes in `out`: colors divided by alpha when it is `associated`, then converted
/// with `convert`. `colors` holds a row of red, green and blue; a gray sample goes into all three,
/// as jxl-render's `clone_gray` does before its own conversions.
pub(crate) fn to_rgba(
row: &[f32],
channels: usize,
associated: bool,
convert: Option<&ToSrgb>,
colors: &mut [Vec<f32>; 3],
out: &mut [u8],
) -> Result<(), String> {
let alpha = |s: &[f32]| if matches!(channels, 2 | 4) { s.last().map_or(1.0, |a| a.clamp(0.0, 1.0)) } else { 1.0 };
let gray = channels < 3;
for (x, s) in row.chunks_exact(channels).enumerate() {
let a = alpha(s);
for (i, plane) in colors.iter_mut().enumerate() {
let v = s.get(if gray { 0 } else { i }).copied().unwrap_or(0.0);
if let Some(c) = plane.get_mut(x) {
*c = if associated && a > 0.0 { v / a } else { v };
}
}
}
// The conversion leaves gray in the first row when it gives one channel.
let gray = match convert {
Some(ToSrgb::Transform(t)) => {
let mut planes: Vec<&mut [f32]> = colors.iter_mut().map(Vec::as_mut_slice).collect();
t.run(&mut planes).map_err(unreadable)? < 3
}
Some(ToSrgb::Curve) => {
colors.iter_mut().flatten().for_each(|v| *v = if *v <= 0.003_130_8 { *v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 });
gray
}
None => gray,
};
for (x, (px, s)) in out.as_chunks_mut::<4>().0.iter_mut().zip(row.chunks_exact(channels)).enumerate() {
let c = |i: usize| colors.get(if gray { 0 } else { i }).and_then(|p| p.get(x)).copied().unwrap_or(0.0);
*px = [byte(c(0)), byte(c(1)), byte(c(2)), byte(alpha(s))];
}
Ok(())
}
/// A container's signature and the boxes the decoder reads pixels from (`ftyp`, `jxll`, `jxlc` and
/// `jxlp`). Every other box is left out: Exif, XMP, JPEG reconstruction data and Brotli-compressed
/// `brob` boxes, which the decoder would otherwise store and unpack without a size limit. A box
/// cut short ends the file. `None` for a bare codestream.
fn pixel_boxes(bytes: &[u8]) -> Option<Vec<u8>> {
let mut rest = bytes.strip_prefix(&CONTAINER)?;
let mut out = CONTAINER.to_vec();
while let (Some(size), Some(ty)) = (rest.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()), rest.get(4..8)) {
let (head, len) = match u32::from_be_bytes(size) {
0 => (8, rest.len() as u64),
1 => match rest.get(8..16).and_then(|l| l.try_into().ok()) {
Some(l) => (16, u64::from_be_bytes(l)),
None => break,
},
n => (8, u64::from(n)),
};
if len < head {
break;
}
let len = usize::try_from(len).unwrap_or(usize::MAX).min(rest.len());
let (b, after) = rest.split_at(len);
if matches!(ty, b"ftyp" | b"jxll" | b"jxlc" | b"jxlp") {
out.extend_from_slice(b);
}
rest = after;
}
Some(out)
}
/// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read
/// the header → (the decoder, how many bytes it took).
fn header(bytes: &[u8], buffers: usize) -> Result<(JxlImage, usize), String> {
let mut uninit = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(buffers)).build_uninit();
let (mut at, mut end) = (0, 0usize);
loop {
end = end.saturating_mul(2).max(HEAD).min(bytes.len());
at += uninit.feed_bytes(&bytes[at..end]).map_err(unreadable)?;
match uninit.try_init().map_err(unreadable)? {
InitializeResult::Initialized(image) => return Ok((image, at)),
InitializeResult::NeedMoreData(_) if end == bytes.len() => return Err(TRUNCATED.into()),
InitializeResult::NeedMoreData(more) => uninit = more,
}
}
}
/// Does the decoder convert to sRGB without a color management system? It does for XYB-encoded
/// images and the color spaces a header names; for an embedded ICC profile, only when the profile
/// describes one of those.
fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool {
xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty()))
}
fn unreadable(e: impl std::fmt::Display) -> String {
format!("the JPEG XL file can't be read: {e}")
}
fn byte(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
+9 -2
View File
@@ -9,7 +9,7 @@ use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, jxlread, psdread, read_file};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -93,6 +93,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if psdread::is_psd(bytes) {
return format(if psdread::is_psb(bytes) { "psb" } else { "psd" });
}
if jxlread::is_jxl(bytes) {
return format("jxl");
}
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f);
}
@@ -451,13 +454,17 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or
/// other extra channels, which the decoder can't read either, become RGBA too.
/// other extra channels, which the decoder can't read either, become RGBA too. JPEG XL images are
/// read with [`jxlread`] and stored as PNG.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if psdread::is_psd(bytes) {
let cms = vectorcraft_color::cms::active();
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
}
if jxlread::is_jxl(bytes) {
return as_png(jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
+10 -2
View File
@@ -30,6 +30,7 @@ mod encode;
pub mod eps;
mod export;
pub(crate) mod imagemap;
pub mod jxlread;
mod legacy_ai;
mod load;
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
@@ -641,6 +642,8 @@ pub const FORMATS: &[Format] = &[
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
// Photoshop's large document format: read like a PSD (its merged image), never written.
reader("psb", "PSB", &["psb"], "image/x-psb", true),
// jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it.
reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -662,6 +665,7 @@ pub const OPEN_EXTS: &[&str] = &[
"bmp",
"psd",
"psb",
"jxl",
"vctemplate",
"dxf",
"emf",
@@ -674,8 +678,9 @@ pub const OPEN_EXTS: &[&str] = &[
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
/// them (File › Open and Place still read them).
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"];
/// them. The macOS bundle doesn't declare JPEG XL images either. File › Open and Place read them
/// all.
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "jxl"];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
@@ -711,6 +716,7 @@ pub const PLACE_EXTS: &[&str] = &[
"bmp",
"psd",
"psb",
"jxl",
"vctemplate",
"dxf",
"emf",
@@ -1205,6 +1211,8 @@ mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_jxlread;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
@@ -0,0 +1,213 @@
//! Reading JPEG XL images: the bare codestream and the container, alpha, 16-bit samples, the size
//! check before decoding, and damaged files refused without a panic.
use serde_json::json;
use zune_core::bit_depth::BitDepth;
use zune_core::colorspace::ColorSpace;
use zune_core::options::EncoderOptions;
use super::jxlread::{ToSrgb, decode, is_jxl, to_rgba};
use super::*;
/// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in
/// native byte order.
fn jxl(w: usize, h: usize, space: ColorSpace, depth: BitDepth, samples: &[u8]) -> Vec<u8> {
let mut out = vec![];
zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(w, h, space, depth)).encode(&mut out).unwrap();
out
}
/// `codestream` in the ISOBMFF container: the signature box, `ftyp` and a `jxlc` box holding it.
fn container(codestream: &[u8]) -> Vec<u8> {
let mut f = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
f.extend(20u32.to_be_bytes());
f.extend(b"ftypjxl \0\0\0\0jxl ");
f.extend((8 + codestream.len() as u32).to_be_bytes());
f.extend(b"jxlc");
f.extend(codestream);
f
}
/// A 3 × 2 RGBA image: opaque red, green and blue, then half-transparent, clear and opaque gray.
const RGBA: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [200, 100, 50, 128], [9, 9, 9, 0], [77, 77, 77, 255]];
fn rgba8() -> Vec<u8> {
jxl(3, 2, ColorSpace::RGBA, BitDepth::Eight, RGBA.as_flattened())
}
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// The pixels of the one image the active document holds.
fn stored_pixels(s: &Session) -> Vec<[u8; 4]> {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.images.len(), 1);
let blob = doc.images.values().next().unwrap();
assert_eq!(blob.mime, "image/png", "stored as PNG");
image::load_from_memory(&blob.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
#[test]
fn jxl_images_open_and_place_with_their_pixels_and_alpha() {
let bare = rgba8();
let boxed = container(&bare);
assert!(is_jxl(&bare) && is_jxl(&boxed) && !is_jxl(b"\xff\xd8\xff"));
for (name, bytes) in [("bare.jxl", &bare), ("boxed.jxl", &boxed), ("named-wrong.png", &boxed)] {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["format"], "jxl", "{name}: told by its content");
let ab = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), (3.0, 2.0), "{name}: 72 ppi, one point a pixel");
assert_eq!(stored_pixels(&s), RGBA, "{name}");
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(3.0), Some(2.0)), "{name}: {r}");
assert_eq!(stored_pixels(&s), RGBA, "{name}: placed");
}
assert!(OPEN_EXTS.contains(&"jxl") && PLACE_EXTS.contains(&"jxl"));
assert!(open_filters().any(|f| f == ("JPEG XL", &["jxl"][..])) && place_filters().any(|f| f == ("JPEG XL", &["jxl"][..])));
}
/// A `.jxl` file copied in a file manager pastes as its image.
#[test]
fn a_copied_jxl_file_pastes_as_its_image() {
use crate::cmd::clipboard::{FILE_HEAD, PASTE_ORDER, file_flavour};
let f = container(&rgba8());
let mime = file_flavour("copied.jxl", &f[..f.len().min(FILE_HEAD)], &PASTE_ORDER);
assert_eq!(mime, Some("image/jxl"));
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("clipboard.importImage", &json!({"dataBase64": b64(&f), "mime": mime})).unwrap();
assert_eq!((r["pixelWidth"].as_u64(), r["pixelHeight"].as_u64()), (Some(3), Some(2)), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(stored_pixels(&s), RGBA);
}
#[test]
fn sixteen_bit_samples_become_eight_bits() {
let rgb: [[u16; 3]; 4] = [[0x0000, 0x8080, 0xffff], [0x1234, 0x80ff, 0x7f00], [0xff00, 0x00ff, 0x4000], [0xc000, 0x0101, 0xfefe]];
let samples: Vec<u8> = rgb.as_flattened().iter().flat_map(|v| v.to_ne_bytes()).collect();
let f = jxl(2, 2, ColorSpace::RGB, BitDepth::Sixteen, &samples);
let to8 = |v: u16| (f64::from(v) / 65535.0 * 255.0).round() as u8;
let want: Vec<[u8; 4]> = rgb.iter().map(|p| [to8(p[0]), to8(p[1]), to8(p[2]), 255]).collect();
assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::<Vec<_>>(), want);
// Gray, 16 bits, in the container, opened as a document.
let gray: Vec<u8> = [0xffffu16, 0x8080, 0x0000, 0x4040].iter().flat_map(|v| v.to_ne_bytes()).collect();
let f = container(&jxl(2, 2, ColorSpace::Luma, BitDepth::Sixteen, &gray));
let mut s = Session::new();
s.execute("document.open", &json!({"name": "gray.jxl", "dataBase64": b64(&f)})).unwrap();
assert_eq!(stored_pixels(&s), [[255, 255, 255, 255], [128, 128, 128, 255], [0, 0, 0, 255], [64, 64, 64, 255]]);
}
/// A bare codestream that is only a header: `w` × `h` pixels and default metadata.
fn header_only(w: u32, h: u32) -> Vec<u8> {
// Each field's bits, read from the lowest bit of each byte up: `div8`, the height as a U32
// (selector 3: 1 + 30 bits), `ratio` 0, the width likewise, then `all_default` for the
// metadata and `default_m` for the transform data.
let fields = [(0, 1), (3, 2), (h - 1, 30), (0, 3), (3, 2), (w - 1, 30), (1, 1), (1, 1)];
let (mut out, mut acc, mut n) = (vec![0xff, 0x0a], 0u64, 0);
for (v, bits) in fields {
acc |= u64::from(v) << n;
n += bits;
while n >= 8 {
out.push(acc as u8);
(acc, n) = (acc >> 8, n - 8);
}
}
out.push(acc as u8);
out
}
#[test]
fn an_image_too_large_is_refused_from_its_header() {
// The header of a 100000 × 100000 image (40 GB as RGBA), with no pixel data after it.
let huge = header_only(100_000, 100_000);
assert_eq!(decode(&huge, MAX_RASTER_ALLOC).unwrap_err(), "an image of 100000 × 100000 pixels is too large to draw");
let mut s = Session::new();
let e = s.execute("document.open", &json!({"name": "huge.jxl", "dataBase64": b64(&huge)})).unwrap_err().to_string();
assert!(e.contains("too large to draw"), "{e}");
// The allowance counts RGBA bytes: 3 × 2 pixels take 24 (the decoder's own buffers then need more).
assert!(decode(&rgba8(), 23).unwrap_err().contains("too large"));
assert!(!decode(&rgba8(), 24).unwrap_err().contains("too large"));
assert!(decode(&rgba8(), 1 << 20).is_ok());
}
/// Premultiplied colors are divided by their alpha before they are converted to sRGB: a linear
/// 0.25 at alpha 0.5 is a straight linear 0.5, which sRGB stores as 0.735 (188 of 255).
#[test]
fn associated_alpha_is_divided_out_before_the_color_conversion() {
use jxl_oxide::{ColorEncodingWithProfile, EnumColourEncoding, NullCms, RenderingIntent};
let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative);
let linear = EnumColourEncoding { tf: jxl_oxide::color::TransferFunction::Linear, ..srgb.clone() };
let header = jxl_oxide::JxlImage::builder().read(&rgba8()[..]).unwrap();
let m = &header.image_header().metadata;
let to_srgb = ToSrgb::Transform(
jxl_color::ColorTransform::new(
&ColorEncodingWithProfile::new(linear),
&ColorEncodingWithProfile::new(srgb),
&m.opsin_inverse_matrix,
&m.tone_mapping,
&NullCms,
)
.unwrap(),
);
let mut colors = [vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]];
let mut out = [0u8; 8];
for convert in [&to_srgb, &ToSrgb::Curve] {
to_rgba(&[0.25, 0.25, 0.25, 0.5, 0.0, 0.0, 0.0, 0.0], 4, true, Some(convert), &mut colors, &mut out).unwrap();
assert_eq!(out, [188, 188, 188, 128, 0, 0, 0, 0]);
// Straight alpha is left as it is.
to_rgba(&[0.25, 0.25, 0.25, 0.5, 1.0, 1.0, 1.0, 1.0], 4, false, Some(convert), &mut colors, &mut out).unwrap();
assert_eq!(out, [137, 137, 137, 128, 255, 255, 255, 255]);
}
// Gray with another white point converts through three channels and comes back as gray.
let gray_e = EnumColourEncoding {
white_point: jxl_oxide::color::WhitePoint::E,
tf: jxl_oxide::color::TransferFunction::Linear,
..EnumColourEncoding::gray_srgb(RenderingIntent::Relative)
};
let gray_srgb = ColorEncodingWithProfile::new(EnumColourEncoding::gray_srgb(RenderingIntent::Relative));
let to_gray =
jxl_color::ColorTransform::new(&ColorEncodingWithProfile::new(gray_e), &gray_srgb, &m.opsin_inverse_matrix, &m.tone_mapping, &NullCms)
.unwrap();
to_rgba(&[0.25, 0.5, 1.0, 1.0], 2, true, Some(&ToSrgb::Transform(to_gray)), &mut colors, &mut out).unwrap();
assert_eq!(out, [188, 188, 188, 128, 255, 255, 255, 255]);
}
/// A container's Exif and Brotli-compressed boxes are left out: an unreadable `brob` box before the
/// codestream doesn't stop the image from opening.
#[test]
fn metadata_boxes_are_not_read() {
let mut f = container(&[]);
f.truncate(32); // the signature and `ftyp`
let mut boxed = |ty: &[u8], body: &[u8]| {
f.extend((8 + body.len() as u32).to_be_bytes());
f.extend(ty);
f.extend(body);
};
boxed(b"Exif", &[0, 0, 0, 0, b'M', b'M', 0, 42]);
boxed(b"brob", &[b'x', b'm', b'l', b' ', 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
boxed(b"jxlc", &rgba8());
assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::<Vec<_>>(), RGBA);
}
#[test]
fn damaged_files_are_refused_without_a_panic() {
let good = rgba8();
for n in 0..good.len() {
assert!(decode(&good[..n], 1 << 30).is_err(), "cut at {n}");
}
// Every byte changed in turn: an error or an image, never a panic.
for i in 0..good.len() {
for v in [0, 1, 0x7f, 0x80, 0xff] {
let mut f = good.clone();
f[i] = v;
let _ = decode(&f, 1 << 30);
}
}
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let cut = &good[..good.len() - 3];
assert!(s.execute("file.place", &json!({"name": "cut.jxl", "dataBase64": b64(cut)})).is_err());
}