diff --git a/ROADMAP.md b/ROADMAP.md index d855b8d5b..8307b65ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -384,6 +384,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-26** · Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss on a frame holding an image follow what the image paints: a transparent part is outside the object, so the effect lines the image's opaque edge, not the frame (#1622). Satin and a centred Inner Glow still follow the frame. No readiness percentage change. - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6147c9cf5..06eb4f4dd 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -211,6 +211,9 @@ impl Renderer { if !it.path.is_closed() { return; } + // An image's edge is where it is opaque, not the frame: inner shadows, glows and bevels + // follow what is painted when the frame holds a graphic. + let painted = matches!(it.content, designcraft_doc::Content::Graphic(_)).then_some((it, page)); // Inner shadow, clipped to the shape. if e.inner_shadow.on && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) @@ -218,7 +221,7 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal, painted); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on @@ -248,7 +251,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix); + self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted); } } // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. @@ -289,16 +292,18 @@ impl Renderer { let sigma = b.size.max(0.0) / 2.0; if let Some(c) = doc.resolve_color(&b.highlight, 1.0) { // The inverse shape moved away from the light lights the edges facing it. - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen, painted); } if let Some(c) = doc.resolve_color(&b.shadow, 1.0) { - self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply); + self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply, painted); } } } /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by - /// `sigma`, clipped to the shape and blended with `mix`. + /// `sigma`, clipped to the shape and blended with `mix`. With `painted` (an object and the + /// page it shows on), the shape is what the object paints, so a transparent part of an image + /// is outside it: the edge runs along the image's opaque pixels, not the frame. #[allow(clippy::too_many_arguments)] fn inner_edge( &mut self, @@ -312,7 +317,33 @@ impl Renderer { choke: f64, paint: peniko::Color, mix: Mix, + painted: Option<(&Item, ShownOn)>, ) { + if let Some((it, page)) = painted { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let area = reach.inflate(pad, pad); + // The colour everywhere but under the painted pixels moved by `off`, blurred, ... + c.set_transform(fr.view); + c.push_layer(None, None, None, None, None); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_paint(paint); + c.fill_rect(&area); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; + me.draw_body(c, &moved, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + // ... kept only where the object paints. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + me.draw_body(c, fr, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + }); + return; + } self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); @@ -458,4 +489,68 @@ mod tests { let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 }; assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide)); } + + /// An inner shadow on a frame holding an image whose left half is transparent: the shadow + /// lines the image's opaque part (its edge in the middle of the frame), not only the frame. + #[test] + fn an_inner_shadow_follows_a_transparent_images_edge() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + // 20 × 20: transparent left half, opaque white right half. + let img = image::RgbaImage::from_fn(20, 20, |x, _| if x < 10 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([255, 255, 255, 255]) }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let aid = designcraft_doc::AssetId(d.alloc()); + d.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + id: aid, + name: "half.png".into(), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: Some((20, 20)), + ..Default::default() + }), + ); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), + ); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (200.0, 200.0), + xf: Affine::translate((100.0, 100.0)), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let s = &mut it.effects.inner_shadow; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.distance = 0.0; + s.size = 20.0; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = crate::Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap(); + let lum = |x: u32| { + let p = page.pixel(x, 200); + u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]) + }; + // The image's opaque part starts at x = 200: just inside it the shadow darkens it; in its + // middle (x = 250, 50 pt from every edge but the frame's right one) it stays light. + assert!(lum(204) + 60 < lum(250), "shadow at the image's own edge: {} vs {}", lum(204), lum(250)); + // The transparent half shows the page: no shadow painted there. + assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); + } }