From 8147d33f56c0fc6549e405594a4f30caf68c9213 Mon Sep 17 00:00:00 2001 From: Simon Hull <5488089+simonhull@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:35:24 -0600 Subject: [PATCH] Images: a reduced image is shrunk before it is separated into ink planes (#4155) With image mips on (the canvas, navigator and layer thumbnails), an image was colorized, separated into the frame's ink plane and shown through Separations Preview's plates at its full size, and only then shrunk to the mip drawn. The 141-megapixel image of #4155 made 1.6 GB of planes a frame, more than the image store holds, so every frame made them again. The mip is now picked first and everything else is made from it, under the keys `kept_mip` and `nearest_kept` already look up. A CMYK image's own inks are halved with its pixels (`ink::key_inks`, `images::half_inks`), so it still paints them rather than inks separated from its displayed colours. Colour bitmap glyphs follow the same order. On that file the app's first frame went from 9.2 s to 1.6 s and the sharp frame after a pan from 3.9 s to 8 ms (M3 Pro, one run each). Fine detail now shows its true average: 1-pixel black and white lines show mid grey, where separating first showed them 44 levels darker. --- crates/render/src/blobs.rs | 14 +++--- crates/render/src/images.rs | 49 +++++++++++++------ crates/render/src/ink.rs | 18 ++++--- crates/render/src/proof.rs | 2 +- crates/render/src/renderer/images.rs | 23 +++++---- crates/render/src/renderer/text.rs | 13 ++--- crates/render/src/tests/memory.rs | 72 ++++++++++++++++++++++++++++ docs/gaps.md | 8 +++- docs/target-app-parity.md | 4 +- 9 files changed, 157 insertions(+), 46 deletions(-) diff --git a/crates/render/src/blobs.rs b/crates/render/src/blobs.rs index 88d721638..99afde077 100644 --- a/crates/render/src/blobs.rs +++ b/crates/render/src/blobs.rs @@ -77,18 +77,18 @@ pub(crate) fn mip_level(m: Affine, w: u16, h: u16) -> u8 { } /// Image pixels `pm` (stored as `key`) to draw through `m` (unit square → device pixels), shrunk -/// by halves while still at least as fine as the device pixels ([`images::mip_level`]); the -/// pixels drawn are marked used in the store. -pub(crate) fn image_mip(key: &images::ImageKey, pm: Arc, m: Affine) -> Arc { +/// by halves while still at least as fine as the device pixels ([`images::mip_level`]), and their +/// key; the pixels drawn are marked used in the store. +pub(crate) fn image_mip(key: images::ImageKey, pm: Arc, m: Affine) -> (images::ImageKey, Arc) { let level = mip_level(m, pm.width(), pm.height()); let mut s = images::store(); if level == 0 { - let _ = s.get(key); // Marks the full pixels used (they were only looked at). - return pm; + let _ = s.get(&key); // Marks the full pixels used (they were only looked at). + return (key, pm); } let at = |l: u8| images::ImageKey { mip: l, ..key.clone() }; if let Some(p) = s.get(&at(level)) { - return p; + return (at(level), p); } // From the finest level kept below it. let (mut l, mut cur) = (1..level).rev().find_map(|l| s.peek(&at(l)).map(|p| (l, p))).unwrap_or((0, pm)); @@ -98,7 +98,7 @@ pub(crate) fn image_mip(key: &images::ImageKey, pm: Arc, m: Affine) -> A let half = Arc::new(images::half(&cur)); cur = images::store().insert(at(l), half); } - cur + (at(level), cur) } /// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible, diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index a187be25e..644325f4a 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -15,6 +15,7 @@ use std::collections::HashMap; use std::sync::{Arc, LazyLock, Mutex, MutexGuard, Weak}; use vectorcraft_doc::ImageBlob; +use vectorcraft_doc::cmyk::Inks; use vello_cpu::Pixmap; use vello_cpu::color::PremulRgba8; @@ -375,23 +376,43 @@ pub fn newly_undecodable<'a>(blobs: impl IntoIterator) -> /// `pm` at half the size (rounded up), each pixel the mean of the 2×2 it covers (the last row and /// column repeat on odd sizes). pub(crate) fn half(pm: &Pixmap) -> Pixmap { - let (w, h) = (pm.width() as usize, pm.height() as usize); - let (ow, oh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1)); - let src = pm.data(); - let at = |x: usize, y: usize| { - src.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().unwrap_or(PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }) - }; - let mut data = Vec::with_capacity(ow * oh); - for y in 0..oh { - for x in 0..ow { - let q = [at(2 * x, 2 * y), at(2 * x + 1, 2 * y), at(2 * x, 2 * y + 1), at(2 * x + 1, 2 * y + 1)]; - let mean = |c: fn(&PremulRgba8) -> u8| ((q.iter().map(|p| u32::from(c(p))).sum::() + 2) / 4) as u8; - data.push(PremulRgba8 { r: mean(|p| p.r), g: mean(|p| p.g), b: mean(|p| p.b), a: mean(|p| p.a) }); - } - } + let (data, ow, oh) = half_with(pm.data(), pm.width().into(), pm.height().into(), PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }, |q| PremulRgba8 { + r: mean(q.map(|p| p.r)), + g: mean(q.map(|p| p.g)), + b: mean(q.map(|p| p.b)), + a: mean(q.map(|p| p.a)), + }); Pixmap::from_parts(data, ow as u16, oh as u16) } +/// CMYK image ink amounts `inks` at half the size, as [`half`] halves pixels: each ink the mean +/// of the 2×2 it covers. `None` only if the halved size were no CMYK image's, which a halved +/// image's never is. +pub(crate) fn half_inks(inks: &Inks) -> Option { + let (w, h) = (usize::try_from(inks.width).ok()?, usize::try_from(inks.height).ok()?); + let (data, ow, oh) = + half_with(inks.data.as_chunks::<4>().0, w, h, [0; 4], |q| std::array::from_fn(|c| mean(q.map(|p| p.get(c).copied().unwrap_or(0))))); + Inks::new(u32::try_from(ow).ok()?, u32::try_from(oh).ok()?, data.into_flattened()) +} + +/// `w`×`h` texels `src` at half the size (rounded up, at least 1×1), each the `mean` of the 2×2 +/// it covers (the last row and column repeat on odd sizes; `blank` stands in for texels `src` +/// is short of), and that size. +fn half_with(src: &[T], w: usize, h: usize, blank: T, mean: impl Fn([T; 4]) -> T) -> (Vec, usize, usize) { + let (ow, oh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1)); + let at = |x: usize, y: usize| src.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().unwrap_or(blank); + let data = (0..oh) + .flat_map(|y| (0..ow).map(move |x| (x, y))) + .map(|(x, y)| mean([at(2 * x, 2 * y), at(2 * x + 1, 2 * y), at(2 * x, 2 * y + 1), at(2 * x + 1, 2 * y + 1)])) + .collect(); + (data, ow, oh) +} + +/// The mean of four channel values, rounded to nearest. +fn mean(v: [u8; 4]) -> u8 { + ((v.iter().map(|&c| u32::from(c)).sum::() + 2) / 4) as u8 +} + /// Pixels `pm` colorized with `rgb`: each pixel takes the color, as opaque as the pixel is dark /// (255 less its Rec. 709 luma) times its alpha, so white pixels are transparent. pub(crate) fn colorized(pm: &Pixmap, [r, g, b]: [u8; 3]) -> Pixmap { diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index d4a5629e5..c4e1d0bb7 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -395,6 +395,16 @@ pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option { inks_pixmap(&blob.cmyk()?, lut) } +/// The ink amounts behind image pixels `key` when they are CMYK image `blob`'s inks as shown (not +/// adjusted or greyscale): its own inks, halved as its pixels are to `key`'s mip +/// ([`images::half_inks`]). `None` for other pixels. +pub(crate) fn key_inks(key: &ImageKey, blob: Option<&ImageBlob>) -> Option { + if key.luts.0 == 0 || key.adjust.is_some() || key.grey { + return None; + } + (0..key.mip).try_fold(blob?.cmyk()?, |inks, _| images::half_inks(&inks)) +} + /// The separation tables of placed images ([`Ink::Cmy`], [`Ink::K`]) and their generation. fn separation() -> (Arc<[ProofLut; 2]>, u64) { cached(&SEPARATE, |cms| { @@ -432,10 +442,7 @@ fn darkened_image(key: &ImageKey, pm: &Arc, ink: Ink, amount: f32, blob: } let base = ink.base(); let display = matches!(base, Ink::Display | Ink::RichBlack); - let raw_cmyk = (key.luts.0 != 0 && key.adjust.is_none() && !key.grey) - .then(|| blob.and_then(ImageBlob::cmyk)) - .flatten() - .filter(|i| (i.width, i.height) == (u32::from(pm.width()), u32::from(pm.height()))); + let raw_cmyk = key_inks(key, blob).filter(|i| (i.width, i.height) == (u32::from(pm.width()), u32::from(pm.height()))); let sep = (!display).then(separation); let shown = (display && raw_cmyk.is_some()).then(display_lut); let mut out = Pixmap::new(pm.width(), pm.height()); @@ -487,8 +494,7 @@ pub(crate) fn ink_image(key: &ImageKey, pm: &Arc, ink: Ink, blob: Option } // CMYK images (shown through the display table) paint their own inks; the others are separated. let size = (pm.width() as u32, pm.height() as u32); - let cmyk = key.luts.0 != 0 && key.adjust.is_none() && !key.grey; - let inks = blob.filter(|_| cmyk).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size); + let inks = key_inks(key, blob).filter(|i| (i.width, i.height) == size); let planes = [Ink::Cmy, Ink::K].map(|plane| { let mut out = Pixmap::new(pm.width(), pm.height()); if let Some(inks) = &inks { diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 0eb25b720..15d5b4644 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -135,7 +135,7 @@ pub(crate) fn plate_image(lut: &PlateLut, key: &ImageKey, pm: &Pixmap, blob: Opt return (want, p); } let size = (u32::from(pm.width()), u32::from(pm.height())); - let raw = blob.filter(|_| key.luts.0 != 0 && key.adjust.is_none() && !key.grey).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size); + let raw = crate::ink::key_inks(key, blob).filter(|i| (i.width, i.height) == size); let mut out = pm.clone(); match raw { Some(inks) => { diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs index 15997e797..0f22fbae3 100644 --- a/crates/render/src/renderer/images.rs +++ b/crates/render/src/renderer/images.rs @@ -76,18 +76,21 @@ impl Renderer { Some(pm) } else { self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| { + // Shrunk first, so what is made of the pixels below is made of the ones drawn: a + // 141-megapixel photo is separated at the size it shows, not at its own (#4155). + let (key, pm) = if mips { image_mip(key, pm, unit) } else { (key, pm) }; + let blob = f.doc.image_blob(im); let (key, pm) = match tint { - Some(rgb) => colorized_pixmap(key, &pm, rgb, !mips), + Some(rgb) => colorized_pixmap(key, &pm, rgb), None => (key, pm), }; // A colorized image is already in the frame's ink ([`image_tint`]), and with // Separations Preview its color is already the visible plates' (`proof::prepare`). - let (key, pm) = if outline || tint.is_some() { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) }; - let (key, pm) = match f.plates.filter(|_| !outline && tint.is_none()) { - Some(lut) => proof::plate_image(lut, &key, &pm, f.doc.image_blob(im), !mips), - None => (key, pm), - }; - if mips { image_mip(&key, pm, unit) } else { pm } + let (key, pm) = if outline || tint.is_some() { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, blob, true) }; + match f.plates.filter(|_| !outline && tint.is_none()) { + Some(lut) => proof::plate_image(lut, &key, &pm, blob, true).1, + None => pm, + } }) }; if let Some(pm) = shown { @@ -320,10 +323,10 @@ fn shown_key(f: &Frame, key: images::ImageKey, grey: bool, tint: Option<[u8; 3]> } /// Image pixels `pm` (kept as `key`) colorized with `rgb` ([`images::colorized`]), kept with the -/// images, and their key. `touch` marks them used there. -fn colorized_pixmap(key: images::ImageKey, pm: &Pixmap, rgb: [u8; 3], touch: bool) -> (images::ImageKey, Arc) { +/// images and marked used there, and their key. +fn colorized_pixmap(key: images::ImageKey, pm: &Pixmap, rgb: [u8; 3]) -> (images::ImageKey, Arc) { let key = images::ImageKey { tint: Some(rgb), ..key }; - if let Some(p) = lookup(&key, touch) { + if let Some(p) = lookup(&key, true) { return (key, p); } let pm = images::store().insert(key.clone(), Arc::new(images::colorized(pm, rgb))); diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index a2488556a..e9dd7f664 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -304,13 +304,14 @@ impl Renderer { /// decoded once and kept with the images' pixels. fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) { let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return }; - let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true); - let (key, pm) = match f.plates { - Some(lut) => proof::plate_image(lut, &key, &pm, Some(&image.blob), true), - None => (key, pm), - }; + // Shrunk before it is inked, as images are (`draw_image_in`). let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64); - let pm = image_mip(&key, pm, unit); + let (key, pm) = image_mip(key, pm, unit); + let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true); + let pm = match f.plates { + Some(lut) => proof::plate_image(lut, &key, &pm, Some(&image.blob), true).1, + None => pm, + }; let sx = image.width as f64 / pm.width().max(1) as f64; let sy = image.height as f64 / pm.height().max(1) as f64; let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index f0c4b40e1..976709e75 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -2422,3 +2422,75 @@ fn compound_paths_draw_from_kept_paths_and_let_old_ones_go() { assert!(r.geom.len() < 1024); assert_eq!(Arc::strong_count(&old), 1, "the renderer no longer keeps the compound path drawn before the move"); } + +/// A `w`×`h` CMYK document showing `blob` as image `key` of `w`×`h` pixels, one point a pixel. +fn cmyk_doc_with(key: &str, blob: ImageBlob, w: u32, h: u32) -> Document { + let mut d = Document::new_with_mode(w as f64, h as f64, vectorcraft_doc::ColorMode::Cmyk); + d.images.insert(key.into(), blob); + let (id, layer) = (d.alloc_id(), d.layers[0].id); + let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + d +} + +/// #4155: an image drawn reduced on the ink planes is separated from the mip it draws, never at +/// its full size. Separating a 141-megapixel image for each plane before shrinking it took +/// seconds a frame, and its full-size planes overflowed the store, so every frame did it again. +#[test] +fn a_reduced_image_is_separated_from_its_mip_not_its_full_pixels() { + let _fonts = fonts_steady(); + let d = cmyk_doc_with("img", ImageBlob::new("image/png", png(64, 64, 111)), 64, 64); + let on = RenderOptions { image_mips: true, ..Default::default() }; + let _ = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 64.0, 64.0), 0.25, &on); + let full = blob_key(&d.images["img"]).0; + let quarter = images::ImageKey { mip: 2, ..full.clone() }; + let s = images::store(); + for plane in [Ink::Cmy, Ink::K] { + assert!(s.peek(&ink::ink_key(&full, plane)).is_none(), "the {plane:?} plane was made at full size"); + let made = s.peek(&ink::ink_key(&quarter, plane)).unwrap_or_else(|| panic!("no {plane:?} plane of the quarter mip")); + assert_eq!((made.width(), made.height()), (16, 16)); + } +} + +/// A CMYK image drawn reduced paints its own inks, averaged, as at full size: not its displayed +/// colours separated again, which would change how much of its dark is black ink. +#[test] +fn a_reduced_cmyk_image_paints_its_own_inks() { + let _fonts = fonts_steady(); + // Inks a separation of their displayed colour would not give back: rich in black. + let ink = [0u8, 102, 255, 140]; + let blob = ImageBlob::cmyk_tiff(&vectorcraft_doc::cmyk::Inks::new(8, 8, ink.repeat(64)).unwrap()).unwrap(); + let d = cmyk_doc_with("inks", blob, 8, 8); + let on = RenderOptions { image_mips: true, ..Default::default() }; + let inks = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 8.0, 8.0), 0.25, &on); + for px in inks.as_chunks::<4>().0 { + assert!(px.iter().zip(ink).all(|(a, b)| a.abs_diff(b) <= 1), "{px:?} vs {ink:?}"); + } +} + +/// On the ink planes, a reduced image shows the average of its pixels as they show at full size: +/// 1-pixel black and white lines show as the mid grey they make. Separating the full pixels and +/// averaging the planes made that grey a dark brown, 44 levels off. +#[test] +fn a_reduced_image_on_the_ink_planes_shows_its_average() { + let _fonts = fonts_steady(); + let lines = image::RgbaImage::from_fn(64, 64, |x, _| image::Rgba(if x % 2 == 0 { [0, 0, 0, 255] } else { [255; 4] })); + let mut bytes = vec![]; + lines.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let mut d = cmyk_doc_with("lines", ImageBlob::new("image/png", bytes), 64, 64); + // Transparency beside the image: the document blends in CMYK, drawing on its ink planes. + let shape = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 0.0, 110.0, 10.0)); + let mut veil = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(Paint::solid(Color::gray(0.5)), Paint::None, 0.0)); + veil.opacity = 0.5; + let layer = d.layers[0].id; + d.insert(Some(layer), 1, veil).unwrap(); + let full = Renderer::new().render(&d, 64, 64, Affine::IDENTITY, &RenderOptions::default()).pixels; + let on = RenderOptions { image_mips: true, ..Default::default() }; + let reduced = Renderer::new().render(&d, 16, 16, Affine::scale(0.25), &on).pixels; + for (i, px) in reduced.as_chunks::<4>().0.iter().enumerate() { + let (bx, by) = (i % 16 * 4, i / 16 * 4); + let block = |c: usize| (0..16).map(|j| u32::from(full[((by + j / 4) * 64 + bx + j % 4) * 4 + c])).sum::().div_ceil(16) as u8; + let want = [block(0), block(1), block(2)]; + assert!(px.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "pixel {i}: {px:?}, the full frame averages {want:?}"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 172234f16..0f98d7e8b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (`.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -649,6 +649,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G15 Performance budgets and a GPU renderer +- **Improved (#4155):** the canvas shrinks a reduced image to the mip it draws before separating it into ink planes, + colorizing it or proofing it, so that work is done at the size the image shows, not at its own. On the + 141-megapixel image of #4155 the first frame went from 9.2 s to 1.6 s and the sharp frame after a pan from 3.9 s + to 8 ms (measured in the app, M3 Pro, one run each). Still to do: the first frame still renders on the UI thread, + and at 100% zoom or more an image is still separated whole; tiles would bound that by the view. - **Improved (2026-11-02):** the renderer keeps a compound path's assembled path with the node, as it keeps a plain path's, instead of assembling its members again in every frame; the path is assembled into a buffer sized up front. Unused geometry entries are dropped after a few frames however few there are (before, only past 1,024 entries), so @@ -759,6 +764,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | G15: a reduced image is shrunk before it is separated into ink planes (#4155); first frame 9.2 s → 1.6 s, sharp frame after a pan 3.9 s → 8 ms on that file. Estimate unchanged. | | 2026-11-05 | minor | G7: document tabs are dragged along the tab strip to reorder them (`document.move`, #5663). Estimate unchanged. | | 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. | | 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bd1ce5e84..a820add61 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; Libraries panel colours keep their spot ink in every document, #5812; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Canvas: an image drawn reduced in a CMYK document is shrunk to the size it shows before it is separated into ink planes, not separated at full size and then shrunk (#4155). On the 141-megapixel image of #4155 the first frame went from 9.2 s to 1.6 s, and the sharp frame after a pan from 3.9 s to 8 ms (measured, M3 Pro, one run each). The same pixels show within 5 levels; fine detail such as 1-pixel lines now shows its true average. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a hex colour parameter (Outer Glow's Color, Inner Glow's, Drop Shadow's) shows a swatch beside the field that follows typed hex values; a double-click opens the Color Picker over the dialog on that colour, and its OK fills the field while Cancel comes back unchanged (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Libraries panel: a spot colour added to a library (from the selection or dragged from the Swatches panel) keeps its ink name, values and tint in the library file; used in any document, also a new one or after the original was closed, it links the paint to that document's spot swatch of the same name, adding it when the document has none of that colour, in one undo step (#5812). Test: `tests::library::a_spot_colour_stays_spot_in_another_document`. Scores and estimates unchanged. | | 2026-11-05 | minor | Eyedropper colour sampling from a path or compound path with one visible stroke no longer rebuilds its path merely to choose among stroke layers. Multiple strokes still sample the topmost one that reaches the point; colours, scores and estimates are unchanged. | @@ -1364,6 +1365,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Dragging objects with raster effects when zoomed in (#2558):** during a drag with a tool on the canvas, the renderer draws filtered content (Gaussian Blur, Feather and the Photoshop-style filters) within 1,000,000 pixels, at a lower resolution where it would need more, unless it can draw the content from an earlier frame's raster. In the desktop app it does the same for drop shadows, outer glows and inner glows. No raster made that way is kept. The frame after the drag renders them as a still frame does, and a dialog's preview, exports and printing render as before. `zoomed_drag_perf` times a drag's frames at 100% to 6400%. - **Dragging other art over a wide blur (#2558):** an effect raster too large to keep whole (a wide blur, a shadow or glow, or any blur zoomed in) is cropped to what can show in the view, or made at a lower resolution and resampled to the view's pixels, and kept while the view stays the same, so dragging other art over the object draws its effects from the still frame instead of re-rendering them at the drag's lower resolution. A drag stays on the render worker once the worker drew the frame shown, since only that renderer holds the rasters. `tests_viewraster` covers each effect, and `a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame` the canvas on and off the worker. Not yet: a worker frame shows one display frame after the overlays, and the first frame of an interaction can land on the renderer that has not made the rasters yet (follow-up: render every canvas frame on the worker, waiting briefly for fast ones). +- **Large images in CMYK documents (#4155):** the canvas draws a reduced image from a mip (halved while still at least as fine as the screen) and, in a CMYK document, separates that mip into its ink planes, colorizes it or shows it through Separations Preview's plates, instead of doing that to the full pixels and shrinking the result. A CMYK image's own inks are halved with its pixels, so it still paints them. Exports draw without mips and are unchanged. On the file of #4155 (one 11,339 × 12,424 image), measured on an M3 Pro under load, one run each: first frame 9.2 s → 1.6 s, opening to the first frame 11.7 s → 3.9 s, the sharp frame after a pan 3.9 s → 8 ms and after a zoom 3.5 s → 0.16 s; the fitted view's pixels differ by at most 5 levels (p99 2). `a_reduced_image_is_separated_from_its_mip_not_its_full_pixels`, `a_reduced_cmyk_image_paints_its_own_inks` and `a_reduced_image_on_the_ink_planes_shows_its_average` (`crates/render/src/tests/memory.rs`) cover the order, the inks and the colour: 1-pixel black and white lines show as the mid grey they average to, where separating first showed them 44 levels darker. - **Panning over a cropped effect raster:** when a pan makes a shadow, glow or filtered content raster cropped to the view again at the same zoom and rotation, the renderer makes it over a margin around the view too (up to half the view's longer side, within 8.4 million pixels), and later pans that keep the view inside that margin draw the raster instead of making it again. Zooming and the first frame at a zoom make the raster without the margin. The canvas pans by whole device pixels: the wheel, a trackpad, a touch screen and the scroll bars add the part of a pixel left over to the next scroll of the same view, and Hand tool and scroll bar thumb drags round the distance dragged. A Drop Shadow or Outer Glow raster holds the shadow or glow of group members outside the view, so a pan that shows a member draws its shadow or glow from the raster. `pans_within_a_cropped_rasters_margin_draw_it_kept` covers Drop Shadow, Gaussian Blur and Feather on a large image at 100%. `a_groups_shadow_raster_holds_the_members_outside_the_view` covers a group's members, `fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept` covers scrolls by fractions of a pixel, and `scrolls_and_pans_move_the_view_by_whole_device_pixels` covers the canvas. - **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.