Files
dwetscherandClaude Opus 5.5 b3ccdf01a7 AF.5: nested overlays innermost first; embedded master pages; text outline budget
- Overlays bake child-first: a group's overlay now lies over its children's
  (they used to be applied after it and win). Each one pays out of the
  import's overlay work budget.
- Master page instances inside embedded documents are left out with a warning:
  their indices count the embedded document's masters, which aren't kept, and
  would show the outer document's.
- A text outline's gradient stops and dashes are charged to the text attribute
  budget, like the fill's.

Addresses CodeRabbit's second review on #3548.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 18:24:57 +07:00

664 lines
26 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Overlay<'_>> {
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<Effect>, Vec<Effect>) = 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<Vec<u8>> {
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<Affine> {
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:?}");
}
}