diff --git a/ROADMAP.md b/ROADMAP.md index 3f8b37c27..a89ba46ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -182,6 +182,7 @@ Newest first. - **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** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). No readiness percentage change. - **2026-10-12** · On screen, blend modes blend with the objects below and not with the paper, as in InDesign and in PDF export: a Color Dodge or Screen object over blank paper shows as it is. 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-12** · Load Swatches refuses an `.ase` file with more than 100,000 colors, and `swatch.load` merges a file of 100,000 colors in under 0.1 s (it took 9.6 s of CPU in a test build). 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. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 70d56cefe..a26acb9b8 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1109,6 +1109,29 @@ 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 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() { + return 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). /// A group (its bounds are its children's). A frame holding pasted-into items is not. pub fn is_group(&self) -> bool { @@ -1265,6 +1288,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 b9d479067..d587526df 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -435,6 +435,89 @@ 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(); + 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). 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(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(); + // 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(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 f332b92c1..7e70ca42a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -765,7 +765,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 }, @@ -780,7 +780,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, @@ -792,22 +792,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() }; - let area = 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 6b29516f4..dab2e3ad0 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -64,6 +64,28 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { }) } +/// 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))); + 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_paint(grad); + 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(); +} + 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 +192,7 @@ 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); } if gf.on || directional { ctx.pop_layer(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 818236b2d..dadc00f1a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -486,6 +486,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. @@ -505,6 +511,10 @@ impl Renderer { self.draw_item(ctx, f, c, xf, page); } } + if feather { + fx::gradient_feather_mask(ctx, f, it, xf); + ctx.pop_layer(); + } if layered { ctx.pop_layer(); }