Browse / github.com/storytold/designcraft / patch
Effects: Spread grows what an object paints; groups cast drop shadow and outer glow
Drop shadow / outer glow Spread grows the frame of a transparent image, not what it paints **What happens:** A drop shadow or outer glow with Spread on a placed image with transparency (a PNG cut-out, a logo) grows the *frame's* outline, not what the image paints. The transparent part of the frame casts a hard dark band along the frame edge, as wide as the spread part of Size. Without Spread the shadow follows the image's pixels, as expected. **Steps:** 1. Place a PNG with a transparent background in a frame larger than its visible content. 2. Object › Effects › Drop Shadow: Size 1.5 cm, Spread 50%. 3. A dark rounded rectangle appears around the whole frame, though only the cut-out
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- PiMinusMax (imported from pull request #1619, open)
- Base
ed8aabd1dc5a88652a076631c2d46886ca60c031- Size
- +267 −18 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
3fad448c66cddf4d88cb304a
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/render/src/fx.rs crates/render/src/lib.rs crates/render/tests/it/effects.rs
@@ -422,6 +422,7 @@ Newest first. - **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.+- **2026-10-22** · Drop shadow and outer glow: Spread grows the silhouette of what the object paints (an image's opaque pixels, a frame's glyphs), not its frame's outline, so a transparent image casts no band along its frame (#1617); a group's objects cast its drop shadow and outer glow together (#1618). No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change.@@ -8,9 +8,10 @@ //! itself and the clip to its shape stay at full resolution. //! //! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text,-//! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2),-//! `spread`/`choke` (percent of size) grow the silhouette instead of blurring it. The shadow's-//! opacity rides on the filter colour's alpha.+//! stroke; a group's objects together) in a `DropShadowOnly` layer under the object. `size`+//! sets the blur (σ = size / 2). `spread` (percent) hardens it: the blurred coverage is raised+//! by 1 / (1 − spread), so the silhouette of what is painted grows solid, never the frame of a+//! transparent image. The shadow's opacity rides on the filter colour's alpha. //! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top. //! - Inner glow, bevel and emboss, satin: built like the inner shadow (an offset inverse of the //! shape, blurred and clipped) with screen / multiply blending; satin XORs two offset copies.@@ -89,16 +90,13 @@ pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Ite } 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: ShownOn) {+ /// Drop shadow and outer glow, below the object (or the group: its objects together). Spread+ /// grows the silhouette of what the object paints, so a transparent part of an image or an+ /// empty frame casts nothing along its frame.+ #[allow(clippy::too_many_arguments)]+ pub(crate) fn draw_below(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn, reach: Rect) { let doc = f.doc; let e = &it.effects;- let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };- // Spread-space reach of what the object paints.- 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(_)) || it.has_nested_items(); let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow;@@ -115,20 +113,81 @@ impl Renderer { // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow);+ let grown = if grow > 0.0 { self.grown_silhouette(ctx, f, it, bp, xf, page, reach, off, grow) } else { None };+ if grow > 0.0 && grown.is_none() {+ continue;+ } self.with_filters(ctx, f, r, sigma, None, |me, c, fr| {- let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };+ let moved = moved_frame(fr, off, sigma); c.set_transform(moved.view); c.push_layer(None, None, None, None, Some(filter));- me.draw_body(c, &moved, it, bp, xf, page);- if grow > 0.0 && solid {- c.set_transform(moved.view * xf);- c.set_paint(peniko::Color::BLACK);- c.set_stroke(kurbo::Stroke::new(grow * 2.0).with_join(kurbo::Join::Round));- c.stroke_path(bp);+ if let Some((mask, at)) = &grown {+ // `at` places the mask on the device; this frame's view replaces `f.view`.+ let to_frame = if f.view.determinant().abs() > 1e-12 { fr.view * f.view.inverse() } else { Affine::IDENTITY };+ c.set_transform(to_frame * *at);+ c.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(mask.clone()), sampler: peniko::ImageSampler::default() });+ c.fill_rect(&Rect::new(0.0, 0.0, f64::from(mask.width()), f64::from(mask.height())));+ } else {+ me.draw_silhouette(c, &moved, it, bp, xf, page); } c.pop_layer(); }); }+ }++ /// What casts the object's shadow: its objects for a group, else the object itself.+ #[allow(clippy::too_many_arguments)]+ fn draw_silhouette(&mut self, c: &mut RenderContext, fr: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) {+ if it.is_group() {+ for child in it.shown_children() {+ self.draw_item(c, fr, child, xf, page);+ }+ } else {+ self.draw_body(c, fr, it, bp, xf, page);+ }+ }++ /// The silhouette moved by `off` and grown by `grow` (spread space) around what it paints, as+ /// black coverage on the device grid: a transparent part of an image or an empty frame grows+ /// nothing. Returns the image and where it goes on the device, or `None` when none of it shows.+ #[allow(clippy::too_many_arguments)]+ fn grown_silhouette(+ &mut self,+ ctx: &RenderContext,+ f: &Frame,+ it: &Item,+ bp: &BezPath,+ xf: Affine,+ page: ShownOn,+ reach: Rect,+ off: Vec2,+ grow: f64,+ ) -> Option<(std::sync::Arc<vello_cpu::Pixmap>, Affine)> {+ let o = offscreen(f.view.transform_rect_bbox(reach + off), grow / f.px + 2.0, ctx.width(), ctx.height())?;+ let mut c = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });+ let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view;+ let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f };+ let moved = moved_frame(&shifted, off, grow);+ self.draw_silhouette(&mut c, &moved, it, bp, xf, page);+ c.flush();+ let mut pm = vello_cpu::Pixmap::new(o.width, o.height);+ c.render(&mut pm, &mut self.resources);+ let coverage: Vec<u8> = pm.data().iter().map(|p| p.a).collect();+ let grown = dilate(&coverage, usize::from(o.width), usize::from(o.height), grow / shifted.px);+ for (p, a) in pm.data_mut().iter_mut().zip(grown) {+ *p = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a };+ }+ Some((std::sync::Arc::new(pm), Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)))+ }++ /// 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: ShownOn) {+ let e = &it.effects;+ let doc = f.doc;+ let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };+ // Spread-space reach of what the object paints.+ let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0);+ self.draw_below(ctx, f, it, bp, xf, page, reach); // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as // much as the opacity gradient says. let gf = &e.gradient_feather;@@ -444,6 +503,86 @@ fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscr (w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 }) } +/// `fr` with its view moved by `off` (spread space), and its visible area widened to take in+/// what a blur of `sigma` brings into view from there.+fn moved_frame<'a>(fr: &Frame<'a>, off: Vec2, sigma: f64) -> Frame<'a> {+ let pad = sigma.max(0.0) * 3.0;+ Frame { view: fr.view * Affine::translate(off), visible: (fr.visible - off).inflate(pad, pad), ..*fr }+}++/// `coverage` (`w` × `h`, row-major) grown by `radius` pixels: each pixel covers as much as its+/// distance to the nearest edge allows (antialiased over one pixel), and never less than it did. Exact Euclidean distances (Felzenszwalb and Huttenlocher's transform).+fn dilate(coverage: &[u8], w: usize, h: usize, radius: f64) -> Vec<u8> {+ const FAR: f64 = 1e20;+ // A pixel's centre lies (0.5 − coverage) outside the edge when less than half covered.+ let seed = |a: u8| match a {+ 0 => FAR,+ 1..128 => (0.5 - f64::from(a) / 255.0).powi(2),+ _ => 0.0,+ };+ let mut d: Vec<f64> = coverage.iter().map(|&a| seed(a)).collect();+ let mut line = vec![0.0; w.max(h)];+ let mut out = vec![0.0; w.max(h)];+ let (mut v, mut z) = (vec![0usize; w.max(h)], vec![0.0; w.max(h) + 1]);+ for x in 0..w {+ for y in 0..h {+ line[y] = d[y * w + x];+ }+ distance_1d(&line[..h], &mut out[..h], &mut v, &mut z);+ for y in 0..h {+ d[y * w + x] = out[y];+ }+ }+ for y in 0..h {+ line[..w].copy_from_slice(&d[y * w..(y + 1) * w]);+ distance_1d(&line[..w], &mut out[..w], &mut v, &mut z);+ d[y * w..(y + 1) * w].copy_from_slice(&out[..w]);+ }+ coverage+ .iter()+ .zip(d)+ .map(|(&a, d2)| {+ let grown = (radius + 0.5 - d2.sqrt()).clamp(0.0, 1.0);+ a.max((grown * 255.0).round() as u8)+ })+ .collect()+}++/// Squared distance transform of one line (`f`: 0 at sources, huge elsewhere) into `out`.+fn distance_1d(f: &[f64], out: &mut [f64], v: &mut [usize], z: &mut [f64]) {+ let n = f.len();+ if n == 0 {+ return;+ }+ let sq = |q: usize| (q * q) as f64;+ let mut k = 0usize;+ v[0] = 0;+ z[0] = f64::NEG_INFINITY;+ z[1] = f64::INFINITY;+ let meet = |q: usize, p: usize| ((f[q] + sq(q)) - (f[p] + sq(p))) / (2.0 * (q as f64 - p as f64));+ for q in 1..n {+ // z[0] is -∞, so this stops at k = 0 at the latest.+ let mut s = meet(q, v[k]);+ while k > 0 && s <= z[k] {+ k -= 1;+ s = meet(q, v[k]);+ }+ k += 1;+ v[k] = q;+ z[k] = s;+ z[k + 1] = f64::INFINITY;+ }+ k = 0;+ for (q, o) in out.iter_mut().enumerate() {+ while z[k + 1] < q as f64 {+ k += 1;+ }+ let p = v[k];+ let dq = q as f64 - p as f64;+ *o = dq * dq + f[p];+ }+}+ #[cfg(test)] mod tests { use super::*;@@ -563,6 +563,13 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); }+ // Drop shadow and outer glow: cast by the group's objects together.+ if f.opts.quality != DisplayQuality::Fast && (it.effects.drop_shadow.on || it.effects.outer_glow.on) {+ let mut stroke: f64 = 0.0;+ it.walk(&mut |i| stroke = stroke.max(if i.stroke.is_none() { 0.0 } else { i.stroke.extent() }));+ let reach = xf.transform_rect_bbox(it.feather_bounds()).inflate(stroke + 1.0, stroke + 1.0);+ self.draw_below(ctx, f, it, &it.path.to_bezpath(), xf, page, reach);+ } // 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 {@@ -374,3 +374,105 @@ fn feather_choke_and_corners() { let rounded = render(&d); assert!(lum(sharp.pixel(41, 41)) + 20 < lum(rounded.pixel(41, 41)), "{:?} vs {:?}", sharp.pixel(41, 41), rounded.pixel(41, 41)); }++/// A 160 × 160 pt frame without fill at (0, 0) holding a 16 × 16 px image, 10 pt a pixel, that+/// is transparent apart from a black square in its middle (pt 60..100).+fn transparent_image_frame(d: &mut Document) -> ItemId {+ use image::ImageEncoder;+ let px: Vec<u8> =+ (0..16 * 16).flat_map(|i| if (6..10).contains(&(i % 16)) && (6..10).contains(&(i / 16)) { [0, 0, 0, 255] } else { [0, 0, 0, 0] }).collect();+ let mut png = Vec::new();+ image::codecs::png::PngEncoder::new(std::io::Cursor::new(&mut png)).write_image(&px, 16, 16, image::ExtendedColorType::Rgba8).unwrap();+ let aid = designcraft_doc::AssetId(d.alloc());+ d.assets.insert(+ aid,+ std::sync::Arc::new(designcraft_doc::Asset {+ page: 0,+ id: aid,+ name: "a".into(),+ mime: "image/png".into(),+ link: None,+ data: std::sync::Arc::new(png),+ pixels: Some((16, 16)),+ pdf_crop: Default::default(),+ pdf_box: None,+ }),+ );+ let id = rect_item(d, Rect::new(0.0, 0.0, 160.0, 160.0), "[None]");+ d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {+ layer_options: None,+ asset: aid,+ size: (160.0, 160.0),+ xf: Affine::IDENTITY,+ clip: None,+ auto_fit: Default::default(),+ fit_align: 4,+ crop: [0.0; 4],+ wrap: Default::default(),+ fill: Fill::none(),+ });+ id+}++/// Spread grows the shadow of what an image paints, not of its frame: the transparent rest of+/// the image casts nothing, in single- and multithreaded rendering alike.+#[test]+fn spread_grows_what_an_image_paints_not_its_frame() {+ let mut d = doc();+ let id = transparent_image_frame(&mut d);+ let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow;+ ds.on = true;+ ds.color = "[Black]".into();+ ds.distance = 0.0;+ ds.size = 20.0;+ ds.spread = 100.0;+ ds.opacity = 1.0;+ for threads in [0, 3] {+ let img = render_with(&d, threads);+ assert!(lum(img.pixel(110, 80)) < 100, "{threads} threads: the square's shadow grows past it: {:?}", img.pixel(110, 80));+ for (x, y) in [(5, 80), (150, 80), (80, 5), (80, 155), (5, 5)] {+ assert_eq!(lum(img.pixel(x, y)), WHITE, "{threads} threads: no shadow along the frame at ({x}, {y})");+ }+ }+}++/// A group's drop shadow and outer glow are cast by its objects together, offset as set.+#[test]+fn a_groups_drop_shadow_is_cast_by_its_objects() {+ let mut d = doc();+ let lid = d.default_layer();+ let kid = |d: &mut Document, r: Rect| {+ std::sync::Arc::new(Item {+ fill: Fill::swatch("C=100 M=0 Y=0 K=0"),+ ..Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(r))+ })+ };+ let items = vec![kid(&mut d, Rect::new(20.0, 20.0, 60.0, 60.0)), kid(&mut d, Rect::new(90.0, 20.0, 130.0, 60.0))];+ let gid = ItemId(d.alloc());+ let mut g = Item::new(gid, lid, Shape::Group, shapes::rectangle(Rect::new(20.0, 20.0, 130.0, 60.0)));+ g.content = designcraft_doc::Content::Group { items };+ d.insert_item(SpreadRef::Doc(0), g, None).unwrap();+ let plain = render(&d);+ let ds = &mut d.item_mut(gid).unwrap().effects.drop_shadow;+ ds.on = true;+ ds.color = "[Black]".into();+ // Straight down by 40 pt, hard.+ ds.angle = 90.0;+ ds.distance = 40.0;+ ds.size = 0.0;+ ds.opacity = 1.0;+ for threads in [0, 3] {+ let img = render_with(&d, threads);+ assert_eq!(lum(plain.pixel(40, 80)), WHITE);+ assert!(lum(img.pixel(40, 80)) < 120, "{threads} threads: under the first object: {:?}", img.pixel(40, 80));+ assert!(lum(img.pixel(110, 80)) < 120, "{threads} threads: under the second object: {:?}", img.pixel(110, 80));+ assert_eq!(lum(img.pixel(75, 80)), WHITE, "{threads} threads: not between them");+ assert_eq!(img.pixel(40, 40), plain.pixel(40, 40), "{threads} threads: the objects stay on top");+ }+ let e = &mut d.item_mut(gid).unwrap().effects;+ e.drop_shadow.on = false;+ e.outer_glow = designcraft_doc::OuterGlow { on: true, color: "[Black]".into(), opacity: 1.0, size: 10.0, spread: 100.0 };+ let img = render(&d);+ assert!(lum(img.pixel(65, 40)) < 120, "the glow surrounds the objects: {:?}", img.pixel(65, 40));+ assert_eq!(lum(img.pixel(75, 40)), WHITE, "and stops {} pt from them", 10);+}