diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 573a9ee16..23aeb83bb 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -125,7 +125,7 @@ const BACKGROUND: FormatOption = FormatOption { name: "background", ty: "string", default: "\"transparent\"", - description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)", + description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent); drawn under the finished art, so blend modes do not read it", }; const ANTI_ALIAS: FormatOption = FormatOption { diff --git a/crates/engine/src/tests/file.rs b/crates/engine/src/tests/file.rs index 1e3a4b923..5fb568a23 100644 --- a/crates/engine/src/tests/file.rs +++ b/crates/engine/src/tests/file.rs @@ -447,6 +447,27 @@ fn export_selection_keeps_the_effects_in_the_crop() { } } +/// An Outer Glow at its default Screen mode on the bare page is in a PNG on white and in a JPEG, +/// and in a transparent PNG (#6125). +#[test] +fn a_screen_outer_glow_is_in_raster_exports() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); + let mut pixel = |p: serde_json::Value| { + let v = s.execute("document.export", &p).unwrap(); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8(); + // 2 pt past the square's stroke, on the right. + *img.get_pixel(122, 110) + }; + for p in [json!({"format": "png", "background": "white"}), json!({"format": "jpg"})] { + let px = pixel(p.clone()); + assert!(px[0] > 235 && px[2] < 235, "{p}: yellow on white: {px:?}"); + } + let px = pixel(json!({"format": "png", "background": "transparent"})); + assert!(px[3] > 0 && px[2] < 40, "the glow is in the transparent image: {px:?}"); +} + /// A group's own drop shadow is inside the crop of the group's selection export too. #[test] fn export_selection_keeps_a_groups_shadow_in_the_crop() { diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index c8df531a8..2d67db207 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -176,7 +176,7 @@ impl Ink { peniko::Color::from_rgba8(r, g, b, a) } - fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] { + pub(crate) fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] { match self.base() { Ink::Display | Ink::RichBlack => [r, g, b, a], _ => { diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e8df56215..2056ccf59 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -232,7 +232,9 @@ impl Renderer { self.live.sweep(g); } - /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. + /// Draw frame `f` (`w`×`h` pixels): the artboards and the page's art, then the background + /// under them. The art blends only with itself, as it does on the canvas, where the paper is + /// drawn apart from it: a Screen glow on the bare page stays visible on a white background (#6125). fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { let (threads, opts) = (self.threads, f.opts); let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); @@ -247,11 +249,6 @@ impl Renderer { ctx.set_aliasing_threshold(opts.anti_alias.threshold()); let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; - if let Some(bg) = opts.background { - ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(f.ink.fixed(bg)); - ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64)); - } if opts.artboards && !opts.outline { // Paper: white on screen, no ink on the ink planes. ctx.set_transform(f.view); @@ -278,7 +275,11 @@ impl Renderer { if self.contexts.len() > 2 { self.contexts.remove(0); } - pm.data_as_u8_slice().to_vec() + let mut pixels = pm.data_as_u8_slice().to_vec(); + if let Some(bg) = opts.background { + over_background(&mut pixels, f.ink.fixed8(bg)); + } + pixels } /// The page's art: the layers (or the pattern being edited), inside the page group when the @@ -392,10 +393,6 @@ impl Renderer { let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); let mut ctx = single_threaded_context(w, w); ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - if let Some(bg) = background { - ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); - 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, @@ -415,6 +412,22 @@ impl Renderer { self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); self.end_frame(); - Some(Rendered { width: w as u32, height: w as u32, pixels: pm.data_as_u8_slice().to_vec() }) + let mut pixels = pm.data_as_u8_slice().to_vec(); + if let Some(bg) = background { + over_background(&mut pixels, bg); + } + Some(Rendered { width: w as u32, height: w as u32, pixels }) + } +} + +/// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight). +fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { + let alpha = u32::from(a); + let under = [u32::from(r) * alpha / 255, u32::from(g) * alpha / 255, u32::from(b) * alpha / 255, alpha]; + for p in pixels.as_chunks_mut::<4>().0 { + let rest = 255 - u32::from(p[3]); + for (v, bg) in p.iter_mut().zip(under) { + *v = (u32::from(*v) + (bg * rest + 127) / 255).min(255) as u8; + } } } diff --git a/crates/render/src/tests/knockout.rs b/crates/render/src/tests/knockout.rs index 2a7e51e81..1f740b6b3 100644 --- a/crates/render/src/tests/knockout.rs +++ b/crates/render/src/tests/knockout.rs @@ -116,15 +116,21 @@ fn page_knockout_and_isolated_blending() { Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off; assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED)); - // Page Isolated Blending: a Difference object no longer blends with the white under the page. + // A Difference object on the bare page keeps its color: the background goes under the + // finished art (#6125), with Page Isolated Blending or without it. let mut d = Document::new(100.0, 100.0); let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); red.opacity = 1.0; red.blend = Blend::Difference; d.insert(Some(d.layers[0].id), 0, red).unwrap(); - assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50)); + assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "the bare page: {:?}", render(&d).pixel(30, 50)); d.page_isolate = true; assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50)); + // A white object under it is art: the Difference object inverts it. + let id = d.alloc_id(); + let white = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); + d.insert(Some(d.layers[0].id), 0, white).unwrap(); + assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white object: {:?}", render(&d).pixel(30, 50)); } #[test] diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index ef8972c3a..e7d8742d2 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -771,6 +771,40 @@ fn rasterize_on_an_image_takes_in_its_strokes_shape() { assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255])); } +/// A 40 pt square filled with `fill` carrying effect `id`, in a 100×100 document. +fn glowing_square(fill: Color, id: &str, params: Value) -> Document { + let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); + n.appearance.effects.push(fx(id, params)); + doc_with(Document::new(100.0, 100.0), n) +} + +/// A glow on the bare page is drawn before the background goes under the art: the pixel is the +/// transparent render's over that color, also for a background other than white (#6125). +#[test] +fn a_screen_outer_glow_on_the_bare_page_shows_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(27, 50); + for (bg, name) in [([255, 255, 255, 255], "white"), ([0, 0, 255, 255], "blue")] { + let p = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(27, 50); + let a = u32::from(clear[3]); + let want: Vec = (0..3).map(|c| (u32::from(clear[c]) * a + u32::from(bg[c]) * (255 - a)) / 255).collect(); + assert!(a > 0 && (0..3).all(|c| u32::from(p[c]).abs_diff(want[c]) <= 2), "{name}: {p:?}, want {want:?}"); + assert_eq!(p[3], 255, "{name}: opaque"); + } +} + +/// An Inner Glow blends with the object's own fill whatever is under the page: white Screen +/// lightens a black square, red Screen leaves a white one white, and red Normal shows on it. +#[test] +fn an_inner_glow_blends_with_its_own_fill_on_a_background() { + let red = |mode: &str| json!({"color": "#ff0000", "mode": mode, "opacity": 100}); + let dark = render(&glowing_square(Color::BLACK, "stylize.innerGlow", json!({"color": "#ffffff", "opacity": 100}))).pixel(32, 50); + assert!(dark[0] > 20 && dark[0] == dark[1], "the white glow lightens the black edge: {dark:?}"); + assert_eq!(render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("screen"))).pixel(32, 50), [255, 255, 255, 255]); + let normal = render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("normal"))).pixel(32, 50); + assert!(normal[0] == 255 && normal[1] < 245, "red on the white edge: {normal:?}"); +} + #[test] fn a_strokes_own_glow_paints_around_that_stroke_only() { let mut d = Document::new(100.0, 100.0); diff --git a/docs/mcp.md b/docs/mcp.md index 8f605eee0..2eb57aca8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -779,7 +779,7 @@ point (72 times that value in ppi); `options.ppi` wins over either scale option. default 72 ppi still use 72 ppi; use `--ppi 300` for print output from those documents. Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file, except in GIF, WebP and -Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused), `antiAlias` (`none`, `art`, `type`: text +Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused; drawn under the finished art, so blend modes do not read it), `antiAlias` (`none`, `art`, `type`: text snapped to pixels) and, for PNG, `interlaced`. `width` or `height` sets the size of each file in pixels: each artboard (with `useArtboards`, each file), `rect` or art bounds gets the scale that makes it that wide or high, within 0.01–64 pixels per point, and the file stores the resolution that results, as with `ppi`. `width` wins over `height`, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2effc1142..f7682a17a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -592,6 +592,8 @@ since 2026-10-01. - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. +- **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`. + - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). - **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`. The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`.