diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7c7c91fe1..f9b40ba9b 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -15,7 +15,8 @@ //! - 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. //! - Feather: the object is masked by its shape inset by half the width and blurred, so the -//! edge fades from ~0 at the path to full strength `width` inside. +//! edge fades from ~0 at the path to full strength `width` inside. The basic, directional and +//! gradient feathers fade the inner effects with the object. use designcraft_doc::Item; use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; @@ -46,6 +47,17 @@ pub(crate) fn outset(it: &Item) -> f64 { o } +/// Whether `it` has a basic feather: a width on a closed shape. +fn basic_feather(it: &Item) -> bool { + it.effects.feather > 0.0 && it.path.is_closed() +} + +/// Whether any of `it`'s feathers (basic, directional, gradient) fades it. +fn fades(it: &Item) -> bool { + let e = &it.effects; + basic_feather(it) || e.directional_feather.on && e.directional_feather.widths.iter().any(|w| *w > 0.0) || e.gradient_feather.on +} + /// (blur σ, growth) in points for an effect `size` with `pct` percent hardened. fn split(size: f64, pct: f64) -> (f64, f64) { let size = bound(size).max(0.0); @@ -149,17 +161,245 @@ impl Renderer { ctx.pop_layer(); } } - // 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; - let directional = e.directional_feather.on && e.directional_feather.widths.iter().any(|w| *w > 0.0); - if gf.on || directional { + // The object, feathered or not, drawn in its own layer and faded there. A basic feather + // clips the body to the shape at device resolution; only its blurred mask may be drawn + // offscreen at a lower one. + let fading = fades(it); + let basic = basic_feather(it); + if fading { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, None, None, None, None); } - // The object, feathered or not. Choke keeps that much of the width opaque (a shorter - // fade); Sharp and Rounded corners follow the inset shape more tightly than Diffused. - if e.feather > 0.0 && it.path.is_closed() { + if basic { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + } + self.draw_body(ctx, f, it, bp, xf, page); + if basic { + ctx.pop_layer(); + } + if fading { + self.fade(ctx, f, it, bp, xf, reach); + ctx.pop_layer(); + } + if !it.path.is_closed() { + return; + } + // The feathers fade the inner effects with the object: each one is faded in its own + // layer before it blends with what is behind. + let fade = fading.then_some(it); + // 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) + { + 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)), + crate::blend_mode(s.mode).mix, + painted, + fade, + ); + } + // Inner glow: from the edges inwards (or out from the centre), screened over the object. + if e.inner_glow.on + && let Some(c) = doc.resolve_color(&e.inner_glow.color, 1.0) + { + let g = &e.inner_glow; + let (sigma, choke) = split(g.size, g.choke); + // Precise keeps the glow close to the edge: a tighter blur. + let sigma = if g.technique == designcraft_doc::GlowTechnique::Precise { sigma * 0.4 } else { sigma }; + let mix = crate::blend_mode(g.mode).mix; + let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); + if g.center { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |_, c, fr| { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_transform(fr.view * xf); + c.set_paint(paint); + c.fill_path(bp); + // Fade towards the edge: cut a band of the glow's size away. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + c.set_transform(fr.view * xf); + c.set_paint(peniko::Color::BLACK); + c.set_stroke(kurbo::Stroke::new((bound(g.size) - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(bp); + c.pop_layer(); + c.pop_layer(); + }); + } else { + self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted, fade); + } + } + // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. + if e.satin.on + && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) + { + let st = &e.satin; + let off = offset(st.angle, st.distance / 2.0); + let sigma = bound(st.size).max(0.0) / 2.0; + let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); + let invert = st.invert; + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), fade, |_, c, fr| { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + if invert { + c.set_transform(fr.view * xf); + c.set_paint(paint); + c.fill_path(bp); + } + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); + c.set_transform(fr.view * Affine::translate(off) * xf); + c.set_paint(paint); + c.fill_path(bp); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); + c.set_transform(fr.view * Affine::translate(-off) * xf); + c.fill_path(bp); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + }); + } + // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. + if e.bevel.on { + let b = &e.bevel; + let off = offset(doc.light_angle(b.angle, b.global_light), bound(b.size) * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5); + let sigma = bound(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. + let paint = color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, paint, Mix::Screen, painted, fade); + } + if let Some(c) = doc.resolve_color(&b.shadow, 1.0) { + let paint = color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)); + self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, paint, Mix::Multiply, painted, fade); + } + } + } + + /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by + /// `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. With `fade`, + /// the edge fades with that object's feathers. + #[allow(clippy::too_many_arguments)] + fn inner_edge( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + bp: &BezPath, + xf: Affine, + off: Vec2, + sigma: f64, + choke: f64, + paint: peniko::Color, + mix: Mix, + painted: Option<(&Item, ShownOn)>, + fade: Option<&Item>, + ) { + let sigma = bound(sigma).max(0.0); + let choke = bound(choke); + if let Some((it, page)) = painted { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |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), fade, |_, c, fr| { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + // Everything outside the offset shape, so the colour bleeds in from the edges. + let m = Affine::translate(off) * xf; + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); + let mut shape = bp.clone(); + shape.apply_affine(m); + inv.extend(shape.iter()); + c.set_paint(paint); + c.set_fill_rule(peniko::Fill::EvenOdd); + c.fill_path(&inv); + c.set_fill_rule(peniko::Fill::NonZero); + if choke > 0.0 { + c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(&shape); + } + c.pop_layer(); + }); + } + + /// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with + /// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen + /// filter layers. Putting it inside the clip blends with transparent pixels instead. With + /// `fade`, what `draw` paints is faded by that object's feathers before it blends. + #[allow(clippy::too_many_arguments)] + fn with_blended_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + sigma: f64, + mix: Mix, + clip: (&BezPath, Affine), + fade: Option<&Item>, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), + ) { + let layered = mix != Mix::Normal || fade.is_some(); + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } + self.with_filters(ctx, f, reach, sigma, Some(clip), draw); + if let Some(it) = fade { + self.fade(ctx, f, it, clip.0, clip.1, reach); + } + if layered { + ctx.pop_layer(); + } + } + + /// Fade what was drawn in the current layer by `it`'s feathers: the basic feather's blurred + /// inset shape, a ramp for each directional feather side and the gradient feather. `bp` and + /// `xf` are the object's shape and spread transform, `reach` (spread space) what it paints. + fn fade(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, reach: Rect) { + let e = &it.effects; + // Choke keeps that much of the width opaque (a shorter fade); Sharp and Rounded corners + // follow the inset shape more tightly than Diffused. + if basic_feather(it) { let fo = &e.feather_options; let w = bound(e.feather) * (1.0 - fo.choke.clamp(0.0, 100.0) / 100.0).max(0.01); let (sigma, join) = match fo.corners { @@ -167,12 +407,7 @@ impl Renderer { designcraft_doc::FeatherCorners::Rounded => (w / 3.0, kurbo::Join::Round), designcraft_doc::FeatherCorners::Sharp => (w / 6.0, kurbo::Join::Miter), }; - // The body and its clip stay at device resolution; only the blurred mask may be drawn - // offscreen at a lower one. - ctx.set_transform(f.view * xf); - ctx.push_clip_layer(bp); - self.draw_body(ctx, f, it, bp, xf, page); - // Keep the body where the blurred inset silhouette is. + // Keep what was drawn where the blurred inset silhouette is. ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); self.with_filters(ctx, f, reach, sigma, None, |_, c, fr| { @@ -190,9 +425,6 @@ impl Renderer { c.pop_layer(); }); ctx.pop_layer(); - ctx.pop_layer(); - } else { - self.draw_body(ctx, f, it, bp, xf, page); } // Directional feather: one fading ramp per side, multiplied in. let df = &e.directional_feather; @@ -222,206 +454,9 @@ impl Renderer { ctx.pop_layer(); } } - if gf.on { + if e.gradient_feather.on { gradient_feather_mask(ctx, f, it, xf); } - if gf.on || directional { - ctx.pop_layer(); - } - 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) - { - 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)), - crate::blend_mode(s.mode).mix, - painted, - ); - } - // Inner glow: from the edges inwards (or out from the centre), screened over the object. - if e.inner_glow.on - && let Some(c) = doc.resolve_color(&e.inner_glow.color, 1.0) - { - let g = &e.inner_glow; - let (sigma, choke) = split(g.size, g.choke); - // Precise keeps the glow close to the edge: a tighter blur. - let sigma = if g.technique == designcraft_doc::GlowTechnique::Precise { sigma * 0.4 } else { sigma }; - let mix = crate::blend_mode(g.mode).mix; - let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); - if g.center { - 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))); - c.set_transform(fr.view * xf); - c.set_paint(paint); - c.fill_path(bp); - // Fade towards the edge: cut a band of the glow's size away. - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); - c.set_transform(fr.view * xf); - c.set_paint(peniko::Color::BLACK); - c.set_stroke(kurbo::Stroke::new((bound(g.size) - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round)); - c.stroke_path(bp); - c.pop_layer(); - c.pop_layer(); - }); - } else { - 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. - if e.satin.on - && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) - { - let st = &e.satin; - let off = offset(st.angle, st.distance / 2.0); - let sigma = bound(st.size).max(0.0) / 2.0; - let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); - let invert = st.invert; - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { - c.set_transform(fr.view); - c.push_layer(None, None, None, None, Some(blur(sigma))); - if invert { - c.set_transform(fr.view * xf); - c.set_paint(paint); - c.fill_path(bp); - } - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); - c.set_transform(fr.view * Affine::translate(off) * xf); - c.set_paint(paint); - c.fill_path(bp); - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); - c.set_transform(fr.view * Affine::translate(-off) * xf); - c.fill_path(bp); - c.pop_layer(); - c.pop_layer(); - c.pop_layer(); - }); - } - // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. - if e.bevel.on { - let b = &e.bevel; - let off = offset(doc.light_angle(b.angle, b.global_light), bound(b.size) * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5); - let sigma = bound(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, 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, painted); - } - } - } - - /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by - /// `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, - ctx: &mut RenderContext, - f: &Frame, - reach: Rect, - bp: &BezPath, - xf: Affine, - off: Vec2, - sigma: f64, - choke: f64, - paint: peniko::Color, - mix: Mix, - painted: Option<(&Item, ShownOn)>, - ) { - let sigma = bound(sigma).max(0.0); - let choke = bound(choke); - 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))); - // Everything outside the offset shape, so the colour bleeds in from the edges. - let m = Affine::translate(off) * xf; - let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; - let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); - let mut shape = bp.clone(); - shape.apply_affine(m); - inv.extend(shape.iter()); - c.set_paint(paint); - c.set_fill_rule(peniko::Fill::EvenOdd); - c.fill_path(&inv); - c.set_fill_rule(peniko::Fill::NonZero); - if choke > 0.0 { - c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); - c.stroke_path(&shape); - } - c.pop_layer(); - }); - } - - /// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with - /// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen - /// filter layers. Putting it inside the clip blends with transparent pixels instead. - #[allow(clippy::too_many_arguments)] - fn with_blended_filters( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - reach: Rect, - sigma: f64, - mix: Mix, - clip: (&BezPath, Affine), - draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), - ) { - if mix != Mix::Normal { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); - } - self.with_filters(ctx, f, reach, sigma, Some(clip), draw); - if mix != Mix::Normal { - ctx.pop_layer(); - } } /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a @@ -607,4 +642,102 @@ mod tests { // The transparent half shows the page: no shadow painted there. assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); } + + /// A feathered object fades to transparent at its edge, its bevel and inner glow included: where + /// the feather shows what is behind the object, the highlight and shadow leave it as it is. + #[test] + fn feathers_fade_the_inner_effects_with_the_object() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, Fill, SpreadRef}; + let doc = |image: bool, feather: u8, inner: u8| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut back = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 350.0, 350.0)), + ); + back.fill = Fill::swatch("C=0 M=100 Y=0 K=0"); + d.insert_item(SpreadRef::Doc(0), back, None).unwrap(); + 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)), + ); + if image { + let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([40, 160, 90, 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: "green.png".into(), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: Some((20, 20)), + ..Default::default() + }), + ); + 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(), + }); + } else { + it.fill = Fill::swatch("C=100 M=0 Y=0 K=0"); + } + let e = &mut it.effects; + match feather { + 0 => e.feather = 30.0, + 1 => (e.directional_feather.on, e.directional_feather.widths) = (true, [30.0; 4]), + _ => e.gradient_feather.on = true, + } + match inner { + 0 => {} + 1 => (e.bevel.on, e.bevel.size) = (true, 12.0), + _ => (e.inner_glow.on, e.inner_glow.color, e.inner_glow.opacity, e.inner_glow.size) = (true, "[Paper]".into(), 1.0, 20.0), + } + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d + }; + let px = |d: &Document, threads: u16, x: u32| { + let mut r = Renderer::new(); + r.threads = threads; + r.render_page(d, &crate::Cache::new(), 0, 1.0, false, &crate::RenderOptions::default()).unwrap().pixel(x, 200) + }; + let diff = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).map(|(p, q)| u32::from(p.abs_diff(q))).sum::(); + for image in [false, true] { + for feather in 0..3 { + for threads in [0, 4] { + let plain = doc(image, feather, 0); + // The object's faded edges: the left and right ones for the basic and directional + // feathers, the right one for the gradient feather (opaque on the left). + let edges: &[u32] = if feather == 2 { &[299] } else { &[100, 299] }; + for &x in edges { + let behind = px(&plain, threads, 70); + let faded = px(&plain, threads, x); + assert!(diff(faded, behind) < 40, "image {image} feather {feather}: the edge at {x} shows what is behind"); + for inner in [1, 2] { + let with = px(&doc(image, feather, inner), threads, x); + assert!( + diff(with, faded) < 30, + "image {image} feather {feather} inner {inner} threads {threads}: {with:?} over the faded edge at {x}, {faded:?} without" + ); + } + } + } + } + } + } }