diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0cfe63661..f34dbdaae 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -13,7 +13,8 @@ //! 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. +//! shape, blurred and clipped) with screen / multiply blending; satin shows where two offset +//! copies' coverages differ. //! - 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. @@ -278,7 +279,48 @@ impl Renderer { 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 { + if g.center + && let Some((it, page)) = painted + { + // With a graphic, the glow fills what the object paints, shrunk by the band: the + // object's coverage is drawn once offscreen and eroded by the band there. + let band = (bound(g.size) - choke).max(0.5); + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { + let spread = (sigma * 3.0 + band) / fr.px + 2.0; + let Some(o) = mask_offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return }; + let mut off = 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)) * fr.view; + me.draw_body(&mut off, &Frame { mt: false, view, px: fr.px * o.scale, ..*fr }, it, bp, xf, page); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(o.width, o.height); + off.render(&mut pm, &mut me.resources); + let mask = erode(&pm, band / (fr.px * o.scale)); + // The eroded coverage in the glow color, ... + 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_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); + c.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(mask)), + sampler: peniko::ImageSampler::default(), + }); + c.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height))); + let pad = sigma * 4.0 + band + 4.0 * fr.px; + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::SrcIn)), None, None, None); + c.set_transform(fr.view); + c.set_paint(paint); + c.fill_rect(&reach.inflate(pad, pad)); + c.pop_layer(); + c.pop_layer(); + // ... blurred and 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(); + }); + } else 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))); @@ -300,7 +342,10 @@ impl Renderer { } } // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. + // With a graphic, the shape is what the object paints, as for the inner shadow. Plain + // satin on a graphic with no distance compares two equal copies and is not drawn. if e.satin.on + && !(painted.is_some() && !e.satin.invert && e.satin.distance == 0.0) && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) { let st = &e.satin; @@ -308,27 +353,71 @@ impl Renderer { 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); + if let Some((it, page)) = painted { + // With a graphic, the object's coverage is drawn once offscreen and the two shifted + // copies are compared there, pixel by pixel, so a partly transparent pixel that + // both copies cover equally gets no satin. + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |me, c, fr| { + // The copies' shift on the device. + let shift = fr.view * off.to_point() - fr.view * kurbo::Point::ZERO; + let spread = (sigma * 3.0) / fr.px + shift.hypot() + 2.0; + let Some(o) = mask_offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return }; + let mut off_ctx = 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)) * fr.view; + me.draw_body(&mut off_ctx, &Frame { mt: false, view, px: fr.px * o.scale, ..*fr }, it, bp, xf, page); + off_ctx.flush(); + let mut pm = vello_cpu::Pixmap::new(o.width, o.height); + off_ctx.render(&mut pm, &mut me.resources); + let mask = satin_mask(&pm, shift / o.scale, invert); + // The satin colour where the copies differ, ... + 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_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); + c.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(mask)), + sampler: peniko::ImageSampler::default(), + }); + c.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height))); + let pad = sigma * 4.0 + off.hypot() + 4.0 * fr.px; + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::SrcIn)), None, None, None); + c.set_transform(fr.view); 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(); - }); + c.fill_rect(&reach.inflate(pad, pad)); + c.pop_layer(); + c.pop_layer(); + // ... blurred and 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(); + }); + } else { + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { + // One copy of the shape in the satin colour, moved by `by`. + let copy = |c: &mut RenderContext, by: Vec2| { + c.set_transform(fr.view * Affine::translate(by) * xf); + c.set_paint(paint); + c.fill_path(bp); + }; + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + if invert { + copy(c, Vec2::ZERO); + } + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); + copy(c, off); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); + copy(c, -off); + 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 { @@ -760,6 +849,118 @@ fn fit(r: Rect, spread: f64) -> Option { (w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 }) } +/// The most pixels a coverage mask drawn offscreen ([`mask_offscreen`]) holds: 4096 × 4096. +const MAX_MASK_PIXELS: f64 = 4096.0 * 4096.0; + +/// The offscreen image an object's coverage is drawn in to build an effect's mask: `reach` (device +/// pixels) widened by `spread`, within the window widened the same way, as [`offscreen`] finds it, +/// at a resolution low enough that the whole area fits within [`crate::MAX_SIDE`] a side and +/// [`MAX_MASK_PIXELS`] in all. `None` when none of it shows. +fn mask_offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option { + let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread); + let r = reach.inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); + if !(w >= 1.0 && h >= 1.0) { + return None; + } + let scale = (spread / MAX_BLUR_SPREAD).max(1.0).max(w.max(h) / crate::MAX_SIDE as f64).max((w * h / MAX_MASK_PIXELS).sqrt()); + let side = |len: f64| (len / scale).ceil().clamp(1.0, crate::MAX_SIDE as f64); + Some(Offscreen { x0, y0, scale, width: side(w) as u16, height: side(h) as u16 }) +} + +/// `pm`'s coverage shrunk by `r` pixels: each pixel takes the smallest alpha within an octagon of +/// radius `r` around it, as white premultiplied by that alpha. Pixels outside `pm` count as +/// transparent. +fn erode(pm: &vello_cpu::Pixmap, r: f64) -> vello_cpu::Pixmap { + let (w, h) = (pm.width() as usize, pm.height() as usize); + let radius = r.max(0.0).round().min(f64::from(u16::MAX)) as usize; + // The octagon is a square of half-width `side` widened by `diag` diagonal steps each way along + // both diagonals, so it reaches `radius` along the axes and about `radius` diagonally. + let diag = (radius as f64 * (1.0 - std::f64::consts::FRAC_1_SQRT_2)).round() as usize; + let side = radius.saturating_sub(2 * diag); + let mut alpha: Vec = pm.data().iter().map(|p| p.a).collect(); + for (step, k) in [((1, 0), side), ((0, 1), side), ((1, 1), diag), ((1, -1), diag)] { + alpha = erode_lines(&alpha, w, h, step, k); + } + let data = alpha.into_iter().map(|a| vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a }).collect(); + vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()) +} + +/// `src` (`w` × `h`, row by row) with each value replaced by the smallest within `k` steps of it +/// along the lines that `step` walks. Steps past the edge count as 0. +fn erode_lines(src: &[u8], w: usize, h: usize, step: (isize, isize), k: usize) -> Vec { + if k == 0 { + return src.to_vec(); + } + let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h; + let mut out = vec![0; src.len()]; + let mut line = Vec::new(); + let mut window = std::collections::VecDeque::new(); + for y in 0..h as isize { + for x in 0..w as isize { + // Each line starts at the pixel whose step back leaves the image. + if inside(x - step.0, y - step.1) { + continue; + } + line.clear(); + let (mut cx, mut cy) = (x, y); + while inside(cx, cy) { + line.push(cy as usize * w + cx as usize); + (cx, cy) = (cx + step.0, cy + step.1); + } + // A running minimum over each pixel's window, with the values still in it in rising order. + window.clear(); + let mut next = 0; + for i in 0..line.len() { + while next < line.len() && next <= i + k { + let v = src[line[next]]; + while window.back().is_some_and(|&(_, b)| b >= v) { + window.pop_back(); + } + window.push_back((next, v)); + next += 1; + } + while window.front().is_some_and(|&(j, _)| j + k < i) { + window.pop_front(); + } + let whole = i >= k && i + k < line.len(); + out[line[i]] = if whole { window.front().map_or(0, |&(_, v)| v) } else { 0 }; + } + } + } + out +} + +/// The satin coverage of `pm`'s two copies moved by `shift` and `-shift` pixels: at each pixel, +/// how much the copies' coverages differ, or with `invert`, `pm`'s own coverage where they +/// agree, as white premultiplied by that amount. Copies are sampled bilinearly; pixels outside +/// `pm` count as transparent. +fn satin_mask(pm: &vello_cpu::Pixmap, shift: Vec2, invert: bool) -> vello_cpu::Pixmap { + let (w, h) = (i64::from(pm.width()), i64::from(pm.height())); + let src = pm.data(); + let alpha = |x: i64, y: i64| if x < 0 || y < 0 || x >= w || y >= h { 0.0 } else { f64::from(src[(y * w + x) as usize].a) / 255.0 }; + let sample = |x: f64, y: f64| { + let (fx, fy) = (x.floor(), y.floor()); + let (tx, ty) = (x - fx, y - fy); + let (ix, iy) = (fx as i64, fy as i64); + let top = alpha(ix, iy) * (1.0 - tx) + alpha(ix.saturating_add(1), iy) * tx; + let bottom = alpha(ix, iy.saturating_add(1)) * (1.0 - tx) + alpha(ix.saturating_add(1), iy.saturating_add(1)) * tx; + top * (1.0 - ty) + bottom * ty + }; + let mut data = Vec::with_capacity(src.len()); + for y in 0..h { + for x in 0..w { + let (px, py) = (x as f64, y as f64); + let diff = (sample(px - shift.x, py - shift.y) - sample(px + shift.x, py + shift.y)).abs(); + let v = if invert { alpha(x, y) * (1.0 - diff) } else { diff }; + let a = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + data.push(vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a }); + } + } + vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()) +} + #[cfg(test)] mod tests { #[test] @@ -794,16 +995,165 @@ mod tests { assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide)); } + /// A coverage mask covers the whole widened area at a lower resolution when it is wider than + /// `MAX_SIDE` or holds more than `MAX_MASK_PIXELS`; a small one stays at full resolution. + #[test] + fn a_coverage_mask_covers_its_whole_area_within_its_pixel_budget() { + let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 }; + assert_eq!(mask_offscreen(Rect::new(150.0, 150.0, 350.0, 350.0), 10.0, 1440, 900), Some(small)); + for (reach, w, h) in [(Rect::new(0.0, 0.0, 65_535.0, 100.0), u16::MAX, 100), (Rect::new(0.0, 0.0, 20_000.0, 20_000.0), 20_000, 20_000)] { + let o = mask_offscreen(reach, 10.0, w, h).unwrap(); + assert_eq!((o.x0, o.y0), (-10.0, -10.0)); + assert!(o.x0 + f64::from(o.width) * o.scale >= reach.x1 + 10.0, "{o:?}"); + assert!(o.y0 + f64::from(o.height) * o.scale >= reach.y1 + 10.0, "{o:?}"); + assert!(f64::from(o.width) * f64::from(o.height) <= MAX_MASK_PIXELS + f64::from(o.width) + f64::from(o.height), "{o:?}"); + } + } + /// 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() { + let (d, mut it) = half_image_frame(); + 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; + let lum = render_row(d, it); + // 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)); + } + + /// Satin on a frame holding an image whose left half is transparent multiplies only over the + /// image's opaque part, plain or inverted: the page shows through the transparent half. + #[test] + fn satin_follows_a_transparent_images_edge() { + for invert in [false, true] { + let (d, mut it) = half_image_frame(); + let s = &mut it.effects.satin; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.angle = 0.0; + s.distance = 100.0; + s.size = 4.0; + s.invert = invert; + let lum = render_row(d, it); + // The copies are 50 pt to each side. Over the frame they leave the satin on 100 to 150 + // (plain) or 150 to 250 (inverted), over the transparent half of the image. + let clear = if invert { 175 } else { 125 }; + assert!(lum(clear) > 700, "invert {invert}: satin over the transparent part at x = {clear}: {}", lum(clear)); + // Over the image's opaque part the copies (150 to 250 and 250 to 350) leave plain satin on + // all of it and inverted satin on none of it. + let shaded = lum(225) < 100; + assert_eq!(shaded, !invert, "invert {invert}: lum at x = 225 is {}", lum(225)); + } + } + + /// Satin on a frame holding a uniformly half-transparent image: where both copies cover the + /// same partial coverage (all of the image with a distance of 0, its middle with a short one), + /// plain satin leaves the image as it is; inverted satin shades it. + #[test] + fn satin_leaves_a_uniform_half_transparent_image_plain() { + for (distance, invert) in [(0.0, false), (40.0, false), (0.0, true)] { + let (d, mut it) = image_frame(|_| image::Rgba([255, 255, 255, 128])); + let s = &mut it.effects.satin; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.angle = 0.0; + s.distance = distance; + s.size = 4.0; + s.invert = invert; + let lum = render_row(d, it); + // The image is white over a white page; satin multiplies black over it. + let shaded = lum(200) < 700; + assert_eq!(shaded, invert, "distance {distance}, invert {invert}: lum at x = 200 is {}", lum(200)); + } + } + + /// The satin mask is the difference of the shifted coverages: none where both copies cover the + /// same half coverage, half along an edge only one copy reaches, and the inverse keeps the + /// pixmap's own coverage where they agree. + #[test] + fn the_satin_mask_compares_partial_coverage() { + let half = vello_cpu::color::PremulRgba8 { r: 128, g: 128, b: 128, a: 128 }; + let pm = vello_cpu::Pixmap::from_parts(vec![half; 40 * 40], 40, 40); + let plain = satin_mask(&pm, Vec2::new(3.0, 0.0), false); + assert_eq!(plain.sample(20, 20).a, 0); + assert_eq!(plain.sample(1, 20).a, 128); + let inverted = satin_mask(&pm, Vec2::new(3.0, 0.0), true); + assert_eq!(inverted.sample(20, 20).a, 128); + assert_eq!(inverted.sample(1, 20).a, 64); + } + + /// An inner glow from the center on a frame holding an image whose left half is transparent + /// fills only the image's opaque part, fading toward that part's own edge. + #[test] + fn a_center_inner_glow_follows_a_transparent_images_edge() { + let (d, mut it) = half_image_frame(); + let g = &mut it.effects.inner_glow; + g.on = true; + g.center = true; + g.color = "[Black]".into(); + g.opacity = 1.0; + g.size = 20.0; + g.mode = Default::default(); + let lum = render_row(d, it); + // The transparent half shows the page: no glow painted there. + assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); + // The opaque part runs from x = 200 to 300: dark in its middle, light at its own left edge. + assert!(lum(250) + 60 < lum(204), "glow fades toward the image's own edge: {} vs {}", lum(250), lum(204)); + } + + /// Eroding keeps a half-transparent area at half coverage away from its edge and clears the + /// band along the edge. + #[test] + fn erosion_keeps_partial_coverage_and_clears_the_edge_band() { + let half = vello_cpu::color::PremulRgba8 { r: 128, g: 128, b: 128, a: 128 }; + let pm = vello_cpu::Pixmap::from_parts(vec![half; 40 * 40], 40, 40); + let out = erode(&pm, 5.0); + assert_eq!(out.sample(20, 20).a, 128); + assert_eq!(out.sample(2, 20).a, 0); + assert_eq!(out.sample(20, 37).a, 0); + assert_eq!(out.sample(6, 20).a, 128); + } + + /// Eroding clears every pixel within the radius of a one-pixel hole, along the axes and the + /// diagonals, and keeps the pixels beyond it. + #[test] + fn erosion_clears_around_a_small_hole() { + let opaque = vello_cpu::color::PremulRgba8 { r: 255, g: 255, b: 255, a: 255 }; + let mut data = vec![opaque; 40 * 40]; + data[20 * 40 + 20] = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }; + let out = erode(&vello_cpu::Pixmap::from_parts(data, 40, 40), 5.0); + for (x, y) in [(20, 20), (24, 20), (16, 20), (20, 24), (20, 16), (23, 23), (17, 17), (23, 17), (17, 23)] { + assert_eq!(out.sample(x, y).a, 0, "({x}, {y}) is within 5 pixels of the hole"); + } + for (x, y) in [(27, 20), (20, 13), (25, 25), (14, 14)] { + assert_eq!(out.sample(x, y).a, 255, "({x}, {y}) is more than 5 pixels from the hole"); + } + } + + /// A 200 pt frame at (100, 100) holding a 20 × 20 image with a transparent left half and an + /// opaque white right half, in a new document. + fn half_image_frame() -> (designcraft_doc::Document, Item) { + image_frame(|x| if x < 10 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([255, 255, 255, 255]) }) + } + + /// A 200 pt frame at (100, 100) holding a 20 × 20 image whose pixels in column x are + /// `pixel(x)`, in a new document. + fn image_frame(pixel: impl Fn(u32) -> image::Rgba) -> (designcraft_doc::Document, Item) { + use designcraft_doc::Document; 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 img = image::RgbaImage::from_fn(20, 20, |x, _| pixel(x)); 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()); @@ -836,26 +1186,20 @@ mod tests { 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(); + (d, it) + } + + /// The first page with `it` added, rendered at 100%: the sum of R, G and B at x on row 200. + fn render_row(mut d: designcraft_doc::Document, it: Item) -> impl Fn(u32) -> u32 { + d.insert_item(designcraft_doc::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| { + move |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)); + } } /// A document with one image frame casting a drop shadow, and the frame's id.