From 6f4fa3e56da61b4fafacdf6e01887c87711217d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 11:14:47 +0200 Subject: [PATCH 1/2] fix(render): A group's gradient feather fades the group, on screen and in PDF A gradient feather on a group (Gradient Feather tool over grouped text, as in LYH Studio's poster tutorial) did nothing on screen: groups never went through the effects path. In PDF the group's mask was the group's own path bounds, and a group has no path, so the mask covered a box of zero size and hid the group. - Item::feather_bounds: the box the feather spans, the objects' bounds for a group. - Render: a feathered group draws its objects in one layer, then fades it with the same mask as other objects (fx::gradient_feather_mask). - PDF: the group's mask spans its objects. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 11 ++++++++++ crates/engine/src/cmd/paths.rs | 33 ++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 12 +++++++++-- crates/render/src/fx.rs | 37 ++++++++++++++++++++-------------- crates/render/src/lib.rs | 13 ++++++++++++ 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..0e1e59ea0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,6 +153,7 @@ Newest first. - **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-12** · A gradient feather on a group fades the group as one, on screen and in PDF. Before, the screen ignored it, and PDF masked the group with a box of zero size because a group has no path of its own. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..80b3f2f6d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1105,6 +1105,17 @@ impl Item { self.path.bounds().unwrap_or(Rect::ZERO) } + /// The box a gradient feather spans, in item space: the path's bounds, or a group's objects' + /// (a group has no path of its own). + pub fn feather_bounds(&self) -> Rect { + if self.is_group() + && let Content::Group { items } = &self.content + { + return items.iter().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + } + self.inner_bounds() + } + /// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union). /// A group (its bounds are its children's). A frame holding pasted-into items is not. pub fn is_group(&self) -> bool { diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..976e892b9 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -435,6 +435,39 @@ mod tests { assert!(t.contains("/SMask") || t.contains("/Luminosity"), "a soft mask"); } + #[test] + fn a_groups_gradient_feather_fades_it_on_screen_and_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut ids = vec![]; + for rect in [[100, 100, 200, 200], [200, 100, 300, 200]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + ids.push(id); + } + let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + // The Gradient Feather tool's drag, then by angle (a group has no path of its own: the + // gradient spans its objects). + for p in [json!({"ids": [g], "from": [100, 150], "to": [300, 150]}), json!({"ids": [g], "angle": 0})] { + s.execute("object.gradientFeather", &p).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]); + assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}"); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); + // Over white paper. + let at = |x: usize, y: usize| { + let c = pm.data()[y * pm.width() as usize + x]; + c.r as u32 + 255 - c.a as u32 + }; + let (l, m, r) = (at(105, 150), at(200, 150), at(295, 150)); + assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}"); + } + } + #[test] fn create_outlines_turns_text_into_paths() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..3627fd954 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -757,7 +757,7 @@ impl Exporter<'_> { } // Gradient feather: a luminosity mask (white = opaque) over the object. let xf = parent * it.xf; - let (p0, p1) = gf.points(it.inner_bounds()); + let (p0, p1) = gf.points(it.feather_bounds()); let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() }; let stops = vec![ Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE }, @@ -791,7 +791,15 @@ impl Exporter<'_> { } .into() }; - let area = it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0); + // A group's mask covers its objects (it has no path of its own), with room for text and + // strokes past their frames. + let area = if it.is_group() { + let b = it.feather_bounds(); + let pad = 36.0 + 0.25 * b.width().max(b.height()); + b.inflate(pad, pad) + } else { + it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0) + }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); ms.push_transform(&tf(xf)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..0e1ffd565 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -64,6 +64,27 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { }) } +/// Keep what was drawn in the current layer as much as `it`'s gradient feather says, over `area` +/// (item space; past the gradient's ends its end opacities carry on). +pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, area: Rect) { + let gf = &it.effects.gradient_feather; + let (p0, p1) = gf.points(it.feather_bounds()); + let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); + let stops = [stop(0.0, gf.start), stop(1.0, gf.end)]; + let grad = if gf.radial { + peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice()) + } else { + peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()) + }; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + ctx.set_transform(f.view * xf); + ctx.set_paint(grad); + ctx.fill_rect(&area); + ctx.reset_paint_transform(); + ctx.pop_layer(); +} + impl Renderer { /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option) { @@ -170,22 +191,8 @@ impl Renderer { } } if gf.on { - let (p0, p1) = gf.points(it.inner_bounds()); - let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); - let stops = [stop(0.0, gf.start), stop(1.0, gf.end)]; - let grad = if gf.radial { - peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice()) - } else { - peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()) - }; - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - ctx.set_transform(f.view * xf); - ctx.set_paint(grad); let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() }; - ctx.fill_rect(&area.inflate(4.0, 4.0)); - ctx.reset_paint_transform(); - ctx.pop_layer(); + gradient_feather_mask(ctx, f, it, xf, area.inflate(4.0, 4.0)); } if gf.on || directional { ctx.pop_layer(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..ea942c2c8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -476,6 +476,12 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } + // Gradient feather: the group's objects drawn together, then faded as one. + let feather = it.effects.gradient_feather.on && f.opts.quality != DisplayQuality::Fast; + if feather { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + } if it.knockout { // Knockout: each object first clears its area of those behind it, so it composites // with the group's backdrop rather than with its siblings. @@ -495,6 +501,13 @@ impl Renderer { self.draw_item(ctx, f, c, xf, page); } } + if feather { + // Generous: text and strokes reach past their frames. + let b = it.feather_bounds(); + let pad = 36.0 + 0.25 * b.width().max(b.height()); + fx::gradient_feather_mask(ctx, f, it, xf, b.inflate(pad, pad)); + ctx.pop_layer(); + } if layered { ctx.pop_layer(); } From 5b74bd71e445ac5818d72b3634cb224cac198408 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 18:01:17 +0200 Subject: [PATCH 2/2] fix(render): A group's gradient feather masks everything the group draws The DestIn mask now covers the whole render target (and the PDF's exported area), with the gradient kept in item space through the paint transform, since past the gradient's ends the end opacity carries on: a child's long drop shadow or thick stroke is faded, not cut off at a box. feather_bounds counts only shown children and active states, walking nested groups without recursion. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 49 ++++++++++++++++++++++++---- crates/engine/src/cmd/paths.rs | 58 +++++++++++++++++++++++++++++++--- crates/pdf/src/export.rs | 19 +++-------- crates/render/src/fx.rs | 14 ++++---- crates/render/src/lib.rs | 5 +-- 5 files changed, 109 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 80b3f2f6d..5c08734f5 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1105,15 +1105,27 @@ impl Item { self.path.bounds().unwrap_or(Rect::ZERO) } - /// The box a gradient feather spans, in item space: the path's bounds, or a group's objects' - /// (a group has no path of its own). + /// The box a gradient feather spans, in item space: the path's bounds, or a group's visible + /// objects' (a group has no path of its own). Only the active states contribute, including in + /// nested groups. Walk iteratively so deeply nested documents don't exhaust the call stack. pub fn feather_bounds(&self) -> Rect { - if self.is_group() - && let Content::Group { items } = &self.content - { - return items.iter().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + if !self.is_group() { + return self.inner_bounds(); } - self.inner_bounds() + let mut stack: Vec<_> = self.shown_children().map(|c| (c.as_ref(), c.xf)).collect(); + let mut bounds: Option = None; + while let Some((it, xf)) = stack.pop() { + if it.hidden { + continue; + } + if it.is_group() { + stack.extend(it.shown_children().map(|c| (c.as_ref(), xf * c.xf))); + } else { + let b = xf.transform_rect_bbox(it.inner_bounds()); + bounds = Some(bounds.map_or(b, |have| have.union(b))); + } + } + bounds.unwrap_or(Rect::ZERO) } /// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union). @@ -1230,6 +1242,29 @@ mod tests { assert_eq!(n, 3); } + #[test] + fn feather_bounds_follow_visible_states_in_nested_groups() { + let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0))); + let mut b = a.clone(); + b.id = ItemId(2); + b.xf = Affine::translate((500.0, 500.0)); + let mut state = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default()); + state.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] }; + state.states = vec!["Visible".into(), "Inactive".into()]; + assert_eq!(state.feather_bounds(), Rect::new(10.0, 20.0, 30.0, 40.0)); + state.active_state = 1; + assert_eq!(state.feather_bounds(), Rect::new(510.0, 520.0, 530.0, 540.0)); + state.active_state = 0; + state.xf = Affine::translate((5.0, 10.0)) * Affine::scale(2.0); + let mut hidden = state.clone(); + hidden.id = ItemId(4); + hidden.hidden = true; + hidden.xf = Affine::translate((-1000.0, -1000.0)); + let mut outer = Item::new(ItemId(5), LayerId(1), Shape::Group, PathData::default()); + outer.content = Content::Group { items: vec![Arc::new(state), Arc::new(hidden)] }; + assert_eq!(outer.feather_bounds(), Rect::new(25.0, 50.0, 65.0, 90.0)); + } + #[test] fn serde_roundtrip() { let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))); diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 976e892b9..d587526df 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -446,15 +446,17 @@ mod tests { ids.push(id); } let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + s.execute("transform.move", &json!({"ids": [g], "dx": 20, "dy": 30})).unwrap(); // The Gradient Feather tool's drag, then by angle (a group has no path of its own: the - // gradient spans its objects). - for p in [json!({"ids": [g], "from": [100, 150], "to": [300, 150]}), json!({"ids": [g], "angle": 0})] { + // gradient spans its objects). The gradient still follows the translated group even + // though the mask geometry covers the whole output. + for p in [json!({"ids": [g], "from": [120, 180], "to": [320, 180]}), json!({"ids": [g], "angle": 0})] { s.execute("object.gradientFeather", &p).unwrap(); let d = s.doc().unwrap().doc.clone(); let mut rr = designcraft_render::Renderer::new(); rr.threads = 0; let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); - let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]); + let (l, m, r) = (img.pixel(125, 180)[0], img.pixel(220, 180)[0], img.pixel(315, 180)[0]); assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}"); let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); @@ -463,11 +465,59 @@ mod tests { let c = pm.data()[y * pm.width() as usize + x]; c.r as u32 + 255 - c.a as u32 }; - let (l, m, r) = (at(105, 150), at(200, 150), at(295, 150)); + let (l, m, r) = (at(125, 180), at(220, 180), at(315, 180)); assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}"); } } + #[test] + fn group_gradient_feather_keeps_distant_shadows_and_thick_strokes() { + for shadow in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let mut ids = vec![]; + for rect in [[120, 120, 170, 170], [175, 120, 225, 170]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + if shadow { + s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 180, "angle": 90, "size": 0, "opacity": 1})).unwrap(); + } else { + s.execute("object.stroke", &json!({"ids": [id], "swatch": "[Black]", "weight": 180})).unwrap(); + } + ids.push(id); + } + let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + // Translate the group so mask geometry and gradient paint exercise distinct spaces. + s.execute("transform.move", &json!({"ids": [g], "dx": 30, "dy": 20})).unwrap(); + let point = if shadow { (175, 345) } else { (175, 60) }; + let sample = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + // Zoomed viewport: the mask must extend over every device pixel. + let img = rr.render_page(d, &s.cache, 0, 2.0, false, &Default::default()).unwrap(); + let screen = img.pixel(point.0 * 2, point.1 * 2)[0]; + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &Default::default()).unwrap(); + let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); + let c = pm.data()[point.1 as usize * pm.width() as usize + point.0 as usize]; + [f32::from(screen), c.r as f32 + 255.0 - c.a as f32] + }; + let before = sample(&s); + // The sample lies past either end of the gradient, and outside the old fixed padding. + s.execute("object.gradientFeather", &json!({"ids": [g], "from": [130, 120], "to": [130, 220], "start": 40, "end": 80})).unwrap(); + let after = sample(&s); + let opacity = if shadow { 0.8 } else { 0.4 }; + for (kind, (before, after)) in ["screen", "PDF"].into_iter().zip(before.into_iter().zip(after)) { + assert!(before < 120.0, "{kind}, shadow={shadow}: sample contains the effect before feathering: {before}"); + let expected = 255.0 - (255.0 - before) * opacity; + assert!( + (after - expected).abs() < 16.0, + "{kind}, shadow={shadow}: end opacity continues past geometry: {after}, expected {expected}" + ); + } + } + } + #[test] fn create_outlines_turns_text_into_paths() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3627fd954..983be9433 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -772,7 +772,7 @@ impl Exporter<'_> { cx: p0.x as f32, cy: p0.y as f32, cr: r, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -784,30 +784,21 @@ impl Exporter<'_> { y1: p0.y as f32, x2: p1.x as f32, y2: p1.y as f32, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, } .into() }; - // A group's mask covers its objects (it has no path of its own), with room for text and - // strokes past their frames. - let area = if it.is_group() { - let b = it.feather_bounds(); - let pad = 36.0 + 0.25 * b.width().max(b.height()); - b.inflate(pad, pad) - } else { - it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0) - }; + // The gradient keeps its item-space geometry, but its mask covers the complete exported + // area (including bleed), so distant child shadows and thick strokes are never cut off. let mut sb = s.stream_builder(); let mut ms = sb.surface(); - ms.push_transform(&tf(xf)); - if let Some(p) = to_path(&area.to_path(0.1)) { + if let Some(p) = to_path(&self.clip.to_path(0.1)) { ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); ms.draw_path(&p); } - ms.pop(); ms.finish(); let stream = sb.finish(); s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0e1ffd565..dab2e3ad0 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -64,9 +64,10 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { }) } -/// Keep what was drawn in the current layer as much as `it`'s gradient feather says, over `area` -/// (item space; past the gradient's ends its end opacities carry on). -pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, area: Rect) { +/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the +/// whole viewport: strokes, text and child effects can extend arbitrarily past the geometry, +/// and past the gradient's ends its end opacities carry on. +pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine) { let gf = &it.effects.gradient_feather; let (p0, p1) = gf.points(it.feather_bounds()); let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); @@ -78,9 +79,9 @@ pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Ite }; ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - ctx.set_transform(f.view * xf); ctx.set_paint(grad); - ctx.fill_rect(&area); + ctx.set_paint_transform(f.view * xf); + ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()))); ctx.reset_paint_transform(); ctx.pop_layer(); } @@ -191,8 +192,7 @@ impl Renderer { } } if gf.on { - let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() }; - gradient_feather_mask(ctx, f, it, xf, area.inflate(4.0, 4.0)); + gradient_feather_mask(ctx, f, it, xf); } if gf.on || directional { ctx.pop_layer(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ea942c2c8..cc937a1a2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -502,10 +502,7 @@ impl Renderer { } } if feather { - // Generous: text and strokes reach past their frames. - let b = it.feather_bounds(); - let pad = 36.0 + 0.25 * b.width().max(b.height()); - fx::gradient_feather_mask(ctx, f, it, xf, b.inflate(pad, pad)); + fx::gradient_feather_mask(ctx, f, it, xf); ctx.pop_layer(); } if layered {