diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index cd8e0c27c..7a93ac617 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -513,15 +513,15 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String { } } -/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an -/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]). -fn render_png( +/// `doc` rendered over `region` with `opts` in colour model `color` (premultiplied RGBA), with the +/// note that an image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]). +fn render_pixels( doc: &vectorcraft_doc::Document, region: Rect, scale: f64, opts: &vectorcraft_render::RenderOptions, color: RasterColorModel, -) -> Result<(Vec, u32, u32, Option<&'static str>)> { +) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> { if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 { return Err(EngineError::Other("image would be too large; lower the resolution".into())); } @@ -530,13 +530,57 @@ fn render_png( let mut img = r.render_region_with(doc, region, scale, opts); let warning = super::rasterfx::undecoded_warning(failed); RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels); + Ok((img, warning)) +} + +/// [`render_pixels`] as a PNG, with its width and height. +fn render_png( + doc: &vectorcraft_doc::Document, + region: Rect, + scale: f64, + opts: &vectorcraft_render::RenderOptions, + color: RasterColorModel, +) -> Result<(Vec, u32, u32, Option<&'static str>)> { + let (img, warning) = render_pixels(doc, region, scale, opts, color)?; Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning)) } +/// `doc` over `region` as a CMYK image of its ink amounts (as CMYK exports draw them), with the +/// transparency of `img` (the same art in screen colours) as a greyscale PNG when it has any: a +/// CMYK image keeps no alpha of its own. +fn cmyk_raster( + doc: &vectorcraft_doc::Document, + region: Rect, + scale: f64, + opts: &vectorcraft_render::RenderOptions, + img: &vectorcraft_render::Rendered, +) -> Result<(vectorcraft_doc::ImageBlob, Option>)> { + let inks = vectorcraft_render::Renderer::new().render_region_inks(doc, region, scale, opts); + let alpha: Vec = img.pixels.as_chunks::<4>().0.iter().map(|p| p[3]).collect(); + // Partly covered pixels carry that much ink: divided back out, as straight colour is, since + // the mask applies the coverage. + let straight = |v: u8, a: u8| if a == 0 { 0 } else { ((u32::from(v) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }; + let data = inks.as_chunks::<4>().0.iter().zip(&alpha).flat_map(|(p, &a)| p.map(|v| straight(v, a))).collect(); + let blob = vectorcraft_doc::cmyk::Inks::new(img.width, img.height, data) + .and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) + .ok_or_else(|| EngineError::Other("the CMYK image couldn't be written".into()))?; + if alpha.iter().all(|&a| a == 255) { + return Ok((blob, None)); + } + let mask = image::GrayImage::from_raw(img.width, img.height, alpha) + .ok_or_else(|| EngineError::Other("the image's transparency doesn't fit it".into()))?; + let mut png = Vec::new(); + mask.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| EngineError::Other(e.to_string()))?; + Ok((blob, Some(png))) +} + /// The image Object › Rasterize makes of some art, before it goes in a document /// ([`rasterize_art`]). pub(crate) struct Rasterized { - png: Vec, + /// A PNG, or a CMYK image of the art's inks ([`cmyk_raster`]). + blob: vectorcraft_doc::ImageBlob, + /// A CMYK image's transparency, a greyscale PNG: it becomes the image's opacity mask. + alpha: Option>, width: u32, height: u32, /// Image pixels → document. @@ -551,11 +595,18 @@ impl Rasterized { /// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's /// outline when it has one. pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node { - let key = unique_key(d, "raster"); - d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png)); - let id = d.alloc_id(); - let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None }; - let node = Node::new(id, NodeKind::Image(Box::new(image))); + let image = |d: &mut vectorcraft_doc::Document, blob: vectorcraft_doc::ImageBlob| { + let key = unique_key(d, "raster"); + d.images.insert(key.clone(), blob); + let image = + ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))) + }; + let mut node = image(d, self.blob); + if let Some(png) = self.alpha { + let art = image(d, vectorcraft_doc::ImageBlob::new("image/png", png)); + node.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + } match self.clip { Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node), None => node, @@ -565,8 +616,9 @@ impl Rasterized { /// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command /// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws -/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. -pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p: &Value, cmd: &str) -> Result { +/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. With `inks`, +/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs. +pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p: &Value, cmd: &str, inks: bool) -> Result { check_numbers(p, &["ppi"], cmd)?; let ppi = f64_or(p, "ppi", doc.raster_effects_ppi); if !(1.0..=2400.0).contains(&ppi) { @@ -630,9 +682,15 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p .flatten() .map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)); let tmp = isolated_doc(doc, nodes); - let (png, width, height, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?; + let opts = raster.render_options(RasterFormat::Png); + let (img, warning) = render_pixels(&tmp, region, scale, &opts, look.color_model)?; + let (blob, alpha) = if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && look.color_model == RasterColorModel::Document { + cmyk_raster(&tmp, region, scale, &opts, &img)? + } else { + (vectorcraft_doc::ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?), None) + }; let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - Ok(Rasterized { png, width, height, xf, clip, warning }) + Ok(Rasterized { blob, alpha, width: img.width, height: img.height, xf, clip, warning }) } fn rasterize(s: &mut Session, p: &Value) -> Result { @@ -640,7 +698,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; let st = s.doc()?; let nodes: Vec = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect(); - let image = rasterize_art(&st.doc, nodes, p, C)?; + let image = rasterize_art(&st.doc, nodes, p, C, true)?; let (w, h, warning) = (image.width, image.height, image.warning); if roots.is_empty() { return Err(bad(C, "nothing selected")); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 43ec3e40d..938d2643a 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -351,7 +351,9 @@ fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option { whole.appearance.effects.remove(at); (whole.opacity, whole.blend) = (1.0, Default::default()); whole.extras_mut().mask = None; - let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?; + // shortcut: expanded Rasterize effects stay RGB, as the object keeps its own opacity mask and the + // image's transparency would need one too; upgrade when CMYK images carry alpha. + let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize", false).ok()?; Some(image.into_node(d)) } diff --git a/crates/engine/src/tests/rastersettings.rs b/crates/engine/src/tests/rastersettings.rs index b3f1cf50b..2110e611e 100644 --- a/crates/engine/src/tests/rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -409,3 +409,49 @@ fn a_gradient_exports_at_the_resolution_the_file_opens_with() { assert_eq!(sizes, [(10, 10)], "saved as {saved}"); } } + +/// Rasterize in a CMYK document's colour model makes a CMYK image of the art's inks (#4882): the +/// image says CMYK, CMYK colours keep their numbers, a CMYK export keeps them, and a transparent +/// background becomes the image's opacity mask. +#[test] +fn rasterize_to_cmyk_makes_a_cmyk_image() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "colorMode": "cmyk"})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + s.execute("paint.setFill", &json!({"color": {"c": 0.2, "m": 0.4, "y": 0.6, "k": 0.1}, "ids": [r.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + let want = [51u8, 102, 153, 26]; + let near = |p: &[u8]| p.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 1); + let rasterize = |s: &mut Session, background: &str| { + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let p = json!({"ppi": 72, "colorModel": "cmyk", "background": background, "addAround": 5}); + let img = id(&s.execute("object.rasterize", &p).unwrap()); + assert_eq!(s.execute("image.info", &json!({"id": img.0})).unwrap()["colorMode"], "CMYK"); + let d = &s.doc().unwrap().doc; + let n = d.node(img).unwrap().clone(); + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + let inks = d.images[&im.key].cmyk().expect("a CMYK image"); + assert_eq!((inks.width, inks.height), (50, 50)); + let at = |x: u32, y: u32| inks.data[((y * 50 + x) * 4) as usize..][..4].to_vec(); + assert!(near(&at(25, 25)), "the fill's inks: {:?}", at(25, 25)); + assert_eq!(at(1, 1), [0; 4], "no ink around the art"); + n + }; + let opaque = rasterize(&mut s, "white"); + assert!(opaque.extras().mask.is_none(), "opaque: no mask"); + // A CMYK TIFF export keeps the image's inks. + let tiff = s.execute("document.export", &json!({"format": "tiff", "colorModel": "cmyk", "ppi": 72})).unwrap(); + let tiff = vectorcraft_format::base64_decode(tiff["dataBase64"].as_str().unwrap()).unwrap(); + let inks = vectorcraft_doc::ImageBlob::new("image/tiff", tiff).cmyk().expect("a CMYK TIFF"); + let at = ((30 * inks.width + 30) * 4) as usize; + assert!(near(&inks.data[at..at + 4]), "exported: {:?}", &inks.data[at..at + 4]); + // Transparent: the art's coverage is the image's opacity mask. + s.execute("edit.undo", &json!({})).unwrap(); + let masked = rasterize(&mut s, "transparent"); + let mask = masked.extras().mask.as_deref().expect("an opacity mask").clone(); + let d = &s.doc().unwrap().doc; + let NodeKind::Image(m) = &mask.art.kind else { panic!("the mask is an image") }; + let alpha = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_luma8(); + assert_eq!((alpha.get_pixel(1, 1).0, alpha.get_pixel(25, 25).0), ([0], [255])); + assert!(mask.clip); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dcf21aa41..3daa404e8 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-10-31 · **Change:** minor (Export for Screens laid out as the reference app's, with Export PDFs As and sub-folders by scale or format) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Object › Rasterize with the CMYK colour model makes a CMYK image, #4882; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | +| 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. | | 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. |