Images: decode JPEG screen proxies at a reduced size

A placed JPEG drawn at a fraction of its pixel size was decoded at full
size first and then halved down to the level the screen needs. For a
24 MP photo that is a 96 MB decode per image, and with several such
images on a page the decoded pixels outgrow the image cache.

A YCbCr or grayscale JPEG whose wanted level is a half, a quarter or an
eighth of its size (or smaller) is now decoded at that size straight
from its compressed blocks with jpeg-decoder's scaled IDCT, cut to the
size the halving path gives the same level, and halved from there for
smaller levels. The embedded ICC profile is applied as in a full
decode. CMYK and YCCK JPEGs, other formats and the full size still
decode in full.
This commit is contained in:
dotCooCoo committed 2026-11-12 10:18:07 -08:00
1 parent f5f7084fc3
commit d3a073470c
9 files changed
+274 -1

No files matched your search

Generated
+7
View File
@@ -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"
+1
View File
@@ -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.
+2
View File
@@ -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"] }
+22
View File
@@ -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());
}
+38
View File
@@ -62,6 +62,44 @@ pub(crate) fn decode_cmyk(bytes: &[u8]) -> Option<(CmykRaster, Option<Vec<u8>>)>
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<u8> = 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;
+18
View File
@@ -442,6 +442,24 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
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<image::RgbaImage> {
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.
+50
View File
@@ -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<u8> {
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::<u64>();
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));
}
}
}
+11 -1
View File
@@ -19,7 +19,17 @@ pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option<Pixmap> {
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<Pixmap> {
premultiplied(&designcraft_images::decode_rgba_reduced(bytes, shrink)?)
}
fn premultiplied(img: &image::RgbaImage) -> Option<Pixmap> {
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
+125
View File
@@ -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<Vec<u8>>, page: u32) -> Option<Arc<Pixmap>> {
/// 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<Vec<u8>>, page: u32, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
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<Vec<u8>>, 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<Vec<u8>>, page: u32, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
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<Pixmap> {
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<Vec<u8>> {
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());
}
}