mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
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:
1 parent
f5f7084fc3
commit
d3a073470c
9 files changed
+274
-1
No files matched your search
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user