Merge pull request #1879 from PiMinusMax/inner-effects-follow-image

Effects: inner shadow, glow and bevel follow a transparent image's edge
This commit is contained in:
Brandon Thomas
2026-10-29 21:55:54 -04:00
committed by GitHub
2 changed files with 101 additions and 5 deletions
+1
View File
@@ -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.
+100 -5
View File
@@ -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));
}
}