diff --git a/Cargo.lock b/Cargo.lock index 9760f61ff..4c4887099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1504,6 +1504,7 @@ dependencies = [ "designcraft-fonts", "flate2", "image", + "jpeg-decoder", "log", "quick-xml 0.41.0", "resvg", @@ -3305,6 +3306,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.106" diff --git a/ROADMAP.md b/ROADMAP.md index 402c81438..559fed4e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,6 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change. - **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change. - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index 685c7d947..658047784 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -21,6 +21,8 @@ log = { workspace = true } craft-cms = { workspace = true } zune-core = { version = "0.5", default-features = false } zune-jpeg = "0.5" +# Reduced-size JPEG decoding (1/2, 1/4, 1/8 through the DCT) for screen proxies. +jpeg-decoder = { version = "0.3", default-features = false } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/images/src/colour/tests.rs b/crates/images/src/colour/tests.rs index ca1607965..28bd99c99 100644 --- a/crates/images/src/colour/tests.rs +++ b/crates/images/src/colour/tests.rs @@ -241,3 +241,25 @@ fn unusable_profiles_leave_the_pixels_alone() { let png = png_with_icc(1, 1, &px, Some(vec![0xAB; 300])); assert_eq!(decode_rgba(&png).unwrap().as_raw(), &px); } + +#[test] +fn reduced_jpegs_get_the_colour_handling_of_full_decodes() { + // An Adobe RGB JPEG: the reduced decode applies the profile as the full decode does. + let img = image::RgbImage::from_pixel(64, 48, image::Rgb([64, 192, 64])); + let mut jpeg = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95); + enc.set_icc_profile(adobe_rgb_icc()).unwrap(); + enc.write_image(img.as_raw(), 64, 48, image::ExtendedColorType::Rgb8).unwrap(); + let full = decode_rgba(&jpeg).unwrap().get_pixel(3, 3).0; + let reduced = crate::decode_rgba_reduced(&jpeg, 3).unwrap(); + assert_eq!(reduced.dimensions(), (8, 6)); + let p = reduced.get_pixel(1, 1).0; + for c in 0..4 { + assert!(p[c].abs_diff(full[c]) <= 2, "{p:?} vs {full:?}"); + } + let want = adobe_to_srgb([64, 192, 64]); + assert!((f32::from(p[0]) - want[0]).abs() <= 3.0, "{p:?} vs {want:?}"); + // CMYK and YCCK JPEGs stay with the full decode, which converts their inks. + assert!(crate::decode_rgba_reduced(&solid_jpeg([0, 255, 255, 255], Some(0)), 1).is_none()); + assert!(crate::decode_rgba_reduced(&solid_jpeg([76, 85, 255, 255], Some(2)), 1).is_none()); +} diff --git a/crates/images/src/jpeg.rs b/crates/images/src/jpeg.rs index 8b44d0c9a..fea0a1e64 100644 --- a/crates/images/src/jpeg.rs +++ b/crates/images/src/jpeg.rs @@ -62,6 +62,44 @@ pub(crate) fn decode_cmyk(bytes: &[u8]) -> Option<(CmykRaster, Option>)> Some((raster, icc)) } +/// The largest reduction [`decode_reduced`] reads straight from a JPEG's 8×8 blocks: 1/8. +pub(crate) const MAX_SHRINK: u8 = 3; + +/// A YCbCr, RGB or grayscale JPEG decoded at 1/2^`shrink` of its size, each side rounded up, for +/// `shrink` 1 to [`MAX_SHRINK`]: straight RGBA and whether the image has colour. `None` for CMYK, +/// 12-bit and lossless JPEGs, other formats and anything that doesn't decode. +pub(crate) fn decode_reduced(bytes: &[u8], shrink: u8) -> Option<(image::RgbaImage, bool)> { + use jpeg_decoder::PixelFormat; + if !bytes.starts_with(&[0xFF, 0xD8]) || shrink == 0 || shrink > MAX_SHRINK { + return None; + } + let mut d = jpeg_decoder::Decoder::new(bytes); + d.set_max_decoding_buffer_size(MAX_BYTES); + d.read_info().ok()?; + let info = d.info()?; + let colour = match info.pixel_format { + PixelFormat::RGB24 => true, + PixelFormat::L8 => false, + PixelFormat::L16 | PixelFormat::CMYK32 => return None, + }; + let reduced = |n: u16| u16::try_from(u32::from(n).div_ceil(1 << shrink)).ok(); + let (w, h) = d.scale(reduced(info.width)?, reduced(info.height)?).ok()?; + let px = d.decode().ok()?; + let pixels = usize::from(w).checked_mul(usize::from(h))?; + let rgba: Vec = if colour { + if px.len() != pixels.checked_mul(3)? { + return None; + } + px.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect() + } else { + if px.len() != pixels { + return None; + } + px.iter().flat_map(|&v| [v, v, v, 255]).collect() + }; + Some((image::RgbaImage::from_raw(u32::from(w), u32::from(h), rgba)?, colour)) +} + /// Whether an APP14 "Adobe" segment comes before the image data. fn adobe_marker(bytes: &[u8]) -> bool { let mut i = 2; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 081af6c2b..530ca8097 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -442,6 +442,24 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { to_display_rgba(px, Some(&icc), &DisplayColour::active()) } +/// The largest `shrink` [`decode_rgba_reduced`] takes: 3, an eighth of each side. +pub const MAX_REDUCED_SHRINK: u8 = jpeg::MAX_SHRINK; + +/// [`decode_rgba`] of a JPEG at 1/2^`shrink` of its size (`shrink` 1 to [`MAX_REDUCED_SHRINK`], each +/// side rounded up), read from its compressed blocks without a full-size decode. The embedded ICC +/// profile is applied as [`decode_rgba`] applies it. `None` where only a full decode applies: other +/// formats, CMYK, 12-bit and lossless JPEGs, and files [`decode_rgba`] refuses as too large. +pub fn decode_rgba_reduced(bytes: &[u8], shrink: u8) -> Option { + use colour::{DisplayColour, Pixels, to_display_rgba}; + if too_large_to_decode(bytes) { + return None; + } + let (rgba, has_colour) = jpeg::decode_reduced(bytes, shrink)?; + let Some(icc) = embedded_icc(bytes) else { return Some(rgba) }; + let px = if has_colour { Pixels::Rgb(rgba) } else { Pixels::Gray(rgba) }; + to_display_rgba(px, Some(&icc), &DisplayColour::active()) +} + /// False when `bytes` is a WebP whose `ICCP` chunk declares more bytes than the file holds or than /// [`colour::MAX_ICC_BYTES`]. The decoder sizes its buffer from the declared length before it reads /// anything, so such a file must not reach `icc_profile()`; its pixels decode without it. diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index cd577f09a..ae5435a62 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -561,3 +561,53 @@ fn a_webp_icc_chunk_that_declares_more_than_it_holds_is_refused_before_decoding( assert!(webp_icc_is_sane(b"\x89PNG not a webp")); assert!(webp_icc_is_sane(&webp_with(&[(b"VP8L", 6, &[0; 6])]))); } + +/// A JPEG of a smooth gradient, `w` × `h`. +fn gradient_jpeg(w: u32, h: u32, gray: bool) -> Vec { + let rgb = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, 128])); + let img = if gray { image::DynamicImage::ImageRgb8(rgb).into_luma8().into() } else { image::DynamicImage::ImageRgb8(rgb) }; + let mut out = Vec::new(); + img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90)).unwrap(); + out +} + +#[test] +fn reduced_jpegs_decode_at_a_half_quarter_or_eighth_rounded_up() { + let jpeg = gradient_jpeg(1001, 77, false); + let full = decode_rgba(&jpeg).unwrap(); + for (shrink, size) in [(1, (501, 39)), (2, (251, 20)), (3, (126, 10))] { + let img = decode_rgba_reduced(&jpeg, shrink).unwrap(); + assert_eq!(img.dimensions(), size, "shrink {shrink}"); + // The same picture as the full decode sampled at the block centres, give or take rounding. + let step = 1u32 << shrink; + let (mut total, mut n) = (0u64, 0u64); + for y in 0..size.1 - 1 { + for x in 0..size.0 - 1 { + let (p, q) = (img.get_pixel(x, y).0, full.get_pixel(x * step + step / 2, y * step + step / 2).0); + total += (0..3).map(|c| u64::from(p[c].abs_diff(q[c]))).sum::(); + n += 3; + assert_eq!(p[3], 255); + } + } + assert!(total / n <= 3, "shrink {shrink}: mean difference {}", total / n); + } + // Only 1/2, 1/4 and 1/8 are read from the blocks. + assert!(decode_rgba_reduced(&jpeg, 0).is_none()); + assert!(decode_rgba_reduced(&jpeg, 4).is_none()); +} + +#[test] +fn reduced_decoding_is_for_jpegs_and_survives_damaged_ones() { + let gray = decode_rgba_reduced(&gradient_jpeg(64, 64, true), 2).unwrap(); + assert_eq!(gray.dimensions(), (16, 16)); + assert!(gray.pixels().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255)); + let png = png_from_rgba(16, 16, vec![255; 16 * 16 * 4]).unwrap(); + assert!(decode_rgba_reduced(&png, 1).is_none()); + assert!(decode_rgba_reduced(&[0xFF, 0xD8], 1).is_none()); + let jpeg = gradient_jpeg(200, 150, false); + for n in (0..jpeg.len()).step_by(37) { + if let Some(img) = decode_rgba_reduced(&jpeg[..n], 1) { + assert_eq!(img.dimensions(), (100, 75)); + } + } +} diff --git a/crates/render/src/decode.rs b/crates/render/src/decode.rs index 135dbf108..8c703701e 100644 --- a/crates/render/src/decode.rs +++ b/crates/render/src/decode.rs @@ -19,7 +19,17 @@ pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option { let data = px.as_chunks::<4>().0.iter().map(|p| vello_cpu::color::PremulRgba8 { r: p[0], g: p[1], b: p[2], a: p[3] }).collect(); return Some(Pixmap::from_parts(data, w.min(u16::MAX as u32) as u16, h.min(u16::MAX as u32) as u16)); } - let img = designcraft_images::decode_rgba(bytes)?; + premultiplied(&designcraft_images::decode_rgba(bytes)?) +} + +/// [`decode_pixmap`] of a JPEG at 1/2^`shrink` of its size, `shrink` 1 to +/// [`designcraft_images::MAX_REDUCED_SHRINK`], without a full-size decode. `None` where only a +/// full decode applies (other formats, CMYK JPEGs). +pub(crate) fn decode_pixmap_reduced(bytes: &[u8], shrink: u8) -> Option { + premultiplied(&designcraft_images::decode_rgba_reduced(bytes, shrink)?) +} + +fn premultiplied(img: &image::RgbaImage) -> Option { let (w, h) = img.dimensions(); if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { return None; diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 37606097d..69691135e 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; use vello_cpu::Pixmap; +use crate::decode::decode_pixmap_reduced; use crate::{decode_pixmap_page, halve}; /// Decoded pixels kept (bytes). @@ -130,6 +131,11 @@ pub fn decoded(bytes: &Arc>, page: u32) -> Option> { /// The mip level of `bytes` closest above `on_screen` device pixels per point for a graphic /// `width_pt` points wide. pub fn mip(bytes: &Arc>, page: u32, width_pt: f64, on_screen: f64) -> Option> { + if lookup((Arc::as_ptr(bytes) as usize, page, 0)).is_none() + && let Some(pm) = reduced(bytes, page, width_pt, on_screen) + { + return Some(pm); + } let full = decoded(bytes, page)?; let ppt = full.width() as f64 / width_pt.max(1e-6); let id = Arc::as_ptr(bytes) as usize; @@ -149,6 +155,71 @@ pub fn mip(bytes: &Arc>, page: u32, width_pt: f64, on_screen: f64) -> Op Some(pm) } +/// [`mip`]'s level for a JPEG, decoded at 1/2, 1/4 or 1/8 of its size straight from its blocks +/// and halved from there, so a screen-sized level needs no full-size decode. `None` when the +/// full size is drawn, for other formats and for JPEGs only a full decode reads (CMYK). +fn reduced(bytes: &Arc>, page: u32, width_pt: f64, on_screen: f64) -> Option> { + if page != 0 || !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let id = Arc::as_ptr(bytes) as usize; + if with(|c| c.bad.contains_key(&(id, page))) { + return None; + } + // The level `mip` picks from a full decode, with each level's size as `halve` gives it. + let (mut w, mut h) = crate::image_size(bytes)?; + let ppt = w as f64 / width_pt.max(1e-6); + let mut sizes = vec![(w, h)]; + while ppt / (1u64 << sizes.len()) as f64 >= on_screen * 1.2 && w > 64 && h > 64 && sizes.len() <= 8 { + (w, h) = ((w / 2).max(1), (h / 2).max(1)); + sizes.push((w, h)); + } + let level = u8::try_from(sizes.len() - 1).ok()?; + if level == 0 { + return None; + } + if let Some(pm) = lookup((id, page, level)) { + return Some(pm); + } + let shrink = level.min(designcraft_images::MAX_REDUCED_SHRINK); + let mut pm = match lookup((id, page, shrink)) { + Some(p) => p, + None => { + // Cut to `halve`'s size (it rounds down, the decoder up), as the level a full + // decode makes. + let (sw, sh) = sizes[usize::from(shrink)]; + let p = Arc::new(crop(decode_pixmap_reduced(bytes, shrink)?, sw, sh)?); + store(bytes, page, shrink, p.clone()); + p + } + }; + for l in shrink + 1..=level { + pm = match lookup((id, page, l)) { + Some(p) => p, + None => { + let half = Arc::new(halve(&pm)); + store(bytes, page, l, half.clone()); + half + } + }; + } + Some(pm) +} + +/// The top-left `w` × `h` of `pm`; `None` when `pm` is smaller. +fn crop(pm: Pixmap, w: u32, h: u32) -> Option { + let (pw, ph) = (u32::from(pm.width()), u32::from(pm.height())); + if (pw, ph) == (w, h) { + return Some(pm); + } + if w > pw || h > ph || w == 0 || h == 0 { + return None; + } + let (pw, w, h) = (pw as usize, w as usize, h as usize); + let data: Vec<_> = pm.data().chunks_exact(pw).take(h).flat_map(|row| &row[..w]).copied().collect(); + Some(Pixmap::from_parts(data, u16::try_from(w).ok()?, u16::try_from(h).ok()?)) +} + /// Drop every decoded image. pub fn clear() { with(|c| { @@ -176,4 +247,58 @@ mod tests { let last = &buffers[MAX_BAD + 9]; assert!(c.bad.contains_key(&(Arc::as_ptr(last) as usize, 0))); } + + fn jpeg(w: u32, h: u32) -> Arc> { + let img = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, 128])); + let mut out = Vec::new(); + img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90)).unwrap(); + Arc::new(out) + } + + #[test] + fn screen_levels_of_a_jpeg_need_no_full_size_decode() { + let bytes = jpeg(1024, 768); + let id = Arc::as_ptr(&bytes) as usize; + // 1 px per point drawn at 0.1: level 3, an eighth, read straight from the blocks. + let pm = mip(&bytes, 0, 1024.0, 0.1).unwrap(); + assert_eq!((pm.width(), pm.height()), (128, 96)); + assert!(lookup((id, 0, 0)).is_none(), "no full-size decode is kept"); + // Close to the full decode halved three times. + let mut want = decode_pixmap_page(&bytes, 0).unwrap(); + for _ in 0..3 { + want = halve(&want); + } + let diff: u64 = pm.data().iter().zip(want.data()).map(|(p, q)| u64::from(p.r.abs_diff(q.r)) + u64::from(p.g.abs_diff(q.g))).sum(); + assert!(diff / (2 * 128 * 96) <= 3, "mean difference {}", diff / (2 * 128 * 96)); + // Smaller levels are halved from that eighth, down to 64 px as before. + let small = mip(&bytes, 0, 1024.0, 0.02).unwrap(); + assert_eq!((small.width(), small.height()), (64, 48)); + assert!(lookup((id, 0, 0)).is_none(), "no full-size decode is kept"); + // The full size is still a full decode. + let full = mip(&bytes, 0, 1024.0, 1.0).unwrap(); + assert_eq!((full.width(), full.height()), (1024, 768)); + } + + #[test] + fn reduced_levels_of_odd_sized_jpegs_have_the_halved_size() { + let bytes = jpeg(1001, 771); + let mut want = decode_pixmap_page(&bytes, 0).unwrap(); + for _ in 0..3 { + want = halve(&want); + } + let pm = mip(&bytes, 0, 1001.0, 0.1).unwrap(); + assert!(lookup((Arc::as_ptr(&bytes) as usize, 0, 0)).is_none()); + assert_eq!((pm.width(), pm.height()), (want.width(), want.height())); + assert_eq!((pm.width(), pm.height()), (125, 96)); + } + + #[test] + fn a_jpeg_that_does_not_decode_is_remembered_as_failed() { + let mut broken = jpeg(256, 256).to_vec(); + broken.truncate(40); + let bytes = Arc::new(broken); + assert!(mip(&bytes, 0, 256.0, 0.1).is_none()); + assert!(with(|c| c.bad.contains_key(&(Arc::as_ptr(&bytes) as usize, 0)))); + assert!(mip(&bytes, 0, 256.0, 0.1).is_none()); + } }