diff --git a/ROADMAP.md b/ROADMAP.md index daf3eb257..b8ec0e075 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ Newest first. - **2026-10-12 (PDFW)** · PDF, booklet and book composition share guarded writing, metadata and font preparation through craft-pdf. Layout, shaping, placed graphics, colour policy, tagging and PDF/X postprocessing remain in DesignCraft. Failed native drawing returns an export error; failed writer cleanup cannot serialize an incomplete document. No readiness percentage change. - **2026-10-13** · Copy with a placed graphic selected (Direct Selection) copies just the graphic, uncropped, where it sits on the page and without the frame's stroke, so Paste Into puts the whole image in place; Cut and Delete then remove only the graphic and leave its frame empty. Nested selections copy in their spread coordinates. No readiness percentage change. +- **2026-10-13** · Object effects (Basic Feather, Inner Glow, shadows, glows, bevel, satin, directional and gradient feathers) apply to frames with pasted-in content, on screen and in image exports: the frame, its clipped content and its stroke are one object to the effect. PDF export still has no soft effects for any object. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index dab2e3ad0..c13d9581e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -96,7 +96,7 @@ impl Renderer { let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0); // The silhouette is the shape when the object paints its area (fill / image); text frames // without a fill cast the shadow of their text only, which can't be grown. - let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)); + let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)) || it.has_nested_items(); let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dadc00f1a..fbc91e6b6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -446,39 +446,9 @@ impl Renderer { } let xf = parent * it.xf; let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.extent() + 2.0, it.stroke.extent() + 2.0); - if it.has_nested_items() { - // A frame with items pasted into it: fill, the items clipped to the frame, stroke. - if !rect_overlaps(vb, f.visible) { - return; - } - let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; - let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); - } - if !it.fill.is_none() { - ctx.set_transform(f.view * xf); - if set_fill_paint(ctx, f.doc, &it.fill, bp.bounding_box()) { - ctx.fill_path(&bp); - } - ctx.reset_paint_transform(); - } - ctx.set_transform(f.view * xf); - ctx.push_clip_layer(&bp); - for c in it.children() { - self.draw_item(ctx, f, c, xf, page); - } - ctx.pop_layer(); - if !it.stroke.is_none() { - self.draw_stroke(ctx, f, it, &bp, xf); - } - if layered { - ctx.pop_layer(); - } - return; - } - if !it.children().is_empty() { + // A frame with items pasted into it draws like any other object (draw_body: fill, the items + // clipped to the frame, stroke), so its effects apply to all of it. + if !it.children().is_empty() && !it.has_nested_items() { // Groups: children carry their own transforms relative to the group. // Isolate Blending / Knockout: the group is its own layer (blend modes stop at it). let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal || it.isolate || it.knockout; @@ -593,6 +563,15 @@ impl Renderer { } ctx.pop_layer(); } + // Items pasted into the frame, clipped by it. + Content::Group { items } if it.has_nested_items() => { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + for c in items { + self.draw_item(ctx, f, c, xf, page); + } + ctx.pop_layer(); + } Content::Text(tf) => { let cs = self.story(f, tf.story, page); if let Some(ft) = cs.frame(it.id) { diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index a664c27a8..bd0160ee1 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -222,3 +222,51 @@ fn multithreaded_effects_match_single_threaded() { assert!(worst <= 4, "case {i}: max channel difference {worst}"); } } + +/// A frame (30..130) holding a pasted-in black square that overhangs it (Edit › Paste Into). +fn pasted_into(d: &mut Document) -> ItemId { + let id = rect_item(d, Rect::new(30.0, 30.0, 130.0, 130.0), "[None]"); + let kid = Item { + fill: Fill::swatch("[Black]"), + ..Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 160.0))) + }; + d.item_mut(id).unwrap().content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(kid)] }; + assert!(d.item(id).unwrap().has_nested_items()); + id +} + +#[test] +fn effects_apply_to_a_frame_with_pasted_in_items() { + let mut d = doc(); + let id = pasted_into(&mut d); + let plain = render(&d); + let black = lum(plain.pixel(80, 80)); + assert!(black < 200 && lum(plain.pixel(31, 80)) <= black + 6, "the content fills the frame: {:?} {:?}", plain.pixel(31, 80), plain.pixel(80, 80)); + assert_eq!(lum(plain.pixel(27, 80)), WHITE, "clipped by the frame"); + // Basic Feather fades the frame's edge, content and all. + d.item_mut(id).unwrap().effects.feather = 16.0; + let img = render(&d); + assert!(lum(img.pixel(30, 80)) > 600, "nearly transparent at the path: {:?}", img.pixel(30, 80)); + assert!(lum(img.pixel(80, 80)) <= black + 6, "solid inside"); + assert_eq!(lum(img.pixel(27, 80)), WHITE, "still clipped"); + // Inner Glow: white screened in from the edges. + d.item_mut(id).unwrap().effects.feather = 0.0; + let g = &mut d.item_mut(id).unwrap().effects.inner_glow; + g.on = true; + g.size = 12.0; + g.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(32, 80)) > black + 150, "lit at the edge: {:?}", img.pixel(32, 80)); + assert!(lum(img.pixel(80, 80)) <= black + 6, "dark in the middle"); + // Drop shadow: cast by the content's silhouette, outside the frame. + d.item_mut(id).unwrap().effects.inner_glow.on = false; + let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow; + ds.on = true; + ds.angle = 180.0; + ds.global_light = false; + ds.distance = 12.0; + ds.size = 0.0; + ds.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(136, 80)) < 300, "the shadow to the right: {:?}", img.pixel(136, 80)); +}