mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
Merge pull request #4647 from storytold/fix/separations-images
Separations Preview: hidden plates leave raster images too (#4153)
This commit is contained in:
@@ -19,6 +19,7 @@ pub(crate) fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey,
|
||||
tint: None,
|
||||
ink: Ink::Display,
|
||||
luts: (lut.as_ref().map_or(0, |l| l.1), 0),
|
||||
plates: 0,
|
||||
mip: 0,
|
||||
};
|
||||
(key, lut.map(|l| l.0), id.size)
|
||||
|
||||
@@ -127,6 +127,8 @@ pub(crate) struct Frame<'a> {
|
||||
pub(crate) opts: &'a RenderOptions,
|
||||
/// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]).
|
||||
pub(crate) ink: Ink,
|
||||
/// With Separations Preview, the tables placed images show the visible plates through.
|
||||
pub(crate) plates: Option<&'a crate::proof::PlateLut>,
|
||||
}
|
||||
|
||||
impl Frame<'_> {
|
||||
|
||||
@@ -716,6 +716,7 @@ mod tests {
|
||||
px: 1.0,
|
||||
opts: &opts,
|
||||
ink: Ink::Display,
|
||||
plates: None,
|
||||
};
|
||||
let mut ctx = frame.offscreen_context(100, 100);
|
||||
let mut r = Renderer::new();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! pixels cached per renderer, a 24-megapixel photo would take 96 MB in each. Here they are kept
|
||||
//! once, keyed by the image's content (not the document's key for it, which another document may
|
||||
//! use for other pixels) and by what was made of them: greyscale (outline mode), recoloured by a
|
||||
//! colour adjustment, separated into ink planes, or shrunk ([`RenderOptions::image_mips`](crate::RenderOptions::image_mips)).
|
||||
//! colour adjustment, separated into ink planes, shown through Separations Preview's plates, or
|
||||
//! shrunk ([`RenderOptions::image_mips`](crate::RenderOptions::image_mips)).
|
||||
//!
|
||||
//! The store holds at most [`ImageStore::budget`] bytes, dropping the least recently used entries
|
||||
//! beyond that, except ones still held elsewhere (a render context drawing them): dropping those
|
||||
@@ -92,6 +93,9 @@ pub(crate) struct ImageKey {
|
||||
/// Generations of the colour tables the pixels were made with (0 = none): a CMYK image's
|
||||
/// display table, and the separation table of ink planes (see `ink`).
|
||||
pub luts: (u64, u64),
|
||||
/// Generation of the Separations Preview table the pixels show the visible plates through
|
||||
/// (`proof::PlateLut`; 0 = none).
|
||||
pub plates: u64,
|
||||
/// Halved this many times ([`half`]).
|
||||
pub mip: u8,
|
||||
}
|
||||
@@ -414,7 +418,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(content: &str) -> ImageKey {
|
||||
ImageKey { content: content.into(), adjust: None, grey: false, tint: None, ink: Ink::Display, luts: (0, 0), mip: 0 }
|
||||
ImageKey { content: content.into(), adjust: None, grey: false, tint: None, ink: Ink::Display, luts: (0, 0), plates: 0, mip: 0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -530,6 +530,7 @@ mod tests {
|
||||
tint: None,
|
||||
ink: Ink::Display,
|
||||
luts: (0, 0),
|
||||
plates: 0,
|
||||
mip: 0,
|
||||
};
|
||||
let pm =
|
||||
|
||||
@@ -181,6 +181,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
|
||||
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
|
||||
opts: &opts,
|
||||
ink,
|
||||
plates: None,
|
||||
};
|
||||
for a in &def.art {
|
||||
r.draw_arc(&mut ctx, &frame, a);
|
||||
|
||||
+126
-25
@@ -8,7 +8,9 @@
|
||||
//! renders as greyscale ink coverage (black = 100%); with several, the visible inks are
|
||||
//! composited through the proof profile, spots multiplied on top. Colours linked to the
|
||||
//! Registration swatch print their tint on every plate, spot plates included.
|
||||
//! Placed raster images are not separated (limitation).
|
||||
//! Placed raster images show their pixels' process inks the same way ([`plate_image`]): a CMYK
|
||||
//! image its own inks, an RGB one its pixels separated through the color settings. They carry
|
||||
//! no spot ink; a colorized image takes its color's plates, as a fill of it does.
|
||||
//! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that
|
||||
//! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with
|
||||
//! Darken, which approximates their inks printing over the inks below: a zero ink lets the
|
||||
@@ -19,51 +21,150 @@
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock, Weak};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak};
|
||||
|
||||
use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
|
||||
use vectorcraft_color::cms::{self, Cms, CmykLut, PROCESS_PLATES, ProofLut, lab};
|
||||
pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
|
||||
use vectorcraft_color::{Color, Swatch, SwatchGroup};
|
||||
pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
|
||||
use vectorcraft_doc::overprint::darken_overprints;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Shared};
|
||||
use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, Shared};
|
||||
use vello_cpu::Pixmap;
|
||||
use vello_cpu::color::PremulRgba8;
|
||||
|
||||
use crate::RenderOptions;
|
||||
use crate::blobs::lookup;
|
||||
use crate::images::{self, ImageKey};
|
||||
|
||||
/// Whether overprinting shows: Overprint Preview, or Separations Preview (which implies it).
|
||||
pub(crate) fn overprints(opts: &RenderOptions) -> bool {
|
||||
opts.overprint_preview || opts.proof.as_ref().is_some_and(|p| p.separations.is_some())
|
||||
}
|
||||
|
||||
/// What the `visible` plates print of `ink`, as display sRGB, without the spot inks over several
|
||||
/// plates: with one plate visible its ink as a grey (black = 100%), else the visible process inks
|
||||
/// through the proof profile.
|
||||
fn plate_rgb(c: &Cms, proof: &ProofSetup, visible: &[String], ink: &Inks) -> [f32; 3] {
|
||||
if let [one] = visible {
|
||||
return [1.0 - ink.on_plate(one); 3];
|
||||
}
|
||||
let mut cmyk = ink.cmyk;
|
||||
for (i, p) in PROCESS_PLATES.iter().enumerate() {
|
||||
if !visible.iter().any(|v| v == p) {
|
||||
cmyk[i] = 0.0;
|
||||
}
|
||||
}
|
||||
c.proof_cmyk_to_srgb(cmyk, proof)
|
||||
}
|
||||
|
||||
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link) -> Color {
|
||||
let ink = inks(doc, c, color, link, proof.intent);
|
||||
let rgb = if visible.len() == 1 {
|
||||
[1.0 - ink.on_plate(&visible[0]); 3]
|
||||
} else {
|
||||
let mut cmyk = ink.cmyk;
|
||||
for (i, p) in PROCESS_PLATES.iter().enumerate() {
|
||||
if !visible.iter().any(|v| v == p) {
|
||||
cmyk[i] = 0.0;
|
||||
let mut rgb = plate_rgb(c, proof, visible, &ink);
|
||||
// Over several plates the visible spot inks print on top.
|
||||
for name in visible.iter().filter(|v| visible.len() != 1 && !PROCESS_PLATES.contains(&v.as_str())) {
|
||||
let t = ink.spot_tint(name);
|
||||
if t > 0.0
|
||||
&& let Some(sc) = spot_color(doc, name)
|
||||
{
|
||||
let s = c.display_rgb(&sc);
|
||||
for i in 0..3 {
|
||||
rgb[i] *= 1.0 - t * (1.0 - s[i]);
|
||||
}
|
||||
}
|
||||
let mut rgb = c.proof_cmyk_to_srgb(cmyk, proof);
|
||||
for name in visible.iter().filter(|v| !PROCESS_PLATES.contains(&v.as_str())) {
|
||||
let t = ink.spot_tint(name);
|
||||
if t > 0.0
|
||||
&& let Some(sc) = spot_color(doc, name)
|
||||
{
|
||||
let s = c.display_rgb(&sc);
|
||||
for i in 0..3 {
|
||||
rgb[i] *= 1.0 - t * (1.0 - s[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
rgb
|
||||
};
|
||||
}
|
||||
let [r, g, b] = c.srgb_to_rgb(rgb);
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
/// Separations Preview's tables for placed images: what the visible plates show of display pixels
|
||||
/// and of a CMYK image's own inks ([`plate_rgb`]), for one proof setup, color settings and set of
|
||||
/// profiles. Each table is made when an image first needs it.
|
||||
pub(crate) struct PlateLut {
|
||||
proof: ProofSetup,
|
||||
cms: Arc<Cms>,
|
||||
/// [`cms::profiles_generation`].
|
||||
profiles: u64,
|
||||
/// What pixels shown through these tables are kept under ([`ImageKey::plates`]): never reused.
|
||||
pub(crate) generation: u64,
|
||||
/// Display sRGB → display sRGB.
|
||||
rgb: OnceLock<ProofLut>,
|
||||
/// Inks → XYZ (inks mix close to linearly in light, as in `ink::InkLut`).
|
||||
cmyk: OnceLock<CmykLut>,
|
||||
}
|
||||
|
||||
impl PlateLut {
|
||||
/// What the visible plates show of process inks `cmyk`, as display sRGB.
|
||||
fn shown(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let visible = self.proof.separations.as_deref().unwrap_or_default();
|
||||
plate_rgb(&self.cms, &self.proof, visible, &Inks { cmyk, spot: None, registration: false })
|
||||
}
|
||||
}
|
||||
|
||||
/// The tables [`plate_lut`] handed out last, the newest last: a few, so renderers drawing with
|
||||
/// different proof setups don't make them again and again.
|
||||
static PLATE_LUTS: Mutex<Vec<Arc<PlateLut>>> = Mutex::new(Vec::new());
|
||||
/// The last [`PlateLut::generation`] handed out.
|
||||
static PLATE_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The tables placed images show Separations Preview's visible plates through with `opts`, or
|
||||
/// `None` when it is off.
|
||||
pub(crate) fn plate_lut(opts: &RenderOptions) -> Option<Arc<PlateLut>> {
|
||||
let proof = opts.proof.as_ref().filter(|p| p.separations.is_some())?;
|
||||
let (c, profiles) = (cms::active(), cms::profiles_generation());
|
||||
let mut luts = PLATE_LUTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(l) = luts.iter().find(|l| Arc::ptr_eq(&l.cms, &c) && l.profiles == profiles && l.proof == *proof) {
|
||||
return Some(l.clone());
|
||||
}
|
||||
if luts.len() >= 4 {
|
||||
luts.remove(0);
|
||||
}
|
||||
let generation = PLATE_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let l = Arc::new(PlateLut { proof: proof.clone(), cms: c, profiles, generation, rgb: OnceLock::new(), cmyk: OnceLock::new() });
|
||||
luts.push(l.clone());
|
||||
Some(l)
|
||||
}
|
||||
|
||||
/// Image pixels `pm` (kept as `key`, from `blob` when it is a document's image as it is) as the
|
||||
/// visible plates show them through `lut`, kept with the images, and their key: a CMYK image shows
|
||||
/// its own inks, other pixels their inks separated with the proof's intent. `touch` marks them
|
||||
/// used there.
|
||||
pub(crate) fn plate_image(lut: &PlateLut, key: &ImageKey, pm: &Pixmap, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) {
|
||||
let want = ImageKey { plates: lut.generation, ..key.clone() };
|
||||
if let Some(p) = lookup(&want, touch) {
|
||||
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 mut out = pm.clone();
|
||||
match raw {
|
||||
Some(inks) => {
|
||||
let t = lut.cmyk.get_or_init(|| {
|
||||
CmykLut::build(|inks| {
|
||||
let [x, y, z] = lab::srgb_to_xyz(lut.shown(inks));
|
||||
[x, y, z, 0.0]
|
||||
})
|
||||
});
|
||||
for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) {
|
||||
let [x, y, z, _] = t.apply(p.map(|v| f32::from(v) / 255.0));
|
||||
let [r, g, b] = lab::xyz_to_srgb([x, y, z]).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
*o = PremulRgba8 { r, g, b, a: 255 };
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let t = lut.rgb.get_or_init(|| ProofLut::build(|rgb| lut.shown(lut.cms.srgb_to_cmyk(rgb, lut.proof.intent))));
|
||||
for px in out.data_mut().iter_mut().filter(|p| p.a > 0) {
|
||||
let a = f32::from(px.a);
|
||||
let un = |v: u8| (f32::from(v) / a).min(1.0);
|
||||
let [r, g, b] = t.apply([un(px.r), un(px.g), un(px.b)]).map(|v| (v.clamp(0.0, 1.0) * a).round() as u8);
|
||||
(px.r, px.g, px.b) = (r, g, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = images::store().insert(want.clone(), Arc::new(out));
|
||||
(want, out)
|
||||
}
|
||||
|
||||
/// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame
|
||||
/// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for
|
||||
/// it, and what that art was made with. The kept nodes keep their addresses: no other node gets
|
||||
|
||||
@@ -115,7 +115,8 @@ impl Renderer {
|
||||
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
|
||||
let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display };
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen };
|
||||
let plates = proof::plate_lut(opts);
|
||||
let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, plates: plates.as_deref() };
|
||||
self.begin_frame();
|
||||
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
|
||||
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
|
||||
@@ -306,7 +307,7 @@ impl Renderer {
|
||||
let mut ctx = single_threaded_context(w, h);
|
||||
let opts = RenderOptions::default();
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display };
|
||||
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display, plates: None };
|
||||
self.begin_frame();
|
||||
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
|
||||
self.clip_paths.clear();
|
||||
@@ -349,7 +350,8 @@ impl Renderer {
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
|
||||
}
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display };
|
||||
let frame =
|
||||
Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display, plates: None };
|
||||
self.begin_frame();
|
||||
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
|
||||
self.clip_paths.clear();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Ink, Node, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, blob_pixmap,
|
||||
fill_rule, image_mip, ink, lookup, mip_level, peniko, vello_cpu,
|
||||
fill_rule, image_mip, ink, lookup, mip_level, peniko, proof, vello_cpu,
|
||||
};
|
||||
use crate::images;
|
||||
|
||||
@@ -74,8 +74,13 @@ impl Renderer {
|
||||
Some(rgb) => colorized_pixmap(key, &pm, rgb, !mips),
|
||||
None => (key, pm),
|
||||
};
|
||||
// A colorized image is already in the frame's ink ([`image_tint`]).
|
||||
// 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 }
|
||||
})
|
||||
};
|
||||
@@ -249,8 +254,7 @@ impl Renderer {
|
||||
m: Affine,
|
||||
) -> Option<Arc<Pixmap>> {
|
||||
let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
|
||||
let key = images::ImageKey { tint, ..key };
|
||||
let key = if grey || tint.is_some() { key } else { ink::ink_key(&key, f.ink) };
|
||||
let key = shown_key(f, key, grey, tint);
|
||||
let level = mip_level(m, w, h);
|
||||
if level == 0 {
|
||||
return None;
|
||||
@@ -272,8 +276,7 @@ impl Renderer {
|
||||
m: Affine,
|
||||
) -> Option<Arc<Pixmap>> {
|
||||
let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
|
||||
let key = images::ImageKey { tint, ..key };
|
||||
let key = if grey || tint.is_some() { key } else { ink::ink_key(&key, f.ink) };
|
||||
let key = shown_key(f, key, grey, tint);
|
||||
let level = mip_level(m, w, h);
|
||||
let s = images::store();
|
||||
let at = |l: u8| s.peek(&images::ImageKey { mip: l, ..key.clone() });
|
||||
@@ -293,6 +296,21 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key [`Renderer::draw_image_in`] gives the pixels it draws of image pixels `key`: in
|
||||
/// greyscale with `grey` (the outline view's) or colorized with `tint` as they are, else painted
|
||||
/// with the frame's ink and shown through its Separations Preview plates.
|
||||
fn shown_key(f: &Frame, key: images::ImageKey, grey: bool, tint: Option<[u8; 3]>) -> images::ImageKey {
|
||||
let key = images::ImageKey { tint, ..key };
|
||||
if grey || tint.is_some() {
|
||||
return key;
|
||||
}
|
||||
let key = ink::ink_key(&key, f.ink);
|
||||
match f.plates {
|
||||
Some(lut) => images::ImageKey { plates: lut.generation, ..key },
|
||||
None => key,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Pixmap>) {
|
||||
|
||||
@@ -246,6 +246,10 @@ impl Renderer {
|
||||
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),
|
||||
};
|
||||
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 sx = image.width as f64 / pm.width().max(1) as f64;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Grayscale and 1-bit images colorized by a fill color draw in that color, white pixels
|
||||
//! transparent.
|
||||
//! transparent; and how Separations Preview shows images.
|
||||
|
||||
use crate::*;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
@@ -153,14 +153,121 @@ fn separations_preview_shows_the_image_on_its_spot_plate() {
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }));
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let shown = |plates: &[&str]| {
|
||||
let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() };
|
||||
let opts = RenderOptions { proof: Some(proof), overprint_preview: true, smooth_images: false, ..Default::default() };
|
||||
Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &opts).pixel(10, 10)
|
||||
};
|
||||
let shown = |plates: &[&str]| Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &separations(plates)).pixel(10, 10);
|
||||
let on = shown(&["Ink"]);
|
||||
// One plate shows as black ink.
|
||||
assert!(on[..3].iter().all(|v| *v < 128), "the Ink plate shows the image, got {on:?}");
|
||||
let off = shown(&["Cyan", "Magenta", "Yellow", "Black"]);
|
||||
assert!(off[..3].iter().all(|v| *v >= 250), "the process plates show paper, got {off:?}");
|
||||
}
|
||||
|
||||
/// Separations Preview showing `plates`, as the canvas draws it.
|
||||
fn separations(plates: &[&str]) -> RenderOptions {
|
||||
let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() };
|
||||
RenderOptions { proof: Some(proof), overprint_preview: true, smooth_images: false, ..Default::default() }
|
||||
}
|
||||
|
||||
/// A 10 pt tall page of `mode`: the image of `blob` (`size` pixels, `scale` pt a pixel) at the
|
||||
/// origin, then from `x0` a 10 pt square of each of `fills`.
|
||||
fn image_and_fills(mode: vectorcraft_doc::ColorMode, blob: ImageBlob, (width, height): (u32, u32), scale: f64, x0: f64, fills: &[Color]) -> Document {
|
||||
let mut d = Document::new_with_mode(x0 + 10.0 * fills.len() as f64, 10.0, mode);
|
||||
let layer = Some(d.layers[0].id);
|
||||
d.images.insert("pic".into(), blob);
|
||||
let im = ImageObject { key: "pic".into(), width, height, xf: Affine::scale(scale), link: None, placement: Default::default(), pixels: None };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
d.insert(layer, 0, n).unwrap();
|
||||
for (i, c) in fills.iter().enumerate() {
|
||||
let x = x0 + 10.0 * i as f64;
|
||||
let square =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(Paint::solid(*c), Paint::None, 0.0));
|
||||
d.insert(layer, i + 1, square).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// An RGB PNG of `width` × `height` `pixels`.
|
||||
fn rgb_png(width: u32, height: u32, pixels: &[[u8; 3]]) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_raw(width, height, pixels.concat()).unwrap();
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` differ by at most `tolerance` in every channel.
|
||||
fn near(a: [u8; 4], b: [u8; 4], tolerance: u8) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= tolerance)
|
||||
}
|
||||
|
||||
/// Separations Preview shows an RGB image's pixels as the visible plates print them, as it shows
|
||||
/// fills of the same colors (#4153): with only Cyan visible a cyan pixel shows as dark ink and a
|
||||
/// red one as paper; with Cyan hidden, or only Magenta and Yellow, as the fills do. With the
|
||||
/// preview off the image keeps its own pixels.
|
||||
#[test]
|
||||
fn separations_preview_shows_an_rgb_images_process_inks() {
|
||||
let (cyan, red) = (Color::rgb(0.0, 1.0, 1.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
let blob = ImageBlob::new("image/png", rgb_png(2, 1, &[[0, 255, 255], [255, 0, 0]]));
|
||||
let d = image_and_fills(vectorcraft_doc::ColorMode::Rgb, blob, (2, 1), 10.0, 20.0, &[cyan, red]);
|
||||
let render = |opts: &RenderOptions| Renderer::new().render(&d, 40, 10, Affine::IDENTITY, opts);
|
||||
let off = render(&RenderOptions { smooth_images: false, ..Default::default() });
|
||||
assert_eq!((off.pixel(5, 5), off.pixel(15, 5)), ([0, 255, 255, 255], [255, 0, 0, 255]), "the preview off: the image's own pixels");
|
||||
for plates in [&["Cyan"][..], &["Magenta", "Yellow", "Black"], &["Magenta", "Yellow"], &["Cyan", "Magenta", "Yellow", "Black"]] {
|
||||
let shown = render(&separations(plates));
|
||||
for (image, fill) in [(5, 25), (15, 35)] {
|
||||
let (a, b) = (shown.pixel(image, 5), shown.pixel(fill, 5));
|
||||
assert!(near(a, b, 3), "{plates:?}: the image pixel at {image} shows as the fill does, {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
let cyan_only = render(&separations(&["Cyan"]));
|
||||
let (c, r) = (cyan_only.pixel(5, 5), cyan_only.pixel(15, 5));
|
||||
assert!(c[0] < 128 && c[0] == c[1] && c[1] == c[2], "the cyan pixel shows its cyan ink as a grey, got {c:?}");
|
||||
assert!(r[..3].iter().all(|v| *v >= 230), "red carries next to no cyan ink, got {r:?}");
|
||||
}
|
||||
|
||||
/// A CMYK image shows its own inks on the plates, not inks separated again from its display
|
||||
/// colors: as a fill of the same inks does.
|
||||
#[test]
|
||||
fn separations_preview_shows_a_cmyk_images_own_inks() {
|
||||
let ink = [0u8, 102, 255, 25];
|
||||
let blob = ImageBlob::cmyk_tiff(&vectorcraft_doc::cmyk::Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap();
|
||||
let fill = Color::cmyk(0.0, 0.4, 1.0, 25.0 / 255.0);
|
||||
let d = image_and_fills(vectorcraft_doc::ColorMode::Cmyk, blob, (2, 2), 5.0, 10.0, &[fill]);
|
||||
for (plates, grey) in [
|
||||
(&["Cyan"][..], Some(255)),
|
||||
(&["Magenta"], Some(153)),
|
||||
(&["Black"], Some(230)),
|
||||
(&["Magenta", "Yellow"], None),
|
||||
(&["Cyan", "Yellow", "Black"], None),
|
||||
] {
|
||||
let shown = Renderer::new().render(&d, 20, 10, Affine::IDENTITY, &separations(plates));
|
||||
let (a, b) = (shown.pixel(5, 5), shown.pixel(15, 5));
|
||||
assert!(near(a, b, 3), "{plates:?}: the image shows as the fill does, {a:?} vs {b:?}");
|
||||
if let Some(v) = grey {
|
||||
assert!(near(a, [v, v, v, 255], 2), "{plates:?}: the plate's own ink as a grey, got {a:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Showing other plates draws the image again through them, its shrunk copies too, and the image
|
||||
/// drawn without the preview keeps its own pixels: each plate set's pixels are kept apart.
|
||||
#[test]
|
||||
fn separations_preview_keeps_an_images_pixels_per_plate_set() {
|
||||
let cyan = Color::rgb(0.0, 1.0, 1.0);
|
||||
let blob = ImageBlob::new("image/png", rgb_png(40, 40, &[[0, 255, 255]; 1600]));
|
||||
let d = image_and_fills(vectorcraft_doc::ColorMode::Rgb, blob, (40, 40), 0.25, 10.0, &[cyan]);
|
||||
let mips = |o: RenderOptions| RenderOptions { image_mips: true, ..o };
|
||||
let off = mips(RenderOptions { smooth_images: false, ..Default::default() });
|
||||
let mut r = Renderer::new();
|
||||
let mut seen = vec![];
|
||||
for _ in 0..2 {
|
||||
for opts in [off.clone(), mips(separations(&["Cyan"])), mips(separations(&["Magenta", "Yellow", "Black"])), off.clone()] {
|
||||
let shown = r.render(&d, 20, 10, Affine::IDENTITY, &opts);
|
||||
let (a, b) = (shown.pixel(5, 5), shown.pixel(15, 5));
|
||||
assert!(near(a, b, 3), "{:?}: the shrunk image shows as the fill does, {a:?} vs {b:?}", opts.proof.map(|p| p.separations));
|
||||
seen.push(a);
|
||||
}
|
||||
}
|
||||
assert_eq!(seen[0], [0, 255, 255, 255], "the preview off: the image's own pixels");
|
||||
assert_eq!(seen[3], seen[0], "and again after the preview");
|
||||
assert_ne!(seen[1], seen[2], "each plate set filters the image anew");
|
||||
assert_eq!(seen[..4], seen[4..], "frames drawn from kept copies draw the same");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Colour, gradients & mesh: several gradient mesh points can be picked (Shift-clicks with the Mesh tool and Direct Selection, Direct Selection's marquee, its click on a mesh line or inside a patch); a drag or the arrow keys move them all (`object.mesh.movePoints`), and a solid fill colour or the Transparency panel's opacity goes to each of them (#4207, #4208, #4170); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Print, colour management, separations: Separations Preview's plates apply to placed and embedded raster images (#4153); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -1027,6 +1027,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Placed documents' bitmaps on screen: when a bitmap made in the background is ready, the desktop app renders the canvas again only where the objects that show its file are, when the frame shown was rendered on the UI thread and that part is at most half the canvas. Bitmaps drawn or made since a frame began are kept for that frame past the 512 MiB budget, up to 2 GiB, so a view showing more than the budget does not make them again in every frame. A bitmap that would take them past 2 GiB is not kept, and its file draws exactly at that zoom. A bitmap that can't be made (the file can't be read and there is no preview) shows the light placeholder and is not asked for again. Each ready bitmap used to render the whole canvas again, and bitmaps past the budget and bitmaps that could not be made were asked for again in every frame (`crates/render/src/tests/placed_document.rs`).
|
||||
- Linked PDF and `.ai` pages: File › Place with Link (the default) places a page of a PDF, `.ai` or `.ait` file read as PDF pages as a placed document, as it links an image, the page and its box (`crop`) kept with the link: Links check, update and relink it, Break Link and Object › Expand give the group placing it without Link gives, every output draws the page's vectors, and Package copies the file. Without Link, or with a password or `textAs: "outlines"`, the page is embedded. EPS and PostScript `.ai` files are still embedded (`crates/engine/src/tests/placed_document.rs`).
|
||||
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`).
|
||||
- Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged.
|
||||
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
|
||||
- A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
|
||||
- PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`).
|
||||
@@ -1204,6 +1205,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Print, colour management, separations: Separations Preview's plates apply to placed and embedded raster images, a CMYK image's own inks included (#4153); evidence added; score and hours unchanged (estimated). |
|
||||
| 2026-10-30 | minor | Colour, gradients & mesh: several gradient mesh points can be picked (Shift-clicks with the Mesh tool and Direct Selection, Direct Selection's marquee, its click on a mesh line or inside a patch); a drag or the arrow keys move them all (`object.mesh.movePoints`), and a solid fill colour or the Transparency panel's opacity goes to each of them (#4207, #4208, #4170); scores unchanged. |
|
||||
| 2026-10-30 | minor | Drawing aids: Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); evidence added; score and hours unchanged (estimated). |
|
||||
| 2026-10-30 | minor | Selection, transform & align tools: Align panel menu › Align to Glyph Bounds › Point Text and Area Text align and distribute type by the ink of its glyphs (#4231); evidence added; score and hours unchanged (estimated). |
|
||||
|
||||
Reference in New Issue
Block a user