From d202910faafd23a593d85c896d78459fb6ee227e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:45:07 -0700 Subject: [PATCH 1/9] File formats: JPEG XL (.jxl) images open and place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 189 +++++++++++++++ Cargo.toml | 8 + README.md | 2 +- crates/engine/Cargo.toml | 5 + crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/engine/src/cmd/fileio/jxlread.rs | 222 ++++++++++++++++++ crates/engine/src/cmd/fileio/load.rs | 11 +- crates/engine/src/cmd/fileio/mod.rs | 12 +- crates/engine/src/cmd/fileio/tests_jxlread.rs | 213 +++++++++++++++++ crates/engine/tests/import_fuzz.rs | 20 +- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + docs/file-format-parity.md | 1 + docs/mcp.md | 11 +- docs/target-app-parity.md | 3 +- 30 files changed, 739 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/jxlread.rs create mode 100644 crates/engine/src/cmd/fileio/tests_jxlread.rs diff --git a/Cargo.lock b/Cargo.lock index eca47581b..9db51aec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2179,6 +2179,182 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jxl-bitstream" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -4653,6 +4829,8 @@ dependencies = [ "crc32fast", "flate2", "image", + "jxl-color", + "jxl-oxide", "kurbo 0.13.1", "log", "proptest", @@ -4680,6 +4858,8 @@ dependencies = [ "vectorcraft-tools", "vectorcraft-trace", "wat", + "zune-core", + "zune-jpegxl", ] [[package]] @@ -6333,6 +6513,15 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zune-jpegxl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/Cargo.toml b/Cargo.toml index 1c10d551b..0d83e9e8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,14 @@ gif = { version = "0.14", default-features = false, features = ["std", "raii_no_ # decoders, read directly (the image crate converts CMYK to RGB). tiff = "0.11" zune-jpeg = "0.5" +# JPEG XL decoder (pure Rust, MIT OR Apache-2.0): File › Open and Place read `.jxl` images. It is +# built without its default `rayon` feature and decodes on one thread. +jxl-oxide = { version = "0.12.6", default-features = false } +# jxl-oxide's color conversions, run on colors after their alpha is divided out. +jxl-color = "0.11.0" +# Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files. +zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] } +zune-core = "0.5" # ui egui = "0.36" diff --git a/README.md b/README.md index 36fe11fe9..79537ee6b 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu all of Illustrator): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c51728ef2..a9139af81 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,8 @@ vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } +jxl-oxide = { workspace = true } +jxl-color = { workspace = true } flate2 = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } @@ -49,3 +51,6 @@ proptest = { workspace = true } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } +# JPEG XL files for the `.jxl` open and place tests. +zune-jpegxl = { workspace = true } +zune-core = { workspace = true } diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 6a17e285b..d5f5cfaeb 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs new file mode 100644 index 000000000..1826c43df --- /dev/null +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -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 { + 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; 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> { + 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 +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 58200162c..a959ecbf2 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -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 { /// 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 { 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); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 93282d33d..868d7d8f0 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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)] diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs new file mode 100644 index 000000000..e4d3a1aad --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -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 { + 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 { + 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 { + 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 = 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::>(), want); + // Gray, 16 bits, in the container, opened as a document. + let gray: Vec = [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 { + // 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::>(), 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()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index a6553a997..8733ce684 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,5 +1,5 @@ //! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, -//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset +//! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, shortcut sets other apps save as text (`.kys`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records @@ -1253,6 +1253,22 @@ fn raster_samples() -> Vec<(&'static str, Vec)> { psb.extend((0..8).flat_map(|_| 2u32.to_be_bytes())); psb.extend((0..8u8).flat_map(|v| [0xfe, v * 30])); out.push(("a.psb", psb)); + // JPEG XL: an 8-bit RGBA codestream, and a 16-bit gray one in the container. + use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; + let jxl = |space, depth, samples: &[u8]| { + let mut b = vec![]; + zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(5, 3, space, depth)).encode(&mut b).unwrap(); + b + }; + out.push(("a.jxl", jxl(ColorSpace::RGBA, BitDepth::Eight, &(0..60).map(|i| (i * 17) as u8).collect::>()))); + let gray = jxl(ColorSpace::Luma, BitDepth::Sixteen, &(0..30).map(|i| (i * 41) as u8).collect::>()); + let mut boxed = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a]; + boxed.extend(20u32.to_be_bytes()); + boxed.extend(b"ftypjxl \0\0\0\0jxl "); + boxed.extend((8 + gray.len() as u32).to_be_bytes()); + boxed.extend(b"jxlc"); + boxed.extend(gray); + out.push(("b.jxl", boxed)); out } @@ -1262,7 +1278,7 @@ proptest! { /// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading /// their resolution or placing them. #[test] - fn mutated_images_place_without_panics(which in 0usize..6, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { + fn mutated_images_place_without_panics(which in 0usize..8, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { let (name, mut bytes) = raster_samples().swap_remove(which); for (at, b) in edits { if let Some(x) = bytes.get_mut(at) { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b289026ba..60b2c632b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4498,6 +4498,9 @@ imageTrace Outline Obrys @msg the PSD file is cut short soubor PSD je useknutý @msg the PSD file's header is damaged hlavička souboru PSD je poškozená @msg the PSD file's image data can't be read: {e} obrazová data souboru PSD nelze přečíst: {e} +@msg the JPEG XL file is cut short soubor JPEG XL je useknutý +@msg CMYK JPEG XL images can't be read obrázky JPEG XL v režimu CMYK nelze přečíst +@msg the JPEG XL file can't be read: {e} soubor JPEG XL nelze přečíst: {e} @msg Imported library {name} Importována knihovna {name} @msg Invalid panel layout; restored the default dock Neplatné rozložení panelů; obnoveno výchozí ukotvení @msg action is required action je povinné diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0dd943820..8ae8c8240 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4208,6 +4208,9 @@ theme Appearance Erscheinungsbild @msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel) @msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden +@msg the JPEG XL file is cut short die JPEG-XL-Datei ist unvollständig +@msg CMYK JPEG XL images can't be read JPEG-XL-Bilder im CMYK-Modus können nicht gelesen werden +@msg the JPEG XL file can't be read: {e} die JPEG-XL-Datei kann nicht gelesen werden: {e} Value Axis Werteachse On Left Side Links On Right Side Rechts diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 56703aea0..7ff10ce92 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4191,6 +4191,9 @@ theme Appearance Εμφάνιση @msg the PSD file is cut short το αρχείο PSD είναι περικομμένο @msg the PSD file's header is damaged η κεφαλίδα του αρχείου PSD είναι κατεστραμμένη @msg the PSD file's image data can't be read: {e} τα δεδομένα εικόνας PSD δεν μπορούν να διαβαστούν: {e} +@msg the JPEG XL file is cut short το αρχείο JPEG XL είναι περικομμένο +@msg CMYK JPEG XL images can't be read οι εικόνες JPEG XL σε CMYK δεν διαβάζονται +@msg the JPEG XL file can't be read: {e} το αρχείο JPEG XL δεν μπορεί να διαβαστεί: {e} Create New Fields by Δημιουργία νέων πεδίων κατά @msg Imported library {name} Εισήχθη η βιβλιοθήκη {name} RGB: RGB: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4c6b07cb3..9b0f3dc24 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4223,6 +4223,9 @@ axis Both Ambos @msg the PSD file is cut short el archivo PSD está incompleto @msg the PSD file's header is damaged el encabezado del archivo PSD está dañado @msg the PSD file's image data can't be read: {e} no se pueden leer los datos de imagen del archivo PSD: {e} +@msg the JPEG XL file is cut short el archivo JPEG XL está incompleto +@msg CMYK JPEG XL images can't be read las imágenes JPEG XL en CMYK no se pueden leer +@msg the JPEG XL file can't be read: {e} no se puede leer el archivo JPEG XL: {e} Video Video De-Interlace… Desentrelazar… De-Interlace Desentrelazar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2750d8a0..d7f85b8a7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4420,6 +4420,9 @@ imageTrace Outline Ääriviiva @msg the PSD file is cut short tämä PSD-tiedosto on katkennut @msg the PSD file's header is damaged tämän PSD-tiedoston otsake on vaurioitunut @msg the PSD file's image data can't be read: {e} tämän PSD-tiedoston kuvadataa ei voi lukea: {e} +@msg the JPEG XL file is cut short tämä JPEG XL -tiedosto on katkennut +@msg CMYK JPEG XL images can't be read CMYK-tilassa olevia JPEG XL -kuvia ei voi lukea +@msg the JPEG XL file can't be read: {e} tätä JPEG XL -tiedostoa ei voi lukea: {e} @msg Imported library {name} Tuotiin kirjasto {name} @msg Invalid panel layout; restored the default dock Virheellinen paneeliasettelu; oletustelakka palautettiin @msg action is required action vaaditaan diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 03146f17a..e7e5f7435 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4234,6 +4234,9 @@ axis Both Les deux @msg the PSD file is cut short le fichier PSD est tronqué @msg the PSD file's header is damaged l'en-tête du fichier PSD est endommagé @msg the PSD file's image data can't be read: {e} les données d'image du fichier PSD ne peuvent pas être lues : {e} +@msg the JPEG XL file is cut short le fichier JPEG XL est tronqué +@msg CMYK JPEG XL images can't be read les images JPEG XL en CMJN ne peuvent pas être lues +@msg the JPEG XL file can't be read: {e} le fichier JPEG XL ne peut pas être lu : {e} Video Vidéo De-Interlace… Désentrelacement… De-Interlace Désentrelacement diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 461070dcf..338e9fcee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4699,6 +4699,9 @@ imageTrace Outline Körvonal @msg the PSD file is cut short a PSD-fájl csonka @msg the PSD file's header is damaged a PSD-fájl fejléce sérült @msg the PSD file's image data can't be read: {e} a PSD-fájl képadatai nem olvashatók: {e} +@msg the JPEG XL file is cut short a JPEG XL-fájl csonka +@msg CMYK JPEG XL images can't be read CMYK módú JPEG XL-képek nem olvashatók +@msg the JPEG XL file can't be read: {e} a JPEG XL-fájl nem olvasható: {e} @msg the PSD file's image data uses an unknown compression ({m}) a PSD-fájl képadatai ismeretlen tömörítést használnak ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) a PSD-kép túl nagy a beolvasáshoz ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open a PDF mentése párbeszédablak nem nyílt meg diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 21cfcf18a..08fa20dfd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4226,6 +4226,9 @@ axis Both Entrambi @msg the PSD file is cut short il file PSD è troncato @msg the PSD file's header is damaged l'intestazione del file PSD è danneggiata @msg the PSD file's image data can't be read: {e} i dati immagine del file PSD non possono essere letti: {e} +@msg the JPEG XL file is cut short il file JPEG XL è troncato +@msg CMYK JPEG XL images can't be read le immagini JPEG XL in CMYK non possono essere lette +@msg the JPEG XL file can't be read: {e} il file JPEG XL non può essere letto: {e} Video Video De-Interlace… Deinterlaccia… De-Interlace Deinterlaccia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 525c112f4..c8b8d35bf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4265,6 +4265,9 @@ swatch Bright Violet ビビッドバイオレット @msg the PSD file is cut short PSD ファイルが途中で切れています @msg the PSD file's header is damaged PSD ファイルのヘッダーが破損しています @msg the PSD file's image data can't be read: {e} PSD ファイルの画像データを読み込めません:{e} +@msg the JPEG XL file is cut short JPEG XL ファイルが途中で切れています +@msg CMYK JPEG XL images can't be read CMYK の JPEG XL 画像は読み込めません +@msg the JPEG XL file can't be read: {e} JPEG XL ファイルを読み込めません:{e} Video ビデオ De-Interlace… インターレース解除… De-Interlace インターレース解除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6443cfcc4..141a6af9e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4197,6 +4197,9 @@ theme Appearance 외관 @msg the PSD file is cut short PSD 파일이 잘려 있습니다 @msg the PSD file's header is damaged PSD 파일의 헤더가 손상되었습니다 @msg the PSD file's image data can't be read: {e} PSD 파일의 이미지 데이터를 읽을 수 없습니다: {e} +@msg the JPEG XL file is cut short JPEG XL 파일이 잘려 있습니다 +@msg CMYK JPEG XL images can't be read CMYK JPEG XL 이미지는 읽을 수 없습니다 +@msg the JPEG XL file can't be read: {e} JPEG XL 파일을 읽을 수 없습니다: {e} Video 비디오 De-Interlace… 디인터레이스… De-Interlace 디인터레이스 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae9765d1d..c17a6bbb7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4316,6 +4316,9 @@ imageTrace Outline Kontur @msg the PSD file is cut short plik PSD jest ucięty @msg the PSD file's header is damaged nagłówek pliku PSD jest uszkodzony @msg the PSD file's image data can't be read: {e} nie można odczytać danych obrazu pliku PSD: {e} +@msg the JPEG XL file is cut short plik JPEG XL jest ucięty +@msg CMYK JPEG XL images can't be read nie można odczytać obrazów JPEG XL w trybie CMYK +@msg the JPEG XL file can't be read: {e} nie można odczytać pliku JPEG XL: {e} @msg Imported library {name} Zaimportowano bibliotekę {name} @msg Invalid panel layout; restored the default dock Nieprawidłowy układ paneli; przywrócono domyślny dok @msg action is required wymagane jest action diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 63e725edf..40cafa014 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3785,6 +3785,9 @@ axis Both Ambos @msg the PSD file is cut short o arquivo PSD está incompleto @msg the PSD file's header is damaged o cabeçalho do arquivo PSD está danificado @msg the PSD file's image data can't be read: {e} os dados de imagem do arquivo PSD não podem ser lidos: {e} +@msg the JPEG XL file is cut short o arquivo JPEG XL está incompleto +@msg CMYK JPEG XL images can't be read imagens JPEG XL em CMYK não podem ser lidas +@msg the JPEG XL file can't be read: {e} o arquivo JPEG XL não pode ser lido: {e} @msg Imported library {name} Biblioteca {name} importada Define a variable first Defina uma variável primeiro Text Texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ee7c6e5cd..381bffb8a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4224,6 +4224,9 @@ axis Both Оба @msg the PSD file is cut short файл PSD обрезан @msg the PSD file's header is damaged заголовок файла PSD повреждён @msg the PSD file's image data can't be read: {e} не удаётся прочитать данные изображения файла PSD: {e} +@msg the JPEG XL file is cut short файл JPEG XL обрезан +@msg CMYK JPEG XL images can't be read изображения JPEG XL в режиме CMYK не читаются +@msg the JPEG XL file can't be read: {e} не удаётся прочитать файл JPEG XL: {e} Video Видео De-Interlace… Устранение чересстрочности… De-Interlace Устранение чересстрочности diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb6ef9f23..b43da5db5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4159,6 +4159,9 @@ axis Both Båda @msg the PSD file is cut short PSD-filen är avkortad @msg the PSD file's header is damaged PSD-filens huvud är skadat @msg the PSD file's image data can't be read: {e} PSD-filens bilddata kan inte läsas: {e} +@msg the JPEG XL file is cut short JPEG XL-filen är avkortad +@msg CMYK JPEG XL images can't be read JPEG XL-bilder i CMYK kan inte läsas +@msg the JPEG XL file can't be read: {e} JPEG XL-filen kan inte läsas: {e} Video Video De-Interlace… Avfläta… De-Interlace Avfläta diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d80b7657e..80879db6a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4210,6 +4210,9 @@ axis Both Обидві @msg the PSD file is cut short файл PSD обрізаний @msg the PSD file's header is damaged заголовок файлу PSD пошкоджено @msg the PSD file's image data can't be read: {e} не вдається прочитати дані зображення файлу PSD: {e} +@msg the JPEG XL file is cut short файл JPEG XL обрізаний +@msg CMYK JPEG XL images can't be read зображення JPEG XL у режимі CMYK не читаються +@msg the JPEG XL file can't be read: {e} не вдається прочитати файл JPEG XL: {e} Video Відео De-Interlace… Усунення черезрядковості… De-Interlace Усунення черезрядковості diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 68faa3b09..c91ef3551 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4337,6 +4337,9 @@ theme Appearance Giao diện @msg the PSD file is cut short tệp PSD bị cắt ngắn @msg the PSD file's header is damaged tiêu đề tệp PSD bị hỏng @msg the PSD file's image data can't be read: {e} dữ liệu ảnh tệp PSD không thể đọc được: {e} +@msg the JPEG XL file is cut short tệp JPEG XL bị cắt ngắn +@msg CMYK JPEG XL images can't be read ảnh JPEG XL hệ màu CMYK không thể đọc được +@msg the JPEG XL file can't be read: {e} tệp JPEG XL không thể đọc được: {e} @msg the PSD file's image data uses an unknown compression ({m}) dữ liệu ảnh tệp PSD dùng nén không xác định ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) ảnh PSD quá lớn để đọc ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open hộp thoại Lưu PDF không mở diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc23a1fcf..09a541f3f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4029,6 +4029,9 @@ lighting Light 光源 @msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys} Load EPS into Clipboard 将 EPS 载入剪贴板 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制 +@msg the JPEG XL file is cut short JPEG XL 文件不完整 +@msg CMYK JPEG XL images can't be read 无法读取 CMYK 模式的 JPEG XL 图像 +@msg the JPEG XL file can't be read: {e} 无法读取 JPEG XL 文件:{e} Fill and Stroke Controls 填色和描边控件 Coloring Controls 颜色控件 Draw Mode Controls 绘图模式控件 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b848ff79b..7fcfa327f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -93,6 +93,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | PSB | open | yes (merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | +| JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | | PICT, PCX, Pixar PXR | open, place (legacy) | no | no | rarely used | diff --git a/docs/mcp.md b/docs/mcp.md index b6c980a1c..ae9df1b43 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -314,7 +314,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). With `path` the backend (the headless session or the app) writes the PNG there too. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | +| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, PSD/PSB, JPEG XL (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -2353,7 +2353,7 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o ## Glass textures Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or -`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD or PSB file, decoded within the pixel allowance the +`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD, PSB or JPEG XL file, decoded within the pixel allowance the renderer draws images in, counted as RGBA. `effect.apply` and `effect.setParams` store the image's gray levels in the effect as a grayscale PNG in base64 and set `texture: "loaded"` unless the params give a `texture`. `effect.list` reports the stored string, and a command given that string stores it unchanged. Any other string, a file that is not an image, or an image past @@ -2922,6 +2922,13 @@ flattened picture every Photoshop file carries (layers aren't read): Bitmap, Gra A document with transparency keeps it. `file.place {path}` links the file like any image (`links.update` reads it again when it changes); Multichannel files are refused. +JPEG XL images (`.jxl`, a bare codestream or the ISOBMFF container) open and place as their first frame, with the +orientation their header gives, decoded with jxl-oxide and stored as PNG. 16-bit and floating-point samples become 8 +bits, and alpha is kept. Colors are converted to sRGB in XYB-encoded images and when the header names the color space +or the embedded ICC profile describes one the decoder knows; other images with an embedded ICC profile keep their +samples as stored. CMYK files are refused, and an image past the pixel allowance is refused from its header, before +its pixels are decoded. `file.place {path}` links the file like any image. VectorCraft doesn't write JPEG XL. + ```json {"name":"export","arguments":{"path":"/tmp/poster.psd","options":{"ppi":300,"maxEditability":true}}} {"name":"run_command","arguments":{"command":"document.export","params":{"format":"psd","layers":false,"colorModel":"cmyk"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f666994a..8053b37a2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -607,6 +607,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). + - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). @@ -663,7 +664,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it. -- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD or PSB file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD, PSB or JPEG XL file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. - **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, Document Setup's substitution highlights, a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). From dfb882da552a73497171f10cef76d8ddca016104 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 13:58:28 -0700 Subject: [PATCH 2/9] File formats: count JPEG XL beside Targa, and translate its messages in ka and zh-hant docs/file-format-parity.md counts 26 formats opened or placed, Targa and JPEG XL among them. clipboard.importImage's params text names Targa, which main reads by its header, beside JPEG XL. The Georgian and Traditional Chinese catalogs, which now translate every status and error message, get the three JPEG XL messages. --- crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/ui-egui/src/i18n/ka.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ docs/file-format-parity.md | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 8cc18ac63..725b8c5d9 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Load Image into Clipboard", [], None, - "{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", + "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, 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 ), diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 22f4a036d..0bc503c1b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4132,6 +4132,9 @@ effect Regular ჩვეულებრივი @msg the PSD file is cut short PSD ფაილი შეკვეცილია @msg the PSD file's header is damaged PSD ფაილის სათაური დაზიანებულია @msg the PSD file's image data can't be read: {e} PSD ფაილის გამოსახულების მონაცემების წაკითხვა ვერ ხერხდება: {e} +@msg the JPEG XL file is cut short JPEG XL ფაილი შეკვეცილია +@msg CMYK JPEG XL images can't be read CMYK JPEG XL გამოსახულებების წაკითხვა ვერ ხერხდება +@msg the JPEG XL file can't be read: {e} JPEG XL ფაილის წაკითხვა ვერ ხერხდება: {e} @msg Imported library {name} შემოტანილია ბიბლიოთეკა {name} @msg Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგენილია ნაგულისხმევი დოკი @msg action is required action აუცილებელია diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f0e1b271c..9c356c2dd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2198,6 +2198,9 @@ file-filter Templates 範本 @msg the PSD file is cut short PSD 檔案不完整 @msg the PSD file's header is damaged PSD 檔頭已損毀 @msg the PSD file's image data can't be read: {e} 無法讀取 PSD 檔案的影像資料:{e} +@msg the JPEG XL file is cut short JPEG XL 檔案不完整 +@msg CMYK JPEG XL images can't be read 無法讀取 CMYK 模式的 JPEG XL 影像 +@msg the JPEG XL file can't be read: {e} 無法讀取 JPEG XL 檔案:{e} @msg Imported library {name} 已匯入資料庫 {name} @msg invalid restored panel group 還原的面板群組無效 @msg Invalid panel layout; restored the default dock 面板配置無效;已還原預設停駐區 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ae524ca23..de67cd2c2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| -| Formats opened or placed | ~30 | 25 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word `.doc`, PS | +| Formats opened or placed | ~30 | 26 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word `.doc`, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | From 7c513f3f3a85a0739c2addcb312caae94bc72e1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 16:39:02 -0700 Subject: [PATCH 3/9] File formats: a JPEG XL image whose decoder buffers run out is too large to draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decoder's pixel buffers are capped by the image's size and the raster allowance. When they need more, File › Open and Place now say the image is too large to draw, as they do when its size alone is over the allowance, instead of saying the file can't be read. Damaged and cut-short files keep their messages. --- Cargo.lock | 1 + Cargo.toml | 2 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/fileio/jxlread.rs | 39 ++++++++++++++----- crates/engine/src/cmd/fileio/tests_jxlread.rs | 17 +++++++- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d886823e..431791145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4839,6 +4839,7 @@ dependencies = [ "flate2", "image", "jxl-color", + "jxl-grid", "jxl-oxide", "kurbo 0.13.1", "log", diff --git a/Cargo.toml b/Cargo.toml index 6fcec350e..496c5de94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,8 @@ zune-jpeg = "0.5" jxl-oxide = { version = "0.12.6", default-features = false } # jxl-oxide's color conversions, run on colors after their alpha is divided out. jxl-color = "0.11.0" +# jxl-oxide's buffer allocation error, told apart from other decoding errors. +jxl-grid = "0.6.2" # Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files. zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] } zune-core = "0.5" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 36557e46f..e87992f44 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -38,6 +38,7 @@ vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } jxl-oxide = { workspace = true } jxl-color = { workspace = true } +jxl-grid = { workspace = true } flate2 = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). roxmltree = "0.21" diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 1826c43df..5c6856ffa 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -39,16 +39,17 @@ pub fn is_jxl(bytes: &[u8]) -> bool { } /// 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. +/// the image would take more than `max_alloc` bytes as RGBA. The image is also too large to draw +/// when the decoder's buffers for its pixels need more than they are allowed. pub fn decode(bytes: &[u8], max_alloc: u64) -> Result { 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 (head, _) = header(bytes, HEADER_BUFFERS, None)?; 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")); + return Err(too_large(w, h)); } if head.pixel_format().has_black() { return Err("CMYK JPEG XL images can't be read".into()); @@ -56,14 +57,15 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result // 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 size = Some((w, h)); + let (mut image, mut at) = header(bytes, budget, size)?; 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)?; + at += image.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?; } 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); @@ -92,7 +94,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result if let Some(encoding) = request { image.request_color_encoding(encoding); } - let render = image.render_frame(0).map_err(unreadable)?; + let render = image.render_frame(0).map_err(|e| failed(e, size))?; let mut stream = render.stream(); let channels = stream.channels() as usize; if !(1..=4).contains(&channels) { @@ -191,14 +193,15 @@ fn pixel_boxes(bytes: &[u8]) -> Option> { } /// 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> { +/// the header → (the decoder, how many bytes it took). `size` is the image's size when it is +/// known, for the error when the buffers need more ([`failed`]). +fn header(bytes: &[u8], buffers: usize, size: Option<(u32, u32)>) -> 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)? { + at += uninit.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?; + match uninit.try_init().map_err(|e| failed(e, size))? { InitializeResult::Initialized(image) => return Ok((image, at)), InitializeResult::NeedMoreData(_) if end == bytes.len() => return Err(TRUNCATED.into()), InitializeResult::NeedMoreData(more) => uninit = more, @@ -213,6 +216,22 @@ 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())) } +/// The decoder's error `e` as a message. When the decoder's buffers needed more than they were +/// allowed and the image's `size` is known, the image is too large to draw. Otherwise the file +/// can't be read. +fn failed(e: Box, size: Option<(u32, u32)>) -> String { + let first: &(dyn std::error::Error + 'static) = &*e; + let out_of_memory = std::iter::successors(Some(first), |c| c.source()).any(|c| c.is::()); + match size { + Some((w, h)) if out_of_memory => too_large(w, h), + _ => unreadable(e), + } +} + +fn too_large(w: u32, h: u32) -> String { + format!("an image of {w} × {h} pixels is too large to draw") +} + fn unreadable(e: impl std::fmt::Display) -> String { format!("the JPEG XL file can't be read: {e}") } diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index e4d3a1aad..f3768c003 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -127,12 +127,25 @@ fn an_image_too_large_is_refused_from_its_header() { 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). + // The allowance counts RGBA bytes: 3 × 2 pixels take 24. 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()); } +/// The decoder's buffers take more than the image as RGBA. When they need more than the allowance, +/// the image is too large to draw, as when its size alone is. +#[test] +fn an_image_whose_decoder_buffers_pass_the_allowance_is_too_large() { + assert_eq!(decode(&rgba8(), 24).unwrap_err(), "an image of 3 × 2 pixels is too large to draw"); + let gray: Vec = (0..64 * 64).map(|i| (i % 251) as u8).collect(); + let f = container(&jxl(64, 64, ColorSpace::Luma, BitDepth::Eight, &gray)); + assert_eq!(decode(&f, 64 * 64 * 4).unwrap_err(), "an image of 64 × 64 pixels is too large to draw"); + assert!(decode(&f, 1 << 20).is_ok()); + // A file cut short is still reported as cut short or unreadable. + let e = decode(&f[..f.len() - 3], 1 << 20).unwrap_err(); + assert!(!e.contains("too large"), "{e}"); +} + /// 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] From f2e2a5b5f9360a8cb468f478e5b6ec8ebec9879b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 21:29:52 -0700 Subject: [PATCH 4/9] File formats: a JPEG XL image keeps the RGB ICC profile its colors stay in When a JPEG XL image embeds an ICC profile that the decoder can't convert to sRGB without a color management system, its colors come back as stored. The PNG the image was stored as left the profile out, so a saved document or an SVG export held those colors without their color space. That PNG now carries the profile when it is an RGB one. Colors converted to sRGB are stored without a profile, as before, and so are a gray image's colors, which come back as RGB. --- crates/engine/src/cmd/fileio/jxlread.rs | 22 ++++++--- crates/engine/src/cmd/fileio/load.rs | 21 ++++++--- crates/engine/src/cmd/fileio/tests_jxlread.rs | 46 +++++++++++++++++-- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 5c6856ffa..021cd5909 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -5,7 +5,8 @@ //! 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, +//! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and with +//! the profile when it is an RGB one. 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. @@ -38,10 +39,11 @@ 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. The image is also too large to draw -/// when the decoder's buffers for its pixels need more than they are allowed. -pub fn decode(bytes: &[u8], max_alloc: u64) -> Result { +/// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors +/// are in when they are not converted to sRGB ([`kept_profile`]). Fails, without decoding the +/// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too +/// large to draw when the decoder's buffers for its pixels need more than they are allowed. +pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), 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. @@ -71,6 +73,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result 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) }; + let profile = kept_profile(metadata.xyb_encoded, image.original_icc()); // 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. @@ -110,7 +113,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result } to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?; } - Ok(out) + Ok((out, profile)) } /// How colors whose alpha was divided out become sRGB. @@ -216,6 +219,13 @@ 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())) } +/// The embedded ICC profile `icc` when the colors come back as stored in it +/// ([`to_srgb_without_cms`] is false), for the PNG they are kept in. Only an RGB profile is kept: +/// the colors of a gray image come back as RGB. +pub(crate) fn kept_profile(xyb: bool, icc: Option<&[u8]>) -> Option> { + icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !to_srgb_without_cms(xyb, Some(icc))).map(<[u8]>::to_vec) +} + /// The decoder's error `e` as a message. When the decoder's buffers needed more than they were /// allowed and the image's `size` is known, the image is too large to draw. Otherwise the file /// can't be read. diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 748843179..c32fec37f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -509,7 +509,7 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option { /// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw. /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are -/// read with [`jxlread`] and stored as PNG. +/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -517,10 +517,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { } 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); + return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, None, ppi); } if jxlread::is_jxl(bytes) { - return as_png(jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + let (img, icc) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; + return as_png(img, icc, ppi); } let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; if reader.format().is_none() && super::tgaread::is_targa(bytes) { @@ -563,14 +564,20 @@ pub fn raster_image(bytes: &[u8]) -> Result { if f.id == "tga" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, ppi) + as_png(img, None, ppi) } -/// `img` stored as a PNG that keeps the file's resolution. -fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result { +/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its +/// colors are in. +pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { + use image::ImageEncoder as _; let size = img.dimensions(); let mut png = Vec::new(); - img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + encoder.set_icc_profile(icc).map_err(err)?; + } + img.write_with_encoder(encoder).map_err(err)?; let png = match ppi { Some(r) => super::ppi::with_png_resolution(&png, r), None => png, diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index f3768c003..1c248f486 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -6,7 +6,7 @@ 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::jxlread::{ToSrgb, decode, is_jxl, kept_profile, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -91,7 +91,7 @@ fn sixteen_bit_samples_become_eight_bits() { 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::>(), want); + assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::>(), want); // Gray, 16 bits, in the container, opened as a document. let gray: Vec = [0xffffu16, 0x8080, 0x0000, 0x4040].iter().flat_map(|v| v.to_ne_bytes()).collect(); let f = container(&jxl(2, 2, ColorSpace::Luma, BitDepth::Sixteen, &gray)); @@ -202,7 +202,47 @@ fn metadata_boxes_are_not_read() { 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::>(), RGBA); + assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::>(), RGBA); +} + +/// Colors left in their embedded RGB profile keep it: the PNG they are stored in carries the +/// profile. Colors converted to sRGB, and a gray image's colors, which come back as RGB, are stored +/// without one. +#[test] +fn colors_left_in_their_icc_profile_keep_it() { + use image::ImageDecoder as _; + use jxl_oxide::{EnumColourEncoding, RenderingIntent}; + // A profile jxl-color can't turn into a JPEG XL color space, so the decoder leaves the colors + // in it: the sRGB profile with the tag of its red curve renamed. + let unnamed = |encoding: &EnumColourEncoding, curve: &[u8; 4]| { + let mut icc = jxl_color::icc::colour_encoding_to_icc(encoding); + let count = u32::from_be_bytes(icc[128..132].try_into().unwrap()) as usize; + for at in (0..count).map(|i| 132 + 12 * i) { + if icc[at..at + 4] == curve[..] { + icc[at..at + 4].copy_from_slice(b"xTRC"); + } + } + icc + }; + let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative); + let rgb = unnamed(&srgb, b"rTRC"); + assert_ne!(rgb, jxl_color::icc::colour_encoding_to_icc(&srgb), "a tag renamed"); + assert_eq!(kept_profile(false, Some(&rgb)), Some(rgb.clone())); + assert_eq!(kept_profile(true, Some(&rgb)), None, "XYB-encoded colors become sRGB"); + assert_eq!(kept_profile(false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))), None, "converted to sRGB"); + let gray = unnamed(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative), b"kTRC"); + assert_eq!(kept_profile(false, Some(&gray)), None, "gray"); + assert_eq!(kept_profile(false, None), None); + // The PNG carries the profile, the pixels and the resolution. + let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap(); + for icc in [Some(rgb), None] { + let RasterImage { blob, width, height, .. } = super::load::as_png(img.clone(), icc.clone(), Some((144.0, 144.0))).unwrap(); + assert_eq!((width, height), (2, 1)); + let mut png = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&blob.bytes[..])).unwrap(); + assert_eq!(png.icc_profile().unwrap(), icc); + assert_eq!(image::DynamicImage::from_decoder(png).unwrap().to_rgba8(), img); + assert_eq!(super::ppi::resolution(&blob.bytes).map(|(x, y)| (x.round(), y.round())), Some((144.0, 144.0))); + } } #[test] From 5018ff319f108976a35673834fc9a30e01448175 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 11:59:56 -0700 Subject: [PATCH 5/9] i18n: Turkish rows for the JPEG XL messages Turkish now translates every message, so it gets rows for the three JPEG XL messages. --- crates/ui-egui/src/i18n/tr.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ce6b2229e..3e4975e75 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4299,6 +4299,9 @@ axis Both Her İkisi @msg the PSD file is cut short PSD dosyası kısa kesildi @msg the PSD file's header is damaged PSD dosyasının başlığı hasarlı @msg the PSD file's image data can't be read: {e} PSD dosyasının görüntü verileri okunamıyor: {e} +@msg the JPEG XL file is cut short JPEG XL dosyası kısa kesildi +@msg CMYK JPEG XL images can't be read CMYK JPEG XL görüntüleri okunamıyor +@msg the JPEG XL file can't be read: {e} JPEG XL dosyası okunamıyor: {e} Video video De-Interlace… Taramasızlaştırma… De-Interlace Taramasızlaştırma From 6118e7afba56eb00d37bdba0264e69e6b3d71265 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:53:19 -0700 Subject: [PATCH 6/9] File formats: JPEG XL reads Exif resolution and bounds the container copy A JPEG XL container's Exif box now sizes opened and placed images by its resolution tags, as TIFF and WebP Exif do; a bare codestream stays at 72 ppi. The pixel boxes copied out of a container are limited to the raster allowance and reserved fallibly, so a large payload is refused with "the JPEG XL container is too large to read" before decoding. --- crates/engine/src/cmd/fileio/jxlread.rs | 19 +++++--- crates/engine/src/cmd/fileio/ppi.rs | 30 +++++++++++- crates/engine/src/cmd/fileio/tests_jxlread.rs | 48 ++++++++++++++++++- crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 2 +- 25 files changed, 111 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 021cd5909..011af6a0f 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -42,9 +42,11 @@ pub fn is_jxl(bytes: &[u8]) -> bool { /// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors /// are in when they are not converted to sRGB ([`kept_profile`]). Fails, without decoding the /// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too -/// large to draw when the decoder's buffers for its pixels need more than they are allowed. +/// large to draw when the decoder's buffers for its pixels need more than they are allowed. A +/// container whose selected boxes exceed `max_alloc` or [`HEADER_BUFFERS`], whichever is greater, +/// is refused with "the JPEG XL container is too large to read". pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), String> { - let pixel_boxes = pixel_boxes(bytes); + let pixel_boxes = pixel_boxes(bytes, max_alloc.max(HEADER_BUFFERS as u64))?; 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, None)?; @@ -169,9 +171,10 @@ pub(crate) fn to_rgba( /// 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> { - let mut rest = bytes.strip_prefix(&CONTAINER)?; +/// cut short ends the file. Selected boxes exceeding `max_copy` are refused with "the JPEG XL +/// container is too large to read". `None` for a bare codestream. +pub(super) fn pixel_boxes(bytes: &[u8], max_copy: u64) -> Result>, String> { + let Some(mut rest) = bytes.strip_prefix(&CONTAINER) else { return Ok(None) }; 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) { @@ -188,11 +191,15 @@ fn pixel_boxes(bytes: &[u8]) -> Option> { 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") { + if (out.len() as u64).saturating_add(b.len() as u64) > max_copy { + return Err("the JPEG XL container is too large to read".into()); + } + out.try_reserve_exact(b.len()).map_err(|_| "the JPEG XL container is too large to read")?; out.extend_from_slice(b); } rest = after; } - Some(out) + Ok(Some(out)) } /// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 61b741756..cc5fe1aa3 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -1,5 +1,5 @@ //! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and -//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX +//! WebP and JPEG XL EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX //! header's HDpi and VDpi. Place //! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it //! as a `pHYs` chunk. @@ -16,6 +16,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { jpeg(bytes) } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { tiff(bytes) + } else if super::jxlread::is_jxl(bytes) { + jxl(bytes) } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { webp(bytes) } else if bytes.starts_with(b"BM") { @@ -153,6 +155,32 @@ fn webp(b: &[u8]) -> Option<(f64, f64)> { None } +fn jxl(b: &[u8]) -> Option<(f64, f64)> { + if !b.starts_with(&[0, 0, 0, 12, b'J', b'X', b'L', b' ', 13, 10, 135, 10]) { + return None; + } + let mut at = 12; + while at < b.len() { + let size = be32(b, at)?; + let ty = slice(b, at.checked_add(4)?, 4)?; + let (header, len) = match size { + 0 => (8, b.len().checked_sub(at)?), + 1 => (16, usize::try_from(u64::from_be_bytes(bytes(b, at.checked_add(8)?)?)).ok()?), + _ => (8, size as usize), + }; + if len < header { + return None; + } + let payload = slice(b, at.checked_add(header)?, len - header)?; + if ty == b"Exif" { + let offset = usize::try_from(be32(payload, 0)?).ok()?; + return tiff(payload.get(4usize.checked_add(offset)?..)?); + } + at = at.checked_add(len)?; + } + None +} + fn bmp(b: &[u8]) -> Option<(f64, f64)> { // BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28. if le32(b, 14)? < 40 { diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index 1c248f486..50255ae0a 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -6,7 +6,7 @@ use zune_core::bit_depth::BitDepth; use zune_core::colorspace::ColorSpace; use zune_core::options::EncoderOptions; -use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, to_rgba}; +use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, pixel_boxes, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -69,6 +69,52 @@ fn jxl_images_open_and_place_with_their_pixels_and_alpha() { assert!(open_filters().any(|f| f == ("JPEG XL", &["jxl"][..])) && place_filters().any(|f| f == ("JPEG XL", &["jxl"][..]))); } +#[test] +fn container_copy_stays_within_the_input_allowance() { + let f = container(&[0; 128]); + assert!(pixel_boxes(&f, 64).unwrap_err().contains("too large")); + assert_eq!(pixel_boxes(&f, f.len() as u64).unwrap().unwrap(), f); +} + +#[test] +fn container_exif_resolution_sizes_open_and_place() { + let bare = rgba8(); + let mut f = container(&[]); + f.truncate(32); + let mut exif = Vec::new(); + exif.extend(4u32.to_be_bytes()); + exif.extend(b"Exif"); + exif.extend(b"II*\0"); + exif.extend(8u32.to_le_bytes()); + exif.extend(3u16.to_le_bytes()); + for (tag, value) in [(282u16, 50u32), (283, 58), (296, 2)] { + exif.extend(tag.to_le_bytes()); + exif.extend((if tag == 296 { 3u16 } else { 5 }).to_le_bytes()); + exif.extend(1u32.to_le_bytes()); + exif.extend(value.to_le_bytes()); + } + exif.extend(0u32.to_le_bytes()); + for value in [144u32, 1, 72, 1] { + exif.extend(value.to_le_bytes()); + } + f.extend((8 + exif.len() as u32).to_be_bytes()); + f.extend(b"Exif"); + f.extend(&exif); + f.extend((8 + bare.len() as u32).to_be_bytes()); + f.extend(b"jxlc"); + f.extend(&bare); + assert_eq!(super::ppi::resolution(&f), Some((144.0, 72.0))); + assert_eq!(super::ppi::resolution(&bare), None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap(); + let rect = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((rect.width(), rect.height()), (1.5, 2.0)); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(1.5), Some(2.0))); + assert_eq!(super::ppi::resolution(&s.doc().unwrap().doc.images.values().next().unwrap().bytes), Some((144.0, 72.0))); +} + /// A `.jxl` file copied in a file manager pastes as its image. #[test] fn a_copied_jxl_file_pastes_as_its_image() { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4d240a2a4..c0db3edc6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5941,6 +5941,7 @@ breakText Glyphs Glifs @msg `kind` must be "line" or "paragraph", not {other} `kind` ha de ser "line" o "paragraph", no {other} @msg `align` must be "left", "center" or "right", not {other} `align` ha de ser "left", "center" o "right", no {other} @msg the JPEG XL file is cut short el fitxer JPEG XL està truncat +@msg the JPEG XL container is too large to read el contenidor JPEG XL és massa gran per llegir-lo @msg CMYK JPEG XL images can't be read les imatges JPEG XL en CMYK no es poden llegir @msg the JPEG XL file can't be read: {e} no es pot llegir el fitxer JPEG XL: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 68b14606e..6016cf5fb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4888,6 +4888,7 @@ imageTrace Outline Obrys @msg the PSD file's header is damaged hlavička souboru PSD je poškozená @msg the PSD file's image data can't be read: {e} obrazová data souboru PSD nelze přečíst: {e} @msg the JPEG XL file is cut short soubor JPEG XL je useknutý +@msg the JPEG XL container is too large to read kontejner JPEG XL je příliš velký pro načtení @msg CMYK JPEG XL images can't be read obrázky JPEG XL v režimu CMYK nelze přečíst @msg the JPEG XL file can't be read: {e} soubor JPEG XL nelze přečíst: {e} @msg Imported library {name} Importována knihovna {name} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e629f19c0..439118a3f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4625,6 +4625,7 @@ theme Appearance Erscheinungsbild @msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden @msg the JPEG XL file is cut short die JPEG-XL-Datei ist unvollständig +@msg the JPEG XL container is too large to read der JPEG-XL-Container ist zu groß zum Lesen @msg CMYK JPEG XL images can't be read JPEG-XL-Bilder im CMYK-Modus können nicht gelesen werden @msg the JPEG XL file can't be read: {e} die JPEG-XL-Datei kann nicht gelesen werden: {e} Value Axis Werteachse diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f5a1e83e5..c972eae23 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4569,6 +4569,7 @@ theme Appearance Εμφάνιση @msg the PSD file's header is damaged η κεφαλίδα του αρχείου PSD είναι κατεστραμμένη @msg the PSD file's image data can't be read: {e} τα δεδομένα εικόνας PSD δεν μπορούν να διαβαστούν: {e} @msg the JPEG XL file is cut short το αρχείο JPEG XL είναι περικομμένο +@msg the JPEG XL container is too large to read το κοντέινερ JPEG XL είναι πολύ μεγάλο για ανάγνωση @msg CMYK JPEG XL images can't be read οι εικόνες JPEG XL σε CMYK δεν διαβάζονται @msg the JPEG XL file can't be read: {e} το αρχείο JPEG XL δεν μπορεί να διαβαστεί: {e} Create New Fields by Δημιουργία νέων πεδίων κατά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e78c5783d..3778d95bf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4609,6 +4609,7 @@ axis Both Ambos @msg the PSD file's header is damaged el encabezado del archivo PSD está dañado @msg the PSD file's image data can't be read: {e} no se pueden leer los datos de imagen del archivo PSD: {e} @msg the JPEG XL file is cut short el archivo JPEG XL está incompleto +@msg the JPEG XL container is too large to read el contenedor JPEG XL es demasiado grande para leerlo @msg CMYK JPEG XL images can't be read las imágenes JPEG XL en CMYK no se pueden leer @msg the JPEG XL file can't be read: {e} no se puede leer el archivo JPEG XL: {e} Video Video diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8b7c8df3b..fadaa1bbf 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4791,6 +4791,7 @@ imageTrace Outline Ääriviiva @msg the PSD file's header is damaged tämän PSD-tiedoston otsake on vaurioitunut @msg the PSD file's image data can't be read: {e} tämän PSD-tiedoston kuvadataa ei voi lukea: {e} @msg the JPEG XL file is cut short tämä JPEG XL -tiedosto on katkennut +@msg the JPEG XL container is too large to read JPEG XL -säiliö on liian suuri luettavaksi @msg CMYK JPEG XL images can't be read CMYK-tilassa olevia JPEG XL -kuvia ei voi lukea @msg the JPEG XL file can't be read: {e} tätä JPEG XL -tiedostoa ei voi lukea: {e} @msg Imported library {name} Tuotiin kirjasto {name} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 683339881..bcd1a43dd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4620,6 +4620,7 @@ axis Both Les deux @msg the PSD file's header is damaged l'en-tête du fichier PSD est endommagé @msg the PSD file's image data can't be read: {e} les données d'image du fichier PSD ne peuvent pas être lues : {e} @msg the JPEG XL file is cut short le fichier JPEG XL est tronqué +@msg the JPEG XL container is too large to read le conteneur JPEG XL est trop volumineux pour être lu @msg CMYK JPEG XL images can't be read les images JPEG XL en CMJN ne peuvent pas être lues @msg the JPEG XL file can't be read: {e} le fichier JPEG XL ne peut pas être lu : {e} Video Vidéo diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b9423f07c..e15532d40 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5122,6 +5122,7 @@ imageTrace Outline Körvonal @msg the PSD file's header is damaged a PSD-fájl fejléce sérült @msg the PSD file's image data can't be read: {e} a PSD-fájl képadatai nem olvashatók: {e} @msg the JPEG XL file is cut short a JPEG XL-fájl csonka +@msg the JPEG XL container is too large to read a JPEG XL-konténer túl nagy az olvasáshoz @msg CMYK JPEG XL images can't be read CMYK módú JPEG XL-képek nem olvashatók @msg the JPEG XL file can't be read: {e} a JPEG XL-fájl nem olvasható: {e} @msg the PSD file's image data uses an unknown compression ({m}) a PSD-fájl képadatai ismeretlen tömörítést használnak ({m}) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3b6caa2e3..15e4aaf24 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4649,6 +4649,7 @@ axis Both Entrambi @msg the PSD file's header is damaged l'intestazione del file PSD è danneggiata @msg the PSD file's image data can't be read: {e} i dati immagine del file PSD non possono essere letti: {e} @msg the JPEG XL file is cut short il file JPEG XL è troncato +@msg the JPEG XL container is too large to read il contenitore JPEG XL è troppo grande per essere letto @msg CMYK JPEG XL images can't be read le immagini JPEG XL in CMYK non possono essere lette @msg the JPEG XL file can't be read: {e} il file JPEG XL non può essere letto: {e} Video Video diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 558135e46..42a9f6f26 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4694,6 +4694,7 @@ swatch Bright Violet ビビッドバイオレット @msg the PSD file's header is damaged PSD ファイルのヘッダーが破損しています @msg the PSD file's image data can't be read: {e} PSD ファイルの画像データを読み込めません:{e} @msg the JPEG XL file is cut short JPEG XL ファイルが途中で切れています +@msg the JPEG XL container is too large to read JPEG XL コンテナが大きすぎて読み込めません @msg CMYK JPEG XL images can't be read CMYK の JPEG XL 画像は読み込めません @msg the JPEG XL file can't be read: {e} JPEG XL ファイルを読み込めません:{e} Video ビデオ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a6b49e906..46dbc8f50 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4328,6 +4328,7 @@ font-technology Variable ცვლადი @msg the PSD file's header is damaged PSD ფაილის სათაური დაზიანებულია @msg the PSD file's image data can't be read: {e} PSD ფაილის გამოსახულების მონაცემების წაკითხვა ვერ ხერხდება: {e} @msg the JPEG XL file is cut short JPEG XL ფაილი შეკვეცილია +@msg the JPEG XL container is too large to read JPEG XL კონტეინერი წასაკითხად ზედმეტად დიდია @msg CMYK JPEG XL images can't be read CMYK JPEG XL გამოსახულებების წაკითხვა ვერ ხერხდება @msg the JPEG XL file can't be read: {e} JPEG XL ფაილის წაკითხვა ვერ ხერხდება: {e} @msg Imported library {name} შემოტანილია ბიბლიოთეკა {name} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 597ac7133..002427ab3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4619,6 +4619,7 @@ theme Appearance 외관 @msg the PSD file's header is damaged PSD 파일의 헤더가 손상되었습니다 @msg the PSD file's image data can't be read: {e} PSD 파일의 이미지 데이터를 읽을 수 없습니다: {e} @msg the JPEG XL file is cut short JPEG XL 파일이 잘려 있습니다 +@msg the JPEG XL container is too large to read JPEG XL 컨테이너가 너무 커서 읽을 수 없습니다 @msg CMYK JPEG XL images can't be read CMYK JPEG XL 이미지는 읽을 수 없습니다 @msg the JPEG XL file can't be read: {e} JPEG XL 파일을 읽을 수 없습니다: {e} Video 비디오 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index aa7262a6d..4adf67150 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4715,6 +4715,7 @@ imageTrace Outline Kontur @msg the PSD file's header is damaged nagłówek pliku PSD jest uszkodzony @msg the PSD file's image data can't be read: {e} nie można odczytać danych obrazu pliku PSD: {e} @msg the JPEG XL file is cut short plik JPEG XL jest ucięty +@msg the JPEG XL container is too large to read kontener JPEG XL jest zbyt duży, aby go odczytać @msg CMYK JPEG XL images can't be read nie można odczytać obrazów JPEG XL w trybie CMYK @msg the JPEG XL file can't be read: {e} nie można odczytać pliku JPEG XL: {e} @msg Imported library {name} Zaimportowano bibliotekę {name} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9eb5cdfb7..90179148e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4152,6 +4152,7 @@ axis Both Ambos @msg the PSD file's header is damaged o cabeçalho do arquivo PSD está danificado @msg the PSD file's image data can't be read: {e} os dados de imagem do arquivo PSD não podem ser lidos: {e} @msg the JPEG XL file is cut short o arquivo JPEG XL está incompleto +@msg the JPEG XL container is too large to read o contêiner JPEG XL é grande demais para ser lido @msg CMYK JPEG XL images can't be read imagens JPEG XL em CMYK não podem ser lidas @msg the JPEG XL file can't be read: {e} o arquivo JPEG XL não pode ser lido: {e} @msg Imported library {name} Biblioteca {name} importada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dfd359b2a..8e9e00167 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4647,6 +4647,7 @@ axis Both Оба @msg the PSD file's header is damaged заголовок файла PSD повреждён @msg the PSD file's image data can't be read: {e} не удаётся прочитать данные изображения файла PSD: {e} @msg the JPEG XL file is cut short файл JPEG XL обрезан +@msg the JPEG XL container is too large to read контейнер JPEG XL слишком велик для чтения @msg CMYK JPEG XL images can't be read изображения JPEG XL в режиме CMYK не читаются @msg the JPEG XL file can't be read: {e} не удаётся прочитать файл JPEG XL: {e} Video Видео diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c3cf3cd7a..c54c5c74b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4526,6 +4526,7 @@ axis Both Båda @msg the PSD file's header is damaged PSD-filens huvud är skadat @msg the PSD file's image data can't be read: {e} PSD-filens bilddata kan inte läsas: {e} @msg the JPEG XL file is cut short JPEG XL-filen är avkortad +@msg the JPEG XL container is too large to read JPEG XL-behållaren är för stor för att läsas @msg CMYK JPEG XL images can't be read JPEG XL-bilder i CMYK kan inte läsas @msg the JPEG XL file can't be read: {e} JPEG XL-filen kan inte läsas: {e} Video Video diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f60aebad1..2ac252482 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4369,6 +4369,7 @@ axis Both Her İkisi @msg the PSD file's header is damaged PSD dosyasının başlığı hasarlı @msg the PSD file's image data can't be read: {e} PSD dosyasının görüntü verileri okunamıyor: {e} @msg the JPEG XL file is cut short JPEG XL dosyası kısa kesildi +@msg the JPEG XL container is too large to read JPEG XL kapsayıcısı okunamayacak kadar büyük @msg CMYK JPEG XL images can't be read CMYK JPEG XL görüntüleri okunamıyor @msg the JPEG XL file can't be read: {e} JPEG XL dosyası okunamıyor: {e} Video video diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5e7e78bb9..cac7bed15 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4633,6 +4633,7 @@ axis Both Обидві @msg the PSD file's header is damaged заголовок файлу PSD пошкоджено @msg the PSD file's image data can't be read: {e} не вдається прочитати дані зображення файлу PSD: {e} @msg the JPEG XL file is cut short файл JPEG XL обрізаний +@msg the JPEG XL container is too large to read контейнер JPEG XL завеликий для читання @msg CMYK JPEG XL images can't be read зображення JPEG XL у режимі CMYK не читаються @msg the JPEG XL file can't be read: {e} не вдається прочитати файл JPEG XL: {e} Video Відео diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dc62f4115..6deb1e112 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4779,6 +4779,7 @@ theme Appearance Giao diện @msg the PSD file's header is damaged tiêu đề tệp PSD bị hỏng @msg the PSD file's image data can't be read: {e} dữ liệu ảnh tệp PSD không thể đọc được: {e} @msg the JPEG XL file is cut short tệp JPEG XL bị cắt ngắn +@msg the JPEG XL container is too large to read vùng chứa JPEG XL quá lớn để đọc @msg CMYK JPEG XL images can't be read ảnh JPEG XL hệ màu CMYK không thể đọc được @msg the JPEG XL file can't be read: {e} tệp JPEG XL không thể đọc được: {e} @msg the PSD file's image data uses an unknown compression ({m}) dữ liệu ảnh tệp PSD dùng nén không xác định ({m}) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 38e928d78..4eaa08c29 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5180,6 +5180,7 @@ lighting Light 光源 Load EPS into Clipboard 将 EPS 载入剪贴板 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制 @msg the JPEG XL file is cut short JPEG XL 文件不完整 +@msg the JPEG XL container is too large to read JPEG XL 容器过大,无法读取 @msg CMYK JPEG XL images can't be read 无法读取 CMYK 模式的 JPEG XL 图像 @msg the JPEG XL file can't be read: {e} 无法读取 JPEG XL 文件:{e} @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} 像素的图像以缩小的尺寸显示 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2829fe60c..b4d4fe2a9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2341,6 +2341,7 @@ file-filter Templates 範本 @msg the PSD file's header is damaged PSD 檔頭已損毀 @msg the PSD file's image data can't be read: {e} 無法讀取 PSD 檔案的影像資料:{e} @msg the JPEG XL file is cut short JPEG XL 檔案不完整 +@msg the JPEG XL container is too large to read JPEG XL 容器過大,無法讀取 @msg CMYK JPEG XL images can't be read 無法讀取 CMYK 模式的 JPEG XL 影像 @msg the JPEG XL file can't be read: {e} 無法讀取 JPEG XL 檔案:{e} @msg Imported library {name} 已匯入資料庫 {name} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6ee1a6f5f..106071a12 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -105,7 +105,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | PSB | open | yes (merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | -| JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | +| JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; container Exif resolution sizes opened and placed images; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | | PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` | From 67516d6365e44ce6189a86fd963310bca3b054b3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:17:13 -0700 Subject: [PATCH 7/9] File formats: JPEG XL swaps a rotated image's resolution and refuses unconverted gray profiles An orientation that exchanges the axes (5 to 8) now swaps the Exif horizontal and vertical resolution, so Open and Place size the oriented pixels correctly. A gray image whose embedded ICC profile the decoder can't convert to sRGB is refused instead of being stored as RGB without a profile. --- crates/engine/src/cmd/fileio/jxlread.rs | 31 +++++++++++++------ crates/engine/src/cmd/fileio/load.rs | 4 +-- crates/engine/src/cmd/fileio/tests_jxlread.rs | 28 +++++++++++++---- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 011af6a0f..9e0a0b64c 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -6,7 +6,8 @@ //! 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, and with -//! the profile when it is an RGB one. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! the profile when it is an RGB one. Gray images with an ICC profile the decoder cannot convert +//! are refused. 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. @@ -39,13 +40,19 @@ pub fn is_jxl(bytes: &[u8]) -> bool { bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER) } +/// Resolution after the decoder applies the image header's orientation. +pub(crate) fn oriented_resolution(ppi: Option<(f64, f64)>, orientation: u32) -> Option<(f64, f64)> { + ppi.map(|(x, y)| if (5..=8).contains(&orientation) { (y, x) } else { (x, y) }) +} + /// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors -/// are in when they are not converted to sRGB ([`kept_profile`]). Fails, without decoding the +/// are in when they are not converted to sRGB ([`output_profile`]), and the header orientation. +/// Fails, without decoding the /// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too /// large to draw when the decoder's buffers for its pixels need more than they are allowed. A /// container whose selected boxes exceed `max_alloc` or [`HEADER_BUFFERS`], whichever is greater, /// is refused with "the JPEG XL container is too large to read". -pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), String> { +pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>, u32), String> { let pixel_boxes = pixel_boxes(bytes, max_alloc.max(HEADER_BUFFERS as u64))?; let bytes = pixel_boxes.as_deref().unwrap_or(bytes); // The decoder reads the header first, with buffers for the header alone. @@ -75,7 +82,8 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option< 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) }; - let profile = kept_profile(metadata.xyb_encoded, image.original_icc()); + let profile = output_profile(metadata.xyb_encoded, metadata.grayscale(), image.original_icc())?; + let orientation = metadata.orientation; // 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. @@ -115,7 +123,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option< } to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?; } - Ok((out, profile)) + Ok((out, profile, orientation)) } /// How colors whose alpha was divided out become sRGB. @@ -226,11 +234,14 @@ 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())) } -/// The embedded ICC profile `icc` when the colors come back as stored in it -/// ([`to_srgb_without_cms`] is false), for the PNG they are kept in. Only an RGB profile is kept: -/// the colors of a gray image come back as RGB. -pub(crate) fn kept_profile(xyb: bool, icc: Option<&[u8]>) -> Option> { - icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !to_srgb_without_cms(xyb, Some(icc))).map(<[u8]>::to_vec) +/// The embedded RGB profile when the decoder leaves colors in it. Gray colors without a +/// conversion to sRGB cannot be stored as RGB without changing their profile. +pub(crate) fn output_profile(xyb: bool, grayscale: bool, icc: Option<&[u8]>) -> Result>, String> { + let converts = to_srgb_without_cms(xyb, icc); + if grayscale && !converts { + return Err(unreadable("unsupported grayscale ICC profile")); + } + Ok(icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !converts).map(<[u8]>::to_vec)) } /// The decoder's error `e` as a message. When the decoder's buffers needed more than they were diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 11a05cc78..f111076d2 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -636,8 +636,8 @@ pub fn raster_image(bytes: &[u8]) -> Result { return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, None, ppi); } if jxlread::is_jxl(bytes) { - let (img, icc) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; - return as_png(img, icc, ppi); + let (img, icc, orientation) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; + return as_png(img, icc, jxlread::oriented_resolution(ppi, orientation)); } let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; if reader.format().is_none() && super::tgaread::is_targa(bytes) { diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index 50255ae0a..1774cb3a2 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -6,7 +6,7 @@ use zune_core::bit_depth::BitDepth; use zune_core::colorspace::ColorSpace; use zune_core::options::EncoderOptions; -use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, pixel_boxes, to_rgba}; +use super::jxlread::{ToSrgb, decode, is_jxl, oriented_resolution, output_profile, pixel_boxes, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -115,6 +115,16 @@ fn container_exif_resolution_sizes_open_and_place() { assert_eq!(super::ppi::resolution(&s.doc().unwrap().doc.images.values().next().unwrap().bytes), Some((144.0, 72.0))); } +#[test] +fn jxl_axis_exchanging_orientations_swap_resolution() { + let ppi = Some((144.0, 72.0)); + for orientation in 1..=8 { + let expected = if orientation >= 5 { Some((72.0, 144.0)) } else { ppi }; + assert_eq!(oriented_resolution(ppi, orientation), expected, "orientation {orientation}"); + } + assert_eq!(oriented_resolution(None, 6), None); +} + /// A `.jxl` file copied in a file manager pastes as its image. #[test] fn a_copied_jxl_file_pastes_as_its_image() { @@ -273,12 +283,18 @@ fn colors_left_in_their_icc_profile_keep_it() { let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative); let rgb = unnamed(&srgb, b"rTRC"); assert_ne!(rgb, jxl_color::icc::colour_encoding_to_icc(&srgb), "a tag renamed"); - assert_eq!(kept_profile(false, Some(&rgb)), Some(rgb.clone())); - assert_eq!(kept_profile(true, Some(&rgb)), None, "XYB-encoded colors become sRGB"); - assert_eq!(kept_profile(false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))), None, "converted to sRGB"); + assert_eq!(output_profile(false, false, Some(&rgb)).unwrap(), Some(rgb.clone())); + assert_eq!(output_profile(true, false, Some(&rgb)).unwrap(), None, "XYB-encoded colors become sRGB"); + assert_eq!(output_profile(false, false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))).unwrap(), None, "converted to sRGB"); let gray = unnamed(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative), b"kTRC"); - assert_eq!(kept_profile(false, Some(&gray)), None, "gray"); - assert_eq!(kept_profile(false, None), None); + assert!(!jxl_oxide::ColorEncodingWithProfile::with_icc(&gray).unwrap().icc_profile().is_empty()); + assert_eq!(output_profile(false, true, Some(&gray)).unwrap_err(), "the JPEG XL file can't be read: unsupported grayscale ICC profile"); + assert_eq!( + output_profile(false, true, Some(&jxl_color::icc::colour_encoding_to_icc(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative)))) + .unwrap(), + None + ); + assert_eq!(output_profile(false, false, None).unwrap(), None); // The PNG carries the profile, the pixels and the resolution. let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap(); for icc in [Some(rgb), None] { From 3698a76c5df989756776369ccdb62d027c61e4cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:09:11 -0800 Subject: [PATCH 8/9] File formats: JPEG XL refuses colors left in a profile that is not an RGB one A color JPEG XL image whose embedded ICC profile the decoder cannot convert to sRGB kept its samples as stored and lost the profile when that profile was not an RGB one, so the image drew those samples as sRGB. It is now refused as unreadable, as a gray image in such a profile already is. --- crates/engine/src/cmd/fileio/jxlread.rs | 16 ++++++++++------ crates/engine/src/cmd/fileio/tests_jxlread.rs | 5 +++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 9e0a0b64c..1d4e3d138 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -4,10 +4,10 @@ //! //! 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, and with -//! the profile when it is an RGB one. Gray images with an ICC profile the decoder cannot convert -//! are refused. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! profiles that describe one of them. Any other image with an embedded RGB ICC profile comes +//! back with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and +//! with the profile. Gray images, and images whose profile is not an RGB one, are refused when the +//! decoder cannot convert their ICC profile. 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. @@ -235,13 +235,17 @@ fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool { } /// The embedded RGB profile when the decoder leaves colors in it. Gray colors without a -/// conversion to sRGB cannot be stored as RGB without changing their profile. +/// conversion to sRGB cannot be stored as RGB without changing their profile, and neither can +/// colors left in a profile that is not an RGB one. pub(crate) fn output_profile(xyb: bool, grayscale: bool, icc: Option<&[u8]>) -> Result>, String> { let converts = to_srgb_without_cms(xyb, icc); if grayscale && !converts { return Err(unreadable("unsupported grayscale ICC profile")); } - Ok(icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !converts).map(<[u8]>::to_vec)) + if !converts && icc.and_then(|icc| icc.get(16..20)) != Some(b"RGB ") { + return Err(unreadable("unsupported ICC profile color space")); + } + Ok(icc.filter(|_| !converts).map(<[u8]>::to_vec)) } /// The decoder's error `e` as a message. When the decoder's buffers needed more than they were diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index 1774cb3a2..6e6af80c8 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -295,6 +295,11 @@ fn colors_left_in_their_icc_profile_keep_it() { None ); assert_eq!(output_profile(false, false, None).unwrap(), None); + // A color image left in a profile that is not an RGB one can't be stored as RGB. + let mut lab = rgb.clone(); + lab[16..20].copy_from_slice(b"Lab "); + assert_eq!(output_profile(false, false, Some(&lab)).unwrap_err(), "the JPEG XL file can't be read: unsupported ICC profile color space"); + assert_eq!(output_profile(true, false, Some(&lab)).unwrap(), None, "XYB-encoded colors become sRGB"); // The PNG carries the profile, the pixels and the resolution. let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap(); for icc in [Some(rgb), None] { From e1798597f3694b52a4b6fdc24d6f6e92d3d0f0f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 17:43:33 -0800 Subject: [PATCH 9/9] Fix as_png calls after merging main --- crates/engine/src/cmd/fileio/load.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 0f2e58baf..daab889fa 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -771,10 +771,10 @@ pub fn raster_image(bytes: &[u8]) -> Result { if super::heifread::is_heif(bytes) { // The EXIF resolution describes the stored pixels; a quarter turn swaps its axes. let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) }); - return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi); } if super::pxrread::is_pxr(bytes) { - return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi); } let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; if reader.format().is_none() && super::tgaread::is_targa(bytes) {