From faea85f7729a3d3886ea3b2a8f81ad9584a49450 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 13:02:28 -0800 Subject: [PATCH 1/2] Fix #6125: raster exports keep a Screen glow on the bare page A raster export filled its background into the page before the art, so blend modes read it as art underneath: an Outer Glow in Screen mode, the default, turned white on a white background and was missing from PNG and JPEG files while the canvas showed it. Blending against nothing also differed from the canvas for Difference and the other modes. The background now goes under the finished art, in frames and in object thumbnails. A blend mode reads only the art below it. A white object under a Difference object still inverts. Page Isolated Blending no longer changes a raster export on a bare page. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/formats.rs | 2 +- crates/engine/src/tests/file.rs | 21 ++++++++++++++ crates/render/src/ink.rs | 2 +- crates/render/src/renderer.rs | 37 +++++++++++++++++-------- crates/render/src/tests/knockout.rs | 10 +++++-- crates/render/src/tests/objectfx.rs | 34 +++++++++++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 2 ++ 8 files changed, 93 insertions(+), 17 deletions(-) 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`. From 5eb6b62632f834de4e977e134b83517d15e35c45 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 14:13:08 -0800 Subject: [PATCH 2/2] Flatten: blend modes do not read the white under the art Flatten Transparency composited each region over white, so a blend mode on the bare page read that white. Raster renders now draw their background under the finished art, and the flattened color no longer matched them: a Color Burn object on the bare page flattened to white and rendered in its own color. The region now composites the art alone and puts the white under it. - Tests that relied on the white under the art now draw art under the style (`every_builtin_style_paints_visibly_on_a_rectangle`) or compare only whole-pixel pans with a fresh render (`a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again`). - New tests cover every blend mode on white and blue backgrounds, a thumbnail and the ink planes with a Screen glow, and `over_background` at background and pixel alpha 0, 128 and 255. --- crates/engine/src/cmd/flatten.rs | 2 +- crates/engine/src/cmd/flatten/apply.rs | 20 +++++------- crates/engine/src/tests/file.rs | 4 +-- crates/engine/src/tests/stylelib.rs | 7 ++++- crates/render/src/fx/tests.rs | 8 +++-- crates/render/src/renderer.rs | 2 +- crates/render/src/tests/knockout.rs | 42 ++++++++++++++++++++++++++ crates/render/src/tests/objectfx.rs | 24 +++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 2 +- 10 files changed, 91 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index ba99c4beb..2a12eb9c5 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -5,7 +5,7 @@ //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by -//! [`composite`]), over white (or over nothing with Preserve Alpha); in CMYK documents their inks +//! [`composite`]), with white under the finished art (or nothing with Preserve Alpha); in CMYK documents their inks //! composite, plane by plane ([`cmyk_planes`]), and the colour is CMYK. Each region becomes a path //! filled with that flat colour. Where gradients, patterns, images, opacity masks or raster //! effects reach there is no single colour: those regions are rendered into one image per group, diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 2b0b1d072..c70f4dca6 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,10 +1,10 @@ use super::analysis::{Scratch, outlined_text, overlapping, reach}; -use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, WHITE}; +use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, ColorMode, Document, EngineError, FillLayer, FillRule, ImageBlob, ImageObject, Inks, - MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, separate, - shape_node, shapes, unique_key, + MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, composite, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, + separate, shape_node, shapes, unique_key, }; // ---------- plan ---------- @@ -235,18 +235,14 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool if alone[3] < 0.5 / 255.0 { return None; } - let start = if o.preserve_alpha { CLEAR } else { WHITE }; - let px = if o.preserve_alpha { - alone - } else { - c.top = None; - c.eval(elems, start) - }; + // The white goes under the finished art, as under the renderer's: blend modes read only the art. + let on_page = |p: Px| if o.preserve_alpha { p } else { composite(BlendMode::Normal, WHITE, p) }; + let px = on_page(alone); let rgb = [px[0], px[1], px[2]]; // The K plane composites the same way. let k = cmyk.then(|| { c.plane = 1; - c.eval(elems, start)[0] + on_page(c.eval(elems, CLEAR))[0] }); // A paint showing as it is keeps its colour (spot, process, swatch) and overprint. let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3); @@ -266,7 +262,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool } /// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out -/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and +/// again, or over nothing with Preserve Alpha. The image and /// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink /// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. /// Fails when the image can't be encoded. diff --git a/crates/engine/src/tests/file.rs b/crates/engine/src/tests/file.rs index 5fb568a23..6467fe091 100644 --- a/crates/engine/src/tests/file.rs +++ b/crates/engine/src/tests/file.rs @@ -462,10 +462,10 @@ fn a_screen_outer_glow_is_in_raster_exports() { }; 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:?}"); + assert!(px[0] > 235 && px[1] > 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:?}"); + assert!(px[3] > 0 && px[0] > 235 && px[1] > 235 && px[2] < 40, "the glow is yellow in the transparent image: {px:?}"); } /// A group's own drop shadow is inside the crop of the group's selection export too. diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 6a93b80a3..31ceb2434 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -251,11 +251,16 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { let (_, lib) = cmd::stylelib::library(&s, b.id).unwrap(); for g in &lib.styles { run(&mut s, "file.new", json!({"width": 100, "height": 100})); + // Art under the rectangle: a blend mode shows only against art, not the bare page. + let under = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#336699", "ids": [under.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [under.0]})); let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); - let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count(); + let bare = img.pixel(0, 0); + let painted = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) != bare).count(); assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); } } diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index a2b8473ac..01ddd0309 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -182,8 +182,8 @@ fn shadow_rasters_keep_their_node_and_a_byte_budget() { /// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole /// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes, /// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel -/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and -/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow +/// keeps the raster and places it at the nearest whole pixel, as a full-size raster kept whole is +/// placed. A 100 pt glow at 500% (scale 2) and a 2 pt glow /// round the page at 800% (scale 3, near the narrowest blur made coarser). #[test] fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { @@ -210,7 +210,9 @@ fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn"); let mut fresh = Renderer::new(); fresh.threads = 2; - assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + if pan.0.fract() == 0.0 && pan.1.fract() == 0.0 { + assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + } } } } diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 2056ccf59..88eeb4533 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -421,7 +421,7 @@ impl Renderer { } /// 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]) { +pub(crate) 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 { diff --git a/crates/render/src/tests/knockout.rs b/crates/render/src/tests/knockout.rs index 1f740b6b3..4043173ae 100644 --- a/crates/render/src/tests/knockout.rs +++ b/crates/render/src/tests/knockout.rs @@ -133,6 +133,48 @@ fn page_knockout_and_isolated_blending() { assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white object: {:?}", render(&d).pixel(30, 50)); } +/// On a white or blue background, every blend mode on the bare page gives the transparent +/// render over that color (#6125). +#[test] +fn no_blend_mode_reads_the_background() { + for mode in Blend::ALL { + let mut d = Document::new(100.0, 100.0); + let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); + red.blend = mode; + d.insert(Some(d.layers[0].id), 0, red).unwrap(); + let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(30, 50); + for bg in [[255, 255, 255, 255], [0, 0, 255, 255]] { + let got = + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(30, 50); + let a = i32::from(clear[3]); + let want = [0, 1, 2].map(|c| (i32::from(clear[c]) * a + i32::from(bg[c]) * (255 - a)) / 255); + assert!((0..3).all(|c| (i32::from(got[c]) - want[c]).abs() <= 3) && got[3] == 255, "{mode:?} on {bg:?}: {got:?}, want {want:?}"); + } + } +} + +/// Premultiplied pixels of alpha 0, 128 and 255 over backgrounds of alpha 0, 128 and 255. +#[test] +fn over_background_composites_premultiplied_pixels() { + let over = |p: [u8; 4], a: u8| { + let mut px = p.to_vec(); + crate::renderer::over_background(&mut px, [200, 100, 0, a]); + [px[0], px[1], px[2], px[3]] + }; + const CLEAR: [u8; 4] = [0; 4]; + const HALF: [u8; 4] = [64, 10, 100, 128]; + const SOLID: [u8; 4] = [9, 8, 7, 255]; + assert_eq!(over(CLEAR, 0), CLEAR); + assert_eq!(over(HALF, 0), HALF); + assert_eq!(over(SOLID, 0), SOLID); + assert_eq!(over(CLEAR, 128), [100, 50, 0, 128]); + assert_eq!(over(HALF, 128), [114, 35, 100, 192]); + assert_eq!(over(SOLID, 128), SOLID); + assert_eq!(over(CLEAR, 255), [200, 100, 0, 255]); + assert_eq!(over(HALF, 255), [164, 60, 100, 255]); + assert_eq!(over(SOLID, 255), SOLID); +} + #[test] fn page_knockout_looks_the_same_when_another_object_blends() { // With Page Knockout Group on, a Multiply square on the page makes the renderer draw the page diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index e7d8742d2..fe1e5ee1d 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -784,6 +784,7 @@ fn glowing_square(fill: Color, id: &str, params: Value) -> Document { 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); + assert!(clear[0] > 20 && clear[0] == clear[1] && clear[2] == 0, "the glow is yellow: {clear:?}"); 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]); @@ -793,6 +794,29 @@ fn a_screen_outer_glow_on_the_bare_page_shows_on_a_background() { } } +/// A thumbnail with a background shows a Screen glow on the bare page, opaque (#6125). +#[test] +fn a_screen_outer_glow_is_in_a_thumbnail_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let n = d.layers[0].children().unwrap()[0].clone(); + let img = Renderer::new().render_node_thumbnail(&d, &n, 100, Some([255; 4])).unwrap(); + let pixels = img.pixels.as_chunks::<4>().0; + assert!(pixels.iter().all(|p| p[3] == 255), "every pixel is opaque"); + assert!(pixels.iter().any(|p| p[0] > 235 && p[1] > 235 && p[2] < 235), "the glow shows as yellow on white"); +} + +/// The ink planes put the background under the art too: on a white background a Screen glow on +/// the bare page prints yellow ink, and the bare page around it prints none (#6125). +#[test] +fn a_screen_outer_glow_prints_ink_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let opts = RenderOptions { background: Some([255; 4]), ..Default::default() }; + let inks = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &opts); + let at = |x: usize, y: usize| &inks[(y * 100 + x) * 4..][..4]; + assert!(at(27, 50)[2] > 10, "yellow ink in the glow: {:?}", at(27, 50)); + assert_eq!(at(5, 50), [0; 4], "no ink on the bare page"); +} + /// 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] diff --git a/docs/mcp.md b/docs/mcp.md index 2eb57aca8..3eda1d344 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2872,7 +2872,7 @@ where the layout puts it. `object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in one undo step. The targets split into groups of overlapping objects; groups without transparency stay as they are. Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over -white; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, +white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7682a17a..b742a48e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -592,7 +592,7 @@ 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`. +- **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. Flatten Transparency puts its white under the finished art the same way. 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`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`. - **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`.