diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cb78d96ba9..c286492236 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -61,6 +61,7 @@ pub mod range; pub mod rastersettings; mod reach; pub mod recolor; +pub mod resample; pub mod selection; pub mod separated; pub mod setup; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index b2f46700eb..80740b7cc2 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -229,7 +229,8 @@ fn proxy_png(bytes: &[u8]) -> Option> { /// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG. fn proxy_of(img: &image::DynamicImage) -> Option> { - let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE); + let (w, h) = crate::resample::fit_size(img.width(), img.height(), PROXY_SIZE, PROXY_SIZE); + let small = crate::resample::averaged(img, w, h); let mut png = Vec::new(); small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; Some(png) @@ -732,4 +733,19 @@ mod tests { image(&mut d, "c", None); assert_eq!(d.linked_only_images().into_iter().collect::>(), ["a"]); } + + /// The preview of an image with clear pixels keeps the color of its opaque ones: the clear + /// black beside them does not darken them, whichever way the preview is made. + #[test] + fn a_preview_keeps_the_color_beside_clear_pixels() { + let mut full = vec![]; + image::RgbaImage::from_fn(1024, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut Cursor::new(&mut full), image::ImageFormat::Png) + .unwrap(); + for blob in [ImageBlob::png(full.clone()).with_proxy(), ImageBlob::png(full.clone()).checked_with_proxy(true).unwrap()] { + let p = image::load_from_memory(blob.proxy.as_ref().unwrap()).unwrap().into_rgba8(); + assert_eq!(p.dimensions(), (PROXY_SIZE, 1)); + assert!(p.pixels().all(|q| q.0 == [200, 30, 30, 128]), "{:?}", p.get_pixel(0, 0)); + } + } } diff --git a/crates/doc/src/resample.rs b/crates/doc/src/resample.rs new file mode 100644 index 0000000000..bc72cce1db --- /dev/null +++ b/crates/doc/src/resample.rs @@ -0,0 +1,259 @@ +//! Scaling images whose pixels carry straight (not premultiplied) alpha. +//! +//! `image`'s filtered resampling filters each channel on its own, which is correct only for +//! premultiplied pixels, and its thumbnail averages each channel on its own too, so on +//! straight-alpha pixels the color under clear pixels bleeds into the opaque ones next to them +//! (clear black darkens a cut-out's edge). [`resized`], [`fitted`] and [`averaged`] weight each +//! pixel's color by its alpha while scaling, as the streaming shrink in the renderer does. + +use image::imageops::FilterType; +use image::{DynamicImage, RgbaImage}; + +/// How [`resized`] makes each new pixel from the old ones. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Resample { + /// `image`'s filtered resampling with this filter. + Filter(FilterType), + /// The mean of the old pixels each new one covers ([`averaged`]). + Average, +} + +/// `img` scaled to `w` × `h` pixels as `how` says, each pixel's color weighted by its alpha. +/// Filtered or averaged, an image with clear or partly clear pixels comes back as 8-bit RGBA +/// whatever its own pixel format, and a pixel that ends up wholly clear is transparent black. An +/// image without clear pixels is scaled as `image` scales it, in its own pixel format. +/// Nearest-neighbor picking leaves the pixels as they are, in their own format, and is not +/// weighted. Filtered, the colors keep the precision of premultiplied 8-bit pixels, and an 8-bit +/// RGBA image is premultiplied in place. +pub fn resized(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + if (w, h) == (img.width(), img.height()) { + return img; + } + let f = match how { + Resample::Average => return averaged(&img, w, h), + Resample::Filter(f) => f, + }; + if f == FilterType::Nearest || opaque(&img) { + return img.resize_exact(w, h, f); + } + let mut rgba = img.into_rgba8(); + for p in rgba.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = times_alpha(*c, *a); + } + } + let mut out = image::imageops::resize(&rgba, w, h, f); + for p in out.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = over_alpha(*c, *a); + } + } + DynamicImage::ImageRgba8(out) +} + +/// [`resized`] to the largest size that fits `w` × `h` pixels and keeps `img`'s proportions, the +/// size `image`'s own `resize` and `thumbnail` pick. +pub fn fitted(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + let (nw, nh) = fit_size(img.width(), img.height(), w, h); + resized(img, nw, nh, how) +} + +/// `img` scaled to `w` × `h` pixels, each new pixel the mean of the old pixels it covers, each old +/// pixel counted by the area of it covered and its color weighted by its alpha. `img` is read one +/// row at a time, so besides the result this takes one row of 8-bit RGBA at most. An image +/// without clear pixels is scaled as `image`'s thumbnail scales it. +pub fn averaged(img: &DynamicImage, w: u32, h: u32) -> DynamicImage { + let (sw, sh) = (img.width(), img.height()); + if opaque(img) || w == 0 || h == 0 || sw == 0 || sh == 0 { + return img.thumbnail_exact(w, h); + } + // Per new pixel of the row being made: red, green and blue times alpha, alpha (all times the + // area covered), and the area covered. + let mut sums = vec![([0f64; 4], 0f64); w as usize]; + let mut out = Vec::with_capacity(w as usize * h as usize * 4); + for ty in 0..h { + for (y, wy) in covered(ty, sh, h) { + let row = rgba_row(img, y as u32); + for ((s, area), tx) in sums.iter_mut().zip(0..w) { + for (x, wx) in covered(tx, sw, w) { + let Some(p) = row.get(x * 4..x * 4 + 4) else { continue }; + let k = wx * wy; + let a = f64::from(p[3]) * k; + s[0] += f64::from(p[0]) * a; + s[1] += f64::from(p[1]) * a; + s[2] += f64::from(p[2]) * a; + s[3] += a; + *area += k; + } + } + } + let byte = |v: f64| v.round().clamp(0.0, 255.0) as u8; + for (s, area) in &mut sums { + out.extend(if s[3] <= 0.0 { [0; 4] } else { [byte(s[0] / s[3]), byte(s[1] / s[3]), byte(s[2] / s[3]), byte(s[3] / *area)] }); + (*s, *area) = ([0.0; 4], 0.0); + } + } + RgbaImage::from_raw(w, h, out).map_or_else(|| img.thumbnail_exact(w, h), DynamicImage::ImageRgba8) +} + +/// Row `y` of `img` as 8-bit RGBA: borrowed from an 8-bit RGBA image, converted from others. +fn rgba_row(img: &DynamicImage, y: u32) -> std::borrow::Cow<'_, [u8]> { + let n = img.width() as usize * 4; + match img { + DynamicImage::ImageRgba8(b) => std::borrow::Cow::Borrowed(b.as_raw().get(y as usize * n..(y as usize + 1) * n).unwrap_or_default()), + other => std::borrow::Cow::Owned(other.crop_imm(0, y, other.width(), 1).into_rgba8().into_raw()), + } +} + +/// The old pixels that new pixel `t` of `tn` covers along a side of `n` old pixels, each with the +/// length of it covered (in old pixels), worked out as they are read so that nothing is stored. +fn covered(t: u32, n: u32, tn: u32) -> impl Iterator { + let scale = f64::from(n) / f64::from(tn); + let (a, b) = (f64::from(t) * scale, (f64::from(t) + 1.0) * scale); + let (first, end) = (a.floor() as u32, (b.ceil() as u32).min(n)); + (first..end.max(first + 1).min(n)).filter_map(move |i| { + let len = b.min(f64::from(i) + 1.0) - a.max(f64::from(i)); + (len > 0.0).then_some((i as usize, len)) + }) +} + +/// Each 4-channel pixel of `px` with all four channels multiplied by its value in `alpha` (one +/// sample a pixel, 0 to 255): ink amounts or colors made ready for scaling beside a separate alpha +/// channel. Pixels past the end of `alpha` are left as they are. +pub fn premultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = times_alpha(*c, a); + } + } +} + +/// [`premultiply`] undone after scaling: each 4-channel pixel of `px` divided by its value in the +/// scaled `alpha`, and 0 where that is 0. +pub fn unpremultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = over_alpha(*c, a); + } + } +} + +/// Does `img` have no alpha channel, or is every pixel of it opaque? +fn opaque(img: &DynamicImage) -> bool { + match img { + DynamicImage::ImageRgba8(b) => b.pixels().all(|p| p[3] == u8::MAX), + DynamicImage::ImageLumaA8(b) => b.pixels().all(|p| p[1] == u8::MAX), + DynamicImage::ImageRgba16(b) => b.pixels().all(|p| p[3] == u16::MAX), + DynamicImage::ImageLumaA16(b) => b.pixels().all(|p| p[1] == u16::MAX), + DynamicImage::ImageRgba32F(b) => b.pixels().all(|p| p[3] >= 1.0), + other => !other.color().has_alpha(), + } +} + +/// Channel `c` times alpha `a`, both 0 to 255. +fn times_alpha(c: u8, a: u8) -> u8 { + ((u16::from(c) * u16::from(a) + 127) / 255) as u8 +} + +/// Premultiplied channel `c` divided by alpha `a` (0 when `a` is 0). +fn over_alpha(c: u8, a: u8) -> u8 { + if a == 0 { + return 0; + } + let a = u32::from(a); + ((u32::from(c) * 255 + a / 2) / a).min(255) as u8 +} + +/// The largest size of at most `nw` × `nh` with the proportions of `w` × `h`, at least a pixel a +/// side. +pub fn fit_size(w: u32, h: u32, nw: u32, nh: u32) -> (u32, u32) { + if w == 0 || h == 0 { + return (nw.max(1), nh.max(1)); + } + let ratio = (f64::from(nw) / f64::from(w)).min(f64::from(nh) / f64::from(h)); + let side = |n: u32| ((f64::from(n) * ratio).round() as u32).max(1); + (side(w), side(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 4 × 2 image whose left half is opaque red and whose right half is clear black. + fn half_clear() -> DynamicImage { + DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })) + } + + /// Is the color of `p`, premultiplied by its alpha, within a step of `rgb` premultiplied by the + /// same alpha (the precision of premultiplied 8-bit pixels)? + fn close(p: [u8; 4], rgb: [u8; 3]) -> bool { + p[..3].iter().zip(rgb).all(|(&c, want)| times_alpha(c, p[3]).abs_diff(times_alpha(want, p[3])) <= 1) + } + + #[test] + fn averaging_keeps_the_color_of_the_opaque_pixels() { + let p = resized(half_clear(), 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0; + assert!(close(p, [200, 30, 30]) && p[3].abs_diff(128) <= 1, "{p:?}"); + } + + /// Three pixels averaged into two: each new pixel takes one old pixel whole and half of the + /// clear one in the middle. + #[test] + fn averaging_counts_the_part_of_a_pixel_covered() { + let img = RgbaImage::from_fn(3, 1, |x, _| if x == 1 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([200, 30, 30, 255]) }); + let out = averaged(&DynamicImage::ImageRgba8(img), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 30, 30, 170], [200, 30, 30, 170]].concat()); + // Gray with alpha is read the same way. + let gray = image::GrayAlphaImage::from_fn(3, 1, |x, _| image::LumaA(if x == 1 { [0, 0] } else { [200, 255] })); + let out = averaged(&DynamicImage::ImageLumaA8(gray), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 200, 200, 170], [200, 200, 200, 170]].concat()); + } + + #[test] + fn filtering_keeps_the_color_of_the_opaque_pixels() { + for f in [FilterType::Triangle, FilterType::CatmullRom, FilterType::Lanczos3] { + let out = resized(half_clear(), 2, 1, Resample::Filter(f)).into_rgba8(); + for x in 0..2 { + let p = out.get_pixel(x, 0).0; + assert!(p[3] == 0 || close(p, [200, 30, 30]), "{f:?} pixel {x}: {p:?}"); + } + } + } + + #[test] + fn fitted_keeps_proportions() { + let img = DynamicImage::ImageRgba8(RgbaImage::new(400, 100)); + let out = fitted(img, 64, 64, Resample::Average); + assert_eq!((out.width(), out.height()), (64, 16)); + } + + #[test] + fn opaque_and_alpha_free_images_scale_as_image_scales_them() { + let rgb = DynamicImage::ImageRgb8(image::RgbImage::from_fn(5, 3, |x, y| image::Rgb([x as u8 * 40, y as u8 * 60, 7]))); + let ours = resized(rgb.clone(), 2, 2, Resample::Filter(FilterType::Triangle)); + assert_eq!(ours, rgb.resize_exact(2, 2, FilterType::Triangle)); + let opaque = DynamicImage::ImageRgba8(rgb.to_rgba8()); + assert_eq!(resized(opaque.clone(), 2, 2, Resample::Average), opaque.thumbnail_exact(2, 2)); + } + + #[test] + fn a_wholly_clear_result_is_transparent_black() { + let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, image::Rgba([90, 90, 90, 0]))); + assert_eq!(resized(img, 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0, [0, 0, 0, 0]); + } + + #[test] + fn premultiply_and_unpremultiply_scale_beside_a_separate_alpha() { + // Two pixels of inks, the second clear: averaged, the first one's inks remain. + let mut px = vec![40, 80, 120, 160, 0, 0, 0, 0]; + premultiply(&mut px, &[255, 0]); + let img = RgbaImage::from_raw(2, 1, px).unwrap(); + let mut out = image::imageops::thumbnail(&img, 1, 1).into_raw(); + let alpha = image::imageops::thumbnail(&image::GrayImage::from_raw(2, 1, vec![255, 0]).unwrap(), 1, 1).into_raw(); + unpremultiply(&mut out, &alpha); + let want = [40, 80, 120, 160]; + assert!(out.iter().zip(want).all(|(&c, w)| times_alpha(c, alpha[0]).abs_diff(times_alpha(w, alpha[0])) <= 1), "{out:?} {alpha:?}"); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6529cf175d..beda6d8ade 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -490,6 +490,43 @@ fn trace_and_style_prompt_steps_run_in_order() { assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}"); } +/// A window capture and an artboard preview scaled down keep the color of opaque pixels beside +/// clear ones: the clear black beside them does not darken them. +#[test] +fn scaled_down_pictures_keep_the_color_beside_clear_pixels() { + struct Striped; + impl Backend for Striped { + fn call(&mut self, method: &str, _: Value) -> Result { + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = vectorcraft_format::base64_encode(png.get_ref()); + Ok(match method { + "document.inspect" => json!({"artboards": [{"width": 64, "height": 4}]}), + _ => json!({"pngBase64": png, "width": 64, "height": 4}), + }) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "test".into() + } + } + + let mut s = Server::new(Box::new(Striped)); + let shots = [("screenshot", json!({"window": true, "max_side": 16})), ("render_preview", json!({"max_side": 16}))]; + for (id, (tool, args)) in (1..).zip(shots) { + let r = call(&mut s, id, tool, args); + let data = + r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("{tool}: {r}"))["data"].as_str().unwrap(); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(data).unwrap()).unwrap().into_rgba8(); + assert_eq!(img.dimensions(), (16, 1), "{tool}"); + assert!(img.pixels().all(|p| p.0 == [200, 30, 30, 128]), "{tool}: {:?}", img.get_pixel(0, 0)); + } +} + /// `screenshot` and `render_preview` with `traceViews: true` draw an Image Trace object in the /// view set with imageTrace.setView, as the app's window does; without it they draw the tracing /// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White. diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index b1c5af70bb..8e514ac48d 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -4,6 +4,7 @@ use super::handlers::{ select_ids, transform, }; use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, json, writable_format}; +use vectorcraft_doc::resample::{Resample, fitted}; /// The `menu` argument of the command listings. pub(super) const MENU_FILTER: &str = @@ -445,7 +446,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result { let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?; // The render was asked for at most 4096 a side. let img = super::handlers::decode_png(&png, 8192).map_err(|e| format!("read preview: {e}"))?; - let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img }; + let img = if img.width() > max || img.height() > max { fitted(img, max, max, Resample::Average) } else { img }; let mut out = std::io::Cursor::new(Vec::new()); img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?; Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()}))) diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index 39d3f3f8e1..5aefa20d31 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -1,5 +1,6 @@ use super::dispatch::trace_views; use super::{Backend, Map, ToolResult, Value, json}; +use vectorcraft_doc::resample::{Resample, fitted}; // ---------- implementation ---------- @@ -521,7 +522,7 @@ fn fit_capture(png: Vec, max: Option, info: &mut Value) -> Result 1.0 { let nw = ((f64::from(w) / k).floor() as u32).max(1); let nh = ((f64::from(h) / k).floor() as u32).max(1); - image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle) + let how = Resample::Filter(image::imageops::FilterType::Triangle); + resized(image::DynamicImage::ImageRgba8(img), nw, nh, how).into_rgba8() } else { img }; @@ -447,4 +450,18 @@ mod tests { let f = Rgba::fitted(img); assert_eq!((f.width, f.height), (MAX_SIDE, 2)); } + + /// A large image scaled to fit keeps the color of its opaque pixels beside clear ones: the + /// clear black beside them does not darken them. + #[test] + fn large_images_keep_their_color_beside_clear_pixels() { + let img = + image::RgbaImage::from_fn(MAX_SIDE * 2, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let f = Rgba::fitted(img); + assert_eq!((f.width, f.height), (MAX_SIDE, 2)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in f.pixels.as_chunks::<4>().0 { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p).all(|(&want, &c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } + } } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index d1382449e5..d8b873364c 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -23,7 +23,8 @@ use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageRea use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; use vectorcraft_doc::ImageBlob; -use vectorcraft_doc::cmyk::jpeg_components; +use vectorcraft_doc::cmyk::{Inks, jpeg_components}; +use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply}; use crate::output::{CmykPixels, Pixels}; use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; @@ -277,15 +278,29 @@ fn jpeg_quality(r: &Rule, jpeg: bool) -> Option { } } -/// `img` resampled to `w` × `h` pixels as `r` says. +/// How `r` resamples an image. +fn resampling(r: &Rule) -> Resample { + match r.downsample { + Downsample::Average => Resample::Average, + Downsample::Subsample => Resample::Filter(FilterType::Nearest), + Downsample::Bicubic | Downsample::None => Resample::Filter(FilterType::CatmullRom), + } +} + +/// `img` resampled to `w` × `h` pixels as `r` says, the colors of an image with alpha weighted by +/// it ([`resized`]). fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { + resized(img, w, h, resampling(r)) +} + +/// The four channels of `img` resampled to `w` × `h` pixels as `r` says, each on its own. +fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { if (w, h) == (img.width(), img.height()) { return img; } - match r.downsample { - Downsample::Average => img.thumbnail_exact(w, h), - Downsample::Subsample => img.resize_exact(w, h, FilterType::Nearest), - Downsample::Bicubic | Downsample::None => img.resize_exact(w, h, FilterType::CatmullRom), + match resampling(r) { + Resample::Average => img.thumbnail_exact(w, h), + Resample::Filter(f) => img.resize_exact(w, h, f), } } @@ -403,17 +418,10 @@ pub(crate) fn recode_cmyk( let Some(inks) = blob.cmyk() else { return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) }; }; - let dims = (inks.width, inks.height); - let (width, height) = sized(&rule, dims, size); - let alpha = match alpha.map(|a| image::GrayImage::from_raw(inks.width, inks.height, a)) { - Some(Some(a)) => Some(resample(DynamicImage::ImageLuma8(a), &rule, (width, height)).into_luma8().into_raw()), - Some(None) => return Recoded { image: None, warning: None }, - None => None, - }; - // The four inks resample as the four channels of an RGBA image do. - let inks = match image::RgbaImage::from_raw(inks.width, inks.height, inks.data) { - Some(img) => resample(DynamicImage::ImageRgba8(img), &rule, (width, height)).into_rgba8().into_raw(), - None => return Recoded { image: None, warning: Some(CMYK_RGB) }, + let (width, height) = sized(&rule, (inks.width, inks.height), size); + let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) { + Ok(r) => r, + Err(warning) => return Recoded { image: None, warning }, }; let (samples, space) = match convert { Some(p) => p.convert(&inks), @@ -425,6 +433,36 @@ pub(crate) fn recode_cmyk( Recoded { image, warning: rule.instead } } +/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any. +type InksAndAlpha = (Vec, Option>); + +/// Ink amounts `inks` and their `alpha` (one sample a pixel) resampled to `to` as `r` says. The +/// inks under clear pixels don't count where neighbors blend: they are multiplied by the alpha +/// before resampling and divided by the resampled alpha after. `Err` with the warning when they +/// can't be read. +fn resampled_inks(inks: Inks, alpha: Option>, r: &Rule, to: (u32, u32)) -> Result> { + let dims = (inks.width, inks.height); + let weighted = to != dims && resampling(r) != Resample::Filter(FilterType::Nearest); + let mut data = inks.data; + let alpha = match alpha.map(|a| image::GrayImage::from_raw(dims.0, dims.1, a)) { + Some(Some(a)) => { + if weighted { + premultiply(&mut data, a.as_raw()); + } + Some(resample(DynamicImage::ImageLuma8(a), r, to).into_luma8().into_raw()) + } + Some(None) => return Err(None), + None => None, + }; + // The four inks resample as the four channels of an RGBA image do, each on its own. + let img = image::RgbaImage::from_raw(dims.0, dims.1, data).ok_or(Some(CMYK_RGB))?; + let mut inks = resample_channels(DynamicImage::ImageRgba8(img), r, to).into_rgba8().into_raw(); + if let Some(a) = alpha.as_deref().filter(|_| weighted) { + unpremultiply(&mut inks, a); + } + Ok((inks, alpha)) +} + /// The most one image read here may allocate (bytes), as the image reader allows by default. const MAX_DECODE: u64 = 512 << 20; @@ -482,3 +520,26 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual } Image::from_jpeg_with_icc(out.into(), icc.map(|i| i.to_vec().into()), interpolate).ok() } + +#[cfg(test)] +mod tests { + use super::*; + + /// The inks of a CMYK image with alpha, resampled, keep the amounts of the opaque pixels: the + /// inks under the clear pixels beside them don't thin them out. + #[test] + fn resampled_inks_keep_the_amounts_beside_clear_pixels() { + let data = [[40, 80, 120, 160], [0, 0, 0, 0]].repeat(8).concat(); + let alpha = [255, 0].repeat(8); + for method in ["average", "bicubic"] { + let c: CompressionSettings = serde_json::from_value(serde_json::json!({"color": {"downsample": method}})).unwrap(); + let inks = Inks::new(16, 1, data.clone()).unwrap(); + let (inks, alpha) = resampled_inks(inks, Some(alpha.clone()), &rule(&c, Kind::Color), (4, 1)).unwrap(); + let alpha = alpha.unwrap(); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for (p, &a) in inks.as_chunks::<4>().0.iter().zip(&alpha) { + assert!(a > 0 && p.iter().zip([40, 80, 120, 160]).all(|(&c, want)| m(c, a).abs_diff(m(want, a)) <= 1), "{method}: {p:?} at {a}"); + } + } + } +} diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index c02f3f5e1c..076c3c08c9 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -73,6 +73,38 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() { assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi"); } +/// An image with clear pixels, resampled, keeps the color of its opaque pixels: the clear black +/// beside them does not darken them. +#[test] +fn resampled_images_keep_their_color_beside_clear_pixels() { + let striped = image::RgbaImage::from_fn(600, 600, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let d = doc(encoded(striped.into(), ImageFormat::Png), "image/png"); + for method in ["average", "bicubic"] { + let mut settings: PdfSettings = + serde_json::from_value(json!({"compression": {"color": {"downsample": method, "ppi": 150, "abovePpi": 225, "compression": "zip"}}})) + .unwrap(); + settings.compression.compress_text = false; + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let rgb = file + .objects() + .into_iter() + .find_map(|o| match o { + Object::Stream(s) + if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") && s.dict().contains_key(b"SMask") => + { + Some(s.decoded().unwrap().into_owned()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{method}: a color image with a soft mask")); + assert_eq!(rgb.len(), 150 * 150 * 3, "{method}"); + for p in rgb.as_chunks::<3>().0 { + assert!(p[0].abs_diff(200) <= 3 && p[1].abs_diff(30) <= 3 && p[2].abs_diff(30) <= 3, "{method}: {p:?}"); + } + } +} + /// The width and height of each image XObject in `pdf`. fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> { let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 1d39ece10c..c130fbe346 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -1,6 +1,7 @@ //! Paint conversion (solid, gradients, patterns) and image decoding. use vectorcraft_color::{GradientKind, Paint}; +use vectorcraft_doc::resample::{Resample, resized}; use vectorcraft_geom::{Rect, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko::{self, ColorStop}; @@ -446,7 +447,7 @@ fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage { } let s = max as f64 / w.max(h) as f64; let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max)); - img.resize_exact(nw, nh, image::imageops::FilterType::Triangle) + resized(img, nw, nh, Resample::Filter(image::imageops::FilterType::Triangle)) } /// [`decode_pixmap_or_why`]'s pixels. @@ -504,7 +505,7 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option w as u32 || img.height() > h as u32 { - img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle) + resized(img, w as u32, h as u32, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; @@ -587,4 +588,35 @@ mod tests { assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC + 1), Err(Undecoded::NoRoom)); assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(())); } + + /// Opaque red beside clear black, every other column of a `w` × 1 image. + fn striped(w: u32) -> image::RgbaImage { + image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + } + + /// Does each pixel of `pm` hold red at its own coverage (premultiplied, within a step)? + fn red_at_its_coverage(pm: &vello_cpu::Pixmap) -> bool { + let m = |c: u8, a: u8| ((u16::from(c) * u16::from(a) + 127) / 255) as u8; + pm.data().iter().all(|p| p.r.abs_diff(m(200, p.a)) <= 1 && p.g.abs_diff(m(30, p.a)) <= 1 && p.b.abs_diff(m(30, p.a)) <= 1) + } + + /// A placed image drawn smaller keeps the color of its opaque pixels at its cut-out edges: the + /// clear pixels beside them do not darken them. + #[test] + fn an_image_drawn_smaller_keeps_its_color_beside_clear_pixels() { + let mut png = Vec::new(); + striped(8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let pm = decode_pixmap_fit(&png, 2, 1).unwrap(); + assert_eq!((pm.width(), pm.height()), (2, 1)); + assert!(pm.data().iter().all(|p| p.a > 0)); + assert!(red_at_its_coverage(&pm), "{:?}", pm.data()); + } + + /// An image wider than a pixmap may be is shrunk to fit with its colors weighted by alpha the same way. + #[test] + fn an_image_shrunk_to_fit_a_pixmap_keeps_its_color_beside_clear_pixels() { + let pm = decoded_pixmap(image::DynamicImage::ImageRgba8(striped(u32::from(u16::MAX) + 4465))).unwrap(); + assert_eq!(pm.width(), u16::MAX); + assert!(red_at_its_coverage(&pm)); + } } diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 3b55d34e11..9c10281783 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -30,6 +30,7 @@ use std::collections::HashMap; use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::Paint; +use vectorcraft_doc::resample::{Resample, fitted}; use vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic}; use crate::theme::Tokens; @@ -831,8 +832,7 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) } let tex = vectorcraft_format::base64_decode(png).and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()).map(|img| { - let img = if img.width().max(img.height()) > px { img.resize(px, px, image::imageops::FilterType::Triangle) } else { img }; - let rgba = img.to_rgba8(); + let rgba = shrunk_thumbnail(img, px); let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); ctx.load_texture(format!("library-{library}-{item}-{px}"), image, egui::TextureOptions::LINEAR) }); @@ -846,6 +846,13 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) tex } +/// Thumbnail `img` shrunk smoothly to at most `px` pixels a side, its colors weighted by its alpha +/// so that clear pixels don't darken the edges of the art, as RGBA. +fn shrunk_thumbnail(img: image::DynamicImage, px: u32) -> image::RgbaImage { + let img = if img.width().max(img.height()) > px { fitted(img, px, px, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; + img.into_rgba8() +} + /// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer). pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) { if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) { diff --git a/crates/ui-egui/src/panels/libraries/tests.rs b/crates/ui-egui/src/panels/libraries/tests.rs index 931a91a444..00ff1bf2cd 100644 --- a/crates/ui-egui/src/panels/libraries/tests.rs +++ b/crates/ui-egui/src/panels/libraries/tests.rs @@ -525,3 +525,16 @@ fn libraries_are_exported_and_imported_from_the_panel_menu() { assert_eq!(names, ["Brand", "Brand (2)"]); assert_eq!(app.ui.status, "Imported library Brand (2)"); } + +/// A graphic's thumbnail shrunk for its tile keeps the color of the art beside clear pixels: the +/// clear black beside it does not darken it. +#[test] +fn a_shrunk_thumbnail_keeps_its_color_beside_clear_pixels() { + let img = image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let small = shrunk_thumbnail(image::DynamicImage::ImageRgba8(img), 16); + assert_eq!(small.dimensions(), (16, 1)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in small.pixels() { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p.0).all(|(&want, c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } +}