//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what //! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's //! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps). //! //! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid //! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow, //! Darker/Lighter Color) use their usual definitions. use std::borrow::Cow; use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point}; use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke}; /// Deepest subtree an overlay is baked into (the reader bounds trees well below this). const MAX_DEPTH: usize = 256; /// Most pixels an overlay is baked into per image. const MAX_PIXELS: usize = 64 << 20; /// Work the overlays of one import may take together: each overlay counts the nodes and pixels /// of the subtree it is baked into (which is copied once more for them all). Nested overlays /// and shared content would otherwise multiply it; past it, overlays are left out and said. pub const MAX_WORK: u64 = 1 << 29; #[derive(Clone, Copy)] enum Source<'a> { Solid(Color), Gradient(&'a Gradient), } #[derive(Clone, Copy)] struct Overlay<'a> { source: Source<'a>, opacity: f64, blend: Blend, } /// `node` with its colour and gradient overlays baked in, and those of everything in it /// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`], /// can't pay for copying it). `warn` hears what could only be approximated or was left out. pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> { if overlays(&node.effects).is_empty() { return Cow::Borrowed(node); } let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else { warn("overlay effects beyond the work one import may take (left out)"); return Cow::Borrowed(node); }; *work = left; let mut out = node.clone(); bake_tree(&mut out, work, 0, warn); Cow::Owned(out) } /// The visible colour and gradient overlays among `effects`. fn overlays(effects: &[Effect]) -> Vec> { effects .iter() .filter_map(|e| match e { Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }), Effect::GradientOverlay { gradient, opacity, blend } => { Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend }) } _ => None, }) .filter(|o| o.opacity > 0.0) .collect() } fn is_overlay(e: &Effect) -> bool { matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }) } /// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay /// lies over what its node shows, its children's overlays included. Each overlay pays for the /// subtree it is baked into out of `work`; past it, it is left out (and said). fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) { if depth > MAX_DEPTH { return; } for c in &mut node.children { bake_tree(c, work, depth + 1, warn); } if !node.effects.iter().any(is_overlay) { return; } let (effects, rest): (Vec, Vec) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay); node.effects = rest; let overlays = overlays(&effects); if overlays.is_empty() { return; } let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX); let cost = subtree_cost(node, *work / count, 0).saturating_mul(count); let Some(left) = work.checked_sub(cost) else { warn("overlay effects beyond the work one import may take (left out)"); return; }; *work = left; for o in &overlays { if matches!(o.blend, Blend::Erase | Blend::PassThrough) { warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)"); } bake_node(node, o, 0, warn); } } /// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`. fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 { if depth > MAX_DEPTH { return 0; } let mut cost: u64 = match &node.kind { Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1), Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1), Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1), _ => 1, }; for c in &node.children { if cost > limit { break; } cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1)); } cost } fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) { if depth > MAX_DEPTH { return; } match &mut node.kind { Kind::Shape { fills, strokes, .. } => { for f in fills.iter_mut() { *f = paint(f, o, warn); } strokes.iter_mut().for_each(|s| stroke(s, o, warn)); } Kind::Artboard { background, strokes, .. } => { for f in background.iter_mut() { *f = paint(f, o, warn); } strokes.iter_mut().for_each(|s| stroke(s, o, warn)); } Kind::Text(t) => { for r in &mut t.runs { r.fill = paint(&r.fill, o, warn); if let Some(s) = &mut r.stroke { stroke(s, o, warn); } } } Kind::Table(t) => { for c in &mut t.cells { for r in &mut c.runs { r.fill = paint(&r.fill, o, warn); if let Some(s) = &mut r.stroke { stroke(s, o, warn); } } } for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() { stroke(s, o, warn); } } Kind::Image(img) => { if !pixels(img, o) { warn("overlay effects on images too large to recolour (left out)"); } } Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} } for e in &mut node.effects { match e { Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o), Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {} Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {} } } for c in &mut node.children { bake_node(c, o, depth + 1, warn); } } fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) { s.paint = paint(&s.paint, o, warn); } /// A paint with the overlay over it. fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint { match (p, o.source) { (Paint::None, _) => Paint::None, (Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)), (Paint::Gradient(g), Source::Solid(s)) => { Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() }) } // Over one colour, the result is the overlay's gradient with each stop laid over it. (Paint::Solid(c), Source::Gradient(g)) => { Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() }) } (Paint::Gradient(base), Source::Gradient(g)) => { if !(o.blend == Blend::Normal && o.opacity >= 1.0) { warn("gradient overlays on gradients (approximated)"); } // Exact when the overlay covers it; otherwise over the base gradient's average colour. let avg = average(base); Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() }) } } } impl Source<'_> { /// One colour for the source (a gradient's average) where only one fits. fn at_solid(&self) -> Color { match self { Source::Solid(c) => *c, Source::Gradient(g) => average(g), } } } fn average(g: &Gradient) -> Color { let n = g.stops.len().max(1) as f64; let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0); for s in &g.stops { let [sr, sg, sb] = rgb(s.color); r += sr; gr += sg; b += sb; a += s.color.alpha(); } Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n } } /// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept. fn over(base: Color, source: Color, o: &Overlay) -> Color { let k = (o.opacity * source.alpha()).clamp(0.0, 1.0); let alpha = base.alpha(); // Normal mixes within the colour model when both share it, so CMYK stays CMYK. if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) { let mix = |a: f64, b: f64| a + (b - a) * k; match (base, source) { (Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => { return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha }; } (Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha }, (_, s) if k >= 1.0 => return with_alpha(s, alpha), _ => {} } } let b = rgb(base); let s = rgb(source); let blended = blend(o.blend, b, s); let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k); Color::Rgb { r, g, b: bl, a: alpha } } fn with_alpha(c: Color, a: f64) -> Color { match c { Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a }, Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a }, Color::Gray { v, .. } => Color::Gray { v, a }, Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a }, } } /// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the /// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour. fn rgb(c: Color) -> [f64; 3] { let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; match c { Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)], Color::Gray { v, .. } => [f(v); 3], Color::Cmyk { c, m, y, k, .. } => { let w = 1.0 - f(k); [(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w] } Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f), } } /// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65. fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; let (l, a, b) = (finite(l), finite(a), finite(b)); let fy = (l + 16.0) / 116.0; let (fx, fz) = (fy + a / 500.0, fy - b / 200.0); let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; // D50 white. let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz)); // XYZ (D50) to linear sRGB, Bradford-adapted. let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z; let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z; let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z; let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }; [gamma(r), gamma(g), gamma(bl)] } /// Bake the overlay into an image's pixels. False if they couldn't be read or are too many. fn pixels(img: &mut Image, o: &Overlay) -> bool { let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false }; let Some(len) = n.checked_mul(4) else { return false }; let mut px = match &img.pixels { Pixels::Rgba8(p) => match p.get(..len) { Some(p) => p.to_vec(), None => return false, }, Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) { Some(p) => p, None => return false, }, // An overlay is blended in RGB: CMYK pixels take the profile-free formula here. Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) { Some(p) if p.len() == n * 5 => p .as_chunks::<5>() .0 .iter() .flat_map(|[c, m, y, k, a]| { let w = 255 - u16::from(*k); let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8; [ch(*c), ch(*m), ch(*y), *a] }) .collect(), _ => return false, }, }; let width = img.width.max(1) as usize; // A solid source is converted once; a gradient is sampled at each pixel's centre. let solid = match o.source { Source::Solid(c) => Some((rgb(c), c.alpha())), Source::Gradient(_) => None, }; let to_unit = match o.source { Source::Gradient(g) => match invert(g.transform) { Some(m) => Some((img.transform.then(m), g)), None => return false, }, Source::Solid(_) => None, }; for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() { if p[3] == 0 { continue; } let (s, sa) = match (solid, &to_unit) { (Some(s), _) => s, (None, Some((m, g))) => { let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); let c = sample(g, q); (rgb(c), c.alpha()) } (None, None) => continue, }; let k = (o.opacity * sa).clamp(0.0, 1.0); let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0); let m = blend(o.blend, b, s); for c in 0..3 { p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8; } } img.pixels = Pixels::Rgba8(px); true } /// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says. fn decode(bytes: &[u8], width: u32, height: u32) -> Option> { let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; if probe.into_dimensions().ok()? != (width, height) { return None; } let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(width); limits.max_image_height = Some(height); limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8); reader.limits(limits); Some(reader.decode().ok()?.into_rgba8().into_raw()) } /// The gradient's colour at unit-space point `q`. fn sample(g: &Gradient, q: Point) -> Color { let t = match g.kind { GradientKind::Linear => q.x, GradientKind::Radial => q.x.hypot(q.y), GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU, }; let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } }; if t <= first.offset { return first.color; } for w in g.stops.windows(2) { let [a, b] = [&w[0], &w[1]]; if t <= b.offset { let span = b.offset - a.offset; let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; // Affinity's midpoint bias (see `Stop::half_point`). let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; let (x, y) = (rgb(a.color), rgb(b.color)); let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; } } last.color } fn invert(m: Affine) -> Option { let [a, b, c, d, e, f] = m.0; let det = a * d - b * c; if !(det.is_finite() && det.abs() > 1e-12) { return None; } let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) } /// The blend of backdrop `b` and source `s` (sRGB, 0..1). fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] { use Blend::*; let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0)); match mode { Normal | PassThrough | Erase => s, Darken => sep(f64::min), Lighten => sep(f64::max), Multiply => sep(|b, s| b * s), Screen => sep(screen), ColorBurn => sep(burn), ColorDodge => sep(dodge), Blend::Overlay => sep(|b, s| hard_light(s, b)), HardLight => sep(hard_light), SoftLight => sep(soft_light), Difference => sep(|b, s| (b - s).abs()), Exclusion => sep(|b, s| b + s - 2.0 * b * s), Add => sep(|b, s| b + s), Subtract => sep(|b, s| b - s), VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }), LinearLight => sep(|b, s| b + 2.0 * s - 1.0), PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), Average => sep(|b, s| (b + s) / 2.0), Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()), Reflect => sep(reflect), Glow => sep(|b, s| reflect(s, b)), DarkerColor => { if lum(s) < lum(b) { s } else { b } } LighterColor => { if lum(s) > lum(b) { s } else { b } } Hue => set_lum(set_sat(s, sat(b)), lum(b)), Saturation => set_lum(set_sat(b, sat(s)), lum(b)), Blend::Color => set_lum(s, lum(b)), Luminosity => set_lum(b, lum(s)), } } fn screen(b: f64, s: f64) -> f64 { b + s - b * s } fn burn(b: f64, s: f64) -> f64 { if b >= 1.0 { 1.0 } else if s <= 0.0 { 0.0 } else { 1.0 - ((1.0 - b) / s).min(1.0) } } fn dodge(b: f64, s: f64) -> f64 { if b <= 0.0 { 0.0 } else if s >= 1.0 { 1.0 } else { (b / (1.0 - s)).min(1.0) } } fn hard_light(b: f64, s: f64) -> f64 { if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } } fn soft_light(b: f64, s: f64) -> f64 { if s <= 0.5 { b - (1.0 - 2.0 * s) * b * (1.0 - b) } else { let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; b + (2.0 * s - 1.0) * (d - b) } } fn reflect(b: f64, s: f64) -> f64 { if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) } } fn lum(c: [f64; 3]) -> f64 { 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] } fn clip_color(c: [f64; 3]) -> [f64; 3] { let l = lum(c); let n = c[0].min(c[1]).min(c[2]); let x = c[0].max(c[1]).max(c[2]); c.map(|v| { let mut v = v; if n < 0.0 && l - n > 1e-12 { v = l + (v - l) * l / (l - n); } if x > 1.0 && x - l > 1e-12 { v = l + (v - l) * (1.0 - l) / (x - l); } v.clamp(0.0, 1.0) }) } fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] { let d = l - lum(c); clip_color(c.map(|v| v + d)) } fn sat(c: [f64; 3]) -> f64 { c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) } fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] { let max = c[0].max(c[1]).max(c[2]); let min = c[0].min(c[1]).min(c[2]); if max - min <= 1e-12 { return [0.0; 3]; } c.map(|v| (v - min) * s / (max - min)) } #[cfg(test)] mod tests { use super::*; fn solid(c: Color, opacity: f64, blend: Blend) -> Node { Node { class: crate::stream::Tag::of(b"ShpN"), name: String::new(), visible: true, locked: false, opacity: 1.0, blend: Blend::Normal, kind: Kind::Shape { path: Default::default(), fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })], strokes: vec![], even_odd: false, }, mask: None, pixel_mask: None, effects: vec![Effect::ColorOverlay { color: c, opacity, blend }], children: vec![], } } fn fill(n: &Node) -> Color { match &n.kind { Kind::Shape { fills, .. } => match fills.first() { Some(Paint::Solid(c)) => *c, other => panic!("{other:?}"), }, other => panic!("{other:?}"), } } #[test] fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() { let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); assert_eq!(fill(&n), white); assert!(n.effects.is_empty(), "the overlay is baked, not kept"); // Half way, mixed in RGB. let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 }); } #[test] fn blend_modes_follow_their_formulas() { let b = [0.25, 0.5, 1.0]; assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]); assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]); assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]); assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]); // Color keeps the backdrop's luminosity with the source's hue and saturation. let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]); assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}"); } #[test] fn overlays_recolour_pixels_inside_their_alpha() { let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY }); let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); let Kind::Image(img) = &out.kind else { panic!() }; assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0])); } #[test] fn a_gradient_overlay_over_one_colour_becomes_that_gradient() { let g = Gradient { kind: GradientKind::Linear, stops: vec![ Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 }, Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 }, ], transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]), }; let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal); n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }]; let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); let Kind::Shape { fills, .. } = &out.kind else { panic!() }; assert_eq!(fills.first(), Some(&Paint::Gradient(g))); } #[test] fn a_group_overlay_lies_over_its_childrens_overlays() { let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }; let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }; let mut group = solid(red, 0.5, Blend::Normal); group.kind = Kind::Group; group.children = vec![solid(blue, 1.0, Blend::Normal)]; // The copy (2 nodes), the child's overlay (1) and the group's (2). let mut work = 5; let out = apply(&group, &mut work, &mut |_| {}).into_owned(); assert_eq!(work, 0); let child = out.children.first().unwrap(); // Blue first, then half of the group's red over it. assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 }); assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked"); // With no work left for the group's overlay, the child's still applies. let mut warnings = Vec::new(); let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned(); assert_eq!(fill(out.children.first().unwrap()), blue); assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); } #[test] fn overlays_stop_at_the_work_one_import_may_take() { let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY }); // 16 pixels and the node, for the copy and for the overlay: 34. let mut work = 40; let mut warnings = Vec::new(); assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_))); assert_eq!((work, warnings.len()), (6, 0)); assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_))); assert_eq!(work, 6); assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); } }