Skip to main content

otf_pixels_ops/
composite.rs

1//! [`Composite`] — draw one image over another (sharp's `composite`).
2//!
3//! This is the first op with two inputs, which makes it the first place
4//! `Op::arity` and the two-element `input_regions` vector do real work.
5//!
6//! # Placement
7//!
8//! An overlay is drawn at an offset, at one of sharp's nine gravities, or
9//! tiled across the whole base ([`Placement`]). Gravity and tiling depend on
10//! both images' sizes, so like [`crate::Resize`] an instance binds them when
11//! it is chained and belongs to that one graph edge.
12//!
13//! # Alpha
14//!
15//! SPEC §Formats: alpha is unassociated at API boundaries. Blending is
16//! therefore computed in premultiplied form internally and converted back on
17//! the way out, because straight-alpha compositing has no correct one-line
18//! form — the naive `f*a + b*(1-a)` is only right when the backdrop is opaque.
19//!
20//! Getting this wrong shows up as a dark halo around soft edges, which is the
21//! single most common compositing bug and looks like a bad matte rather than
22//! like arithmetic.
23//!
24//! # Blend modes
25//!
26//! sharp's, which are libvips', which are Cairo's operators: the Porter-Duff
27//! set and the separable PDF blend modes, with the formulas of
28//! <https://www.cairographics.org/operators/>. A mode acts only where the
29//! overlay lies: outside it the base is untouched, even for modes such as
30//! [`Blend::Clear`] that would erase it if the overlay were extended with
31//! transparency.
32
33use std::sync::OnceLock;
34
35use otf_pixels_core::{
36    ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, SampleKind, Tile,
37    TileMut,
38};
39
40use crate::convert::{from_rgba, to_rgba};
41use crate::resize::Position;
42
43/// How the overlay is combined with the base: sharp's `blend`.
44///
45/// In the formulas, `A` is the overlay and `B` the base.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
47#[non_exhaustive]
48pub enum Blend {
49    /// Nothing: the area under the overlay becomes transparent.
50    Clear,
51    /// The overlay replaces the base, alpha included.
52    Source,
53    /// Porter-Duff source-over: the overlay is drawn on top of the base.
54    #[default]
55    Over,
56    /// The overlay where the base is, transparent elsewhere.
57    In,
58    /// The overlay where the base is not.
59    Out,
60    /// The overlay over the base, kept to the base's alpha.
61    Atop,
62    /// The base alone: the overlay is ignored.
63    Dest,
64    /// The base over the overlay.
65    DestOver,
66    /// The base where the overlay is.
67    DestIn,
68    /// The base where the overlay is not: an overlay used as an eraser.
69    DestOut,
70    /// The base over the overlay, kept to the overlay's alpha.
71    DestAtop,
72    /// Each where the other is not.
73    Xor,
74    /// Colours and alphas summed.
75    Add,
76    /// The overlay added only as far as the base has room for it.
77    Saturate,
78    /// Colours multiplied: darkens.
79    Multiply,
80    /// Inverted colours multiplied: lightens.
81    Screen,
82    /// Multiply or screen, chosen by the base's colour.
83    Overlay,
84    /// The darker of the two colours.
85    Darken,
86    /// The lighter of the two colours.
87    Lighten,
88    /// The base brightened to reflect the overlay.
89    ColourDodge,
90    /// The base darkened to reflect the overlay.
91    ColourBurn,
92    /// Multiply or screen, chosen by the overlay's colour.
93    HardLight,
94    /// A softer [`Blend::HardLight`].
95    SoftLight,
96    /// The absolute difference of the colours.
97    Difference,
98    /// [`Blend::Difference`] with lower contrast.
99    Exclusion,
100}
101
102impl Blend {
103    /// Whether an opaque base stays opaque under this mode wherever an
104    /// overlay with (`overlay_alpha`) or without alpha lands. When it does
105    /// not, an opaque base format gains alpha.
106    const fn keeps_opaque(self, overlay_alpha: bool) -> bool {
107        match self {
108            Self::Clear | Self::Out | Self::DestOut | Self::Xor => false,
109            Self::Source | Self::In | Self::DestIn | Self::DestAtop => !overlay_alpha,
110            _ => true,
111        }
112    }
113
114    /// A PDF mode's mix of overlay colour `a` and base colour `b`, both
115    /// straight, 0..=1.
116    fn mix(self, a: f32, b: f32) -> f32 {
117        let multiply = |x: f32, y: f32| x * y;
118        let screen = |x: f32, y: f32| x + y - x * y;
119        let hard_light = |source: f32, backdrop: f32| {
120            if source <= 0.5 {
121                multiply(backdrop, 2.0 * source)
122            } else {
123                screen(backdrop, 2.0 * source - 1.0)
124            }
125        };
126        match self {
127            Self::Multiply => multiply(a, b),
128            Self::Screen => screen(a, b),
129            Self::Overlay => hard_light(b, a),
130            Self::Darken => a.min(b),
131            Self::Lighten => a.max(b),
132            Self::ColourDodge => {
133                if b <= 0.0 {
134                    0.0
135                } else if a >= 1.0 {
136                    1.0
137                } else {
138                    (b / (1.0 - a)).min(1.0)
139                }
140            }
141            Self::ColourBurn => {
142                if b >= 1.0 {
143                    1.0
144                } else if a <= 0.0 {
145                    0.0
146                } else {
147                    1.0 - ((1.0 - b) / a).min(1.0)
148                }
149            }
150            Self::HardLight => hard_light(a, b),
151            Self::SoftLight => {
152                if a <= 0.5 {
153                    b - (1.0 - 2.0 * a) * b * (1.0 - b)
154                } else {
155                    let d = if b <= 0.25 {
156                        ((16.0 * b - 12.0) * b + 4.0) * b
157                    } else {
158                        b.sqrt()
159                    };
160                    b + (2.0 * a - 1.0) * (d - b)
161                }
162            }
163            Self::Difference => (a - b).abs(),
164            Self::Exclusion => a + b - 2.0 * a * b,
165            _ => a,
166        }
167    }
168
169    /// Blend straight RGBA `a` (overlay) onto `b` (base), giving straight
170    /// RGBA.
171    fn apply(self, a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
172        let (aa, ab) = (a[3], b[3]);
173        // Each mode is its result alpha and, per channel, its premultiplied
174        // colour from the straight colours `x` (overlay) and `y` (base).
175        let alpha = match self {
176            Self::Clear => 0.0,
177            Self::Source | Self::DestAtop => aa,
178            Self::In | Self::DestIn => aa * ab,
179            Self::Out => aa * (1.0 - ab),
180            Self::Atop | Self::Dest => ab,
181            Self::DestOut => (1.0 - aa) * ab,
182            Self::Xor => aa + ab - 2.0 * aa * ab,
183            Self::Add | Self::Saturate => (aa + ab).min(1.0),
184            // Over, DestOver and the PDF modes.
185            _ => aa + ab * (1.0 - aa),
186        };
187        let colour = |x: f32, y: f32| -> f32 {
188            match self {
189                Self::Clear => 0.0,
190                Self::Source => x * aa,
191                Self::Over => x * aa + y * ab * (1.0 - aa),
192                Self::In => x * aa * ab,
193                Self::Out => x * aa * (1.0 - ab),
194                Self::Atop => x * aa * ab + y * ab * (1.0 - aa),
195                Self::Dest => y * ab,
196                Self::DestOver => y * ab + x * aa * (1.0 - ab),
197                Self::DestIn => y * ab * aa,
198                Self::DestOut => y * ab * (1.0 - aa),
199                Self::DestAtop => y * ab * aa + x * aa * (1.0 - ab),
200                Self::Xor => x * aa * (1.0 - ab) + y * ab * (1.0 - aa),
201                Self::Add => x * aa + y * ab,
202                Self::Saturate => x * aa.min(1.0 - ab) + y * ab,
203                _ => {
204                    x * aa * (1.0 - ab)
205                        + y * ab * (1.0 - aa)
206                        + aa * ab * self.mix(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0))
207                }
208            }
209        };
210        if alpha <= 0.0 {
211            // Fully transparent: colour is undefined, so it is zeroed rather
212            // than divided by zero.
213            return [0.0; 4];
214        }
215        let straight = |x: f32, y: f32| (colour(x, y) / alpha).clamp(0.0, 1.0);
216        [
217            straight(a[0], b[0]),
218            straight(a[1], b[1]),
219            straight(a[2], b[2]),
220            alpha.clamp(0.0, 1.0),
221        ]
222    }
223}
224
225/// Where the overlay goes: sharp's `left`/`top`, `gravity` and `tile`.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227#[non_exhaustive]
228pub enum Placement {
229    /// The overlay's top-left corner at `(x, y)` in base coordinates.
230    /// Negative offsets clip rather than fail.
231    At(i64, i64),
232    /// Against an edge, a corner or the middle of the base, as sharp's
233    /// `gravity` places it. An overlay larger than the base overhangs it
234    /// evenly and is clipped.
235    Gravity(Position),
236    /// Repeated over the whole base, aligned as sharp aligns `tile` with a
237    /// `gravity`: centred, for instance, puts one copy in the middle.
238    Tiled(Position),
239    /// Repeated over the whole base, the base's top-left pixel showing the
240    /// overlay's pixel at `(x, y)` (taken modulo its size), as sharp's
241    /// `tile` with `left` and `top` does.
242    TiledFrom(i64, i64),
243}
244
245impl Default for Placement {
246    /// Centred, sharp's default gravity.
247    fn default() -> Self {
248        Self::Gravity(Position::Centre)
249    }
250}
251
252/// The sizes a composite was chained with, and what they resolve to.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254struct Bound {
255    base: (u32, u32),
256    overlay: (u32, u32),
257    /// The overlay's offset for a single copy, or the overlay pixel shown at
258    /// the base's origin for a tiled one.
259    at: (i64, i64),
260    tiled: bool,
261}
262
263/// The near edge, the middle or the far edge of a `spare` span, for gravity
264/// along one axis: `near` and `far` say which edge the gravity names.
265/// `round_up` follows sharp, which centres a tile crop rounding up and an
266/// overlay rounding toward zero.
267const fn along(spare: i64, near: bool, far: bool, round_up: bool) -> i64 {
268    if near {
269        0
270    } else if far {
271        spare
272    } else if round_up {
273        (spare + 1) / 2
274    } else {
275        spare / 2
276    }
277}
278
279/// Which edges a gravity names: `(west, east, north, south)`.
280const fn edges(position: Position) -> (bool, bool, bool, bool) {
281    match position {
282        Position::Centre => (false, false, false, false),
283        Position::North => (false, false, true, false),
284        Position::NorthEast => (false, true, true, false),
285        Position::East => (false, true, false, false),
286        Position::SouthEast => (false, true, false, true),
287        Position::South => (false, false, false, true),
288        Position::SouthWest => (true, false, false, true),
289        Position::West => (true, false, false, false),
290        Position::NorthWest => (true, false, true, false),
291    }
292}
293
294/// The phase of a tiled overlay along one axis: which overlay pixel the
295/// base's first pixel shows. sharp replicates the overlay enough times to
296/// cover the base — an odd number when centred on this axis, so one copy
297/// sits in the middle — then crops the result to the base by the gravity.
298fn tile_phase(base: u32, overlay: u32, near: bool, far: bool) -> i64 {
299    let (base, overlay) = (i64::from(base), i64::from(overlay));
300    let copies = if overlay <= base {
301        let copies = (base + overlay - 1) / overlay;
302        if near || far { copies } else { copies | 1 }
303    } else {
304        1
305    };
306    along(copies * overlay - base, near, far, true).rem_euclid(overlay)
307}
308
309impl Bound {
310    fn new(placement: Placement, base: &ImageDescriptor, overlay: &ImageDescriptor) -> Self {
311        let (bw, bh) = (base.width, base.height);
312        let (ow, oh) = (overlay.width, overlay.height);
313        let (at, tiled) = match placement {
314            Placement::At(x, y) => ((x, y), false),
315            Placement::Gravity(position) => {
316                let (west, east, north, south) = edges(position);
317                let spare_x = i64::from(bw) - i64::from(ow);
318                let spare_y = i64::from(bh) - i64::from(oh);
319                (
320                    (
321                        along(spare_x, west, east, false),
322                        along(spare_y, north, south, false),
323                    ),
324                    false,
325                )
326            }
327            Placement::Tiled(position) => {
328                let (west, east, north, south) = edges(position);
329                (
330                    (
331                        tile_phase(bw, ow, west, east),
332                        tile_phase(bh, oh, north, south),
333                    ),
334                    true,
335                )
336            }
337            Placement::TiledFrom(x, y) => (
338                (x.rem_euclid(i64::from(ow)), y.rem_euclid(i64::from(oh))),
339                true,
340            ),
341        };
342        Self {
343            base: (bw, bh),
344            overlay: (ow, oh),
345            at,
346            tiled,
347        }
348    }
349
350    /// The overlay coordinate base coordinate `at` shows along one axis,
351    /// or `None` where a single copy does not reach.
352    fn source(&self, at: u32, offset: i64, size: u32) -> Option<u32> {
353        let i = i64::from(at);
354        let mapped = if self.tiled {
355            (i + offset).rem_euclid(i64::from(size))
356        } else {
357            let j = i - offset;
358            if j < 0 || j >= i64::from(size) {
359                return None;
360            }
361            j
362        };
363        u32::try_from(mapped).ok()
364    }
365
366    /// The overlay span base span `from..to` reads along one axis.
367    fn span(&self, from: u32, to: u32, offset: i64, size: u32) -> Option<(u32, u32)> {
368        if !self.tiled {
369            let start = (i64::from(from) - offset).max(0);
370            let end = (i64::from(to) - offset).min(i64::from(size));
371            return (start < end).then_some((start as u32, end as u32));
372        }
373        if to - from >= size {
374            return Some((0, size));
375        }
376        // A span shorter than the overlay wraps at most once.
377        let start = (i64::from(from) + offset).rem_euclid(i64::from(size)) as u32;
378        let end = start + (to - from);
379        Some(if end <= size { (start, end) } else { (0, size) })
380    }
381
382    /// The part of the overlay output `region` reads, in overlay
383    /// coordinates, or `None` if the overlay misses it.
384    fn overlay_region(&self, region: Region) -> Option<Region> {
385        let (x0, x1) = self.span(region.x, region.x + region.width, self.at.0, self.overlay.0)?;
386        let (y0, y1) = self.span(
387            region.y,
388            region.y + region.height,
389            self.at.1,
390            self.overlay.1,
391        )?;
392        Some(Region::new(x0, y0, x1 - x0, y1 - y0))
393    }
394}
395
396/// Draw an overlay over a base.
397///
398/// Input order is `[base, overlay]`, matching the reading order of "composite
399/// this over that". The two may differ in format: the result has the base's
400/// depth, gains colour when the overlay has it and the base is grey, and
401/// gains alpha when the blend can make an opaque base translucent.
402#[derive(Debug)]
403pub struct Composite {
404    placement: Placement,
405    blend: Blend,
406    bound: OnceLock<Bound>,
407}
408
409impl Clone for Composite {
410    fn clone(&self) -> Self {
411        // Not the binding: a clone may be chained onto other images.
412        Self::new(self.placement, self.blend)
413    }
414}
415
416impl Composite {
417    /// Draw the overlay where `placement` says, combined by `blend`.
418    #[must_use]
419    pub const fn new(placement: Placement, blend: Blend) -> Self {
420        Self {
421            placement,
422            blend,
423            bound: OnceLock::new(),
424        }
425    }
426
427    /// Draw the overlay with its top-left corner at `(x, y)` in base
428    /// coordinates.
429    ///
430    /// Negative offsets are allowed: the overlay is clipped to the base rather
431    /// than rejected, which is what makes "centre a watermark" expressible
432    /// without the caller doing the clipping arithmetic.
433    #[must_use]
434    pub const fn at(x: i64, y: i64, blend: Blend) -> Self {
435        Self::new(Placement::At(x, y), blend)
436    }
437
438    /// Draw the overlay over the base at the origin with source-over.
439    #[must_use]
440    pub const fn over() -> Self {
441        Self::at(0, 0, Blend::Over)
442    }
443
444    /// Where the overlay goes.
445    #[must_use]
446    pub const fn placement(&self) -> Placement {
447        self.placement
448    }
449
450    /// The blend mode.
451    #[must_use]
452    pub const fn blend(&self) -> Blend {
453        self.blend
454    }
455
456    /// Bind to the inputs' sizes, or check they match those already bound.
457    fn bind(&self, base: &ImageDescriptor, overlay: &ImageDescriptor) -> Result<&Bound> {
458        let candidate = Bound::new(self.placement, base, overlay);
459        let bound = self.bound.get_or_init(|| candidate);
460        if (bound.base, bound.overlay) != (candidate.base, candidate.overlay) {
461            return Err(PixelsError::graph(format!(
462                "`composite` is bound to a {}x{} base and a {}x{} overlay; \
463                 build a new Composite per pair of images",
464                bound.base.0, bound.base.1, bound.overlay.0, bound.overlay.1
465            )));
466        }
467        Ok(bound)
468    }
469
470    /// The output format for a base in `base` and an overlay in `overlay`.
471    fn output_format(&self, base: PixelFormat, overlay: PixelFormat) -> Result<PixelFormat> {
472        let has_alpha = |layout| matches!(layout, ChannelLayout::GrayAlpha | ChannelLayout::Rgba);
473        let colour = |layout| matches!(layout, ChannelLayout::Rgb | ChannelLayout::Rgba);
474        let (b, o) = (base.layout(), overlay.layout());
475        let alpha = has_alpha(b) || !self.blend.keeps_opaque(has_alpha(o));
476        let layout = match (colour(b) || colour(o), alpha) {
477            (false, false) => ChannelLayout::Gray,
478            (false, true) => ChannelLayout::GrayAlpha,
479            (true, false) => ChannelLayout::Rgb,
480            (true, true) => ChannelLayout::Rgba,
481        };
482        PixelFormat::from_parts(layout, base.sample_kind()).ok_or_else(|| {
483            PixelsError::unsupported(format!(
484                "composite has no {layout:?} format at the depth of {base}"
485            ))
486        })
487    }
488}
489
490impl Op for Composite {
491    /// Never rescaled, for two independent reasons: the overlay position is in
492    /// pixels, and the second input is a separate image that would not be
493    /// reduced alongside the first.
494    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
495        None
496    }
497    fn name(&self) -> &'static str {
498        "composite"
499    }
500
501    fn arity(&self) -> usize {
502        2
503    }
504
505    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
506        let (base, overlay) = pair(inputs)?;
507        self.bind(base, overlay)?;
508        // The output is the base's shape: compositing draws *onto* something,
509        // so the backdrop defines the canvas.
510        let mut out = *base;
511        out.pixel = self.output_format(base.pixel, overlay.pixel)?;
512        Ok(out)
513    }
514
515    fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
516        let (base, overlay) = pair(inputs)?;
517        let bound = self.bind(base, overlay)?;
518        // The base is needed wherever the output is. The overlay is needed only
519        // where it actually lands — a watermark in the corner must not make the
520        // scheduler pull the whole overlay for every tile.
521        let wanted = bound
522            .overlay_region(output)
523            // An empty region still has to be a valid one, so a miss is
524            // reported as a 1x1 at the origin rather than a zero-sized region
525            // the tile machinery would reject.
526            .unwrap_or_else(|| Region::new(0, 0, 1, 1));
527        Ok(vec![output, wanted])
528    }
529
530    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
531        let base = inputs
532            .first()
533            .ok_or_else(|| PixelsError::graph("`composite` needs a base tile"))?;
534        let overlay = inputs
535            .get(1)
536            .ok_or_else(|| PixelsError::graph("`composite` needs an overlay tile"))?;
537        let bound = self
538            .bound
539            .get()
540            .ok_or_else(|| PixelsError::graph("`composite` computed before it was chained"))?;
541
542        let region = output.region();
543        let area = overlay.region();
544        let (from, over, to) = (base.pixel(), overlay.pixel(), output.pixel());
545        let (from_bytes, over_bytes, to_bytes) = (
546            from.bytes_per_pixel(),
547            over.bytes_per_pixel(),
548            to.bytes_per_pixel(),
549        );
550        // Source-over between 8-bit images with the same colour channels,
551        // either with or without alpha, runs in integers: that is the common
552        // watermark, an RGBA PNG over an RGB or RGBA photo. Everything else
553        // goes through RGBA floats.
554        let colour = |format: PixelFormat| {
555            matches!(format.layout(), ChannelLayout::Rgb | ChannelLayout::Rgba)
556        };
557        let integer = self.blend == Blend::Over
558            && from == to
559            && from.sample_kind() == SampleKind::U8
560            && over.sample_kind() == SampleKind::U8
561            && colour(from) == colour(over);
562        let colours = if colour(from) { 3 } else { 1 };
563
564        // Where each output column reads the overlay, as a byte offset into an
565        // overlay row, or `None` where it misses. The same for every row.
566        let reached = bound.overlay_region(region).is_some();
567        let columns: Vec<Option<usize>> = (region.x..region.x + region.width)
568            .map(|x| {
569                bound
570                    .source(x, bound.at.0, bound.overlay.0)
571                    .filter(|_| reached)
572                    .map(|sx| (sx - area.x) as usize * over_bytes)
573            })
574            .collect();
575
576        for y in region.y..region.y + region.height {
577            let source = base
578                .row(y)
579                .ok_or_else(|| PixelsError::graph(format!("composite base is missing row {y}")))?;
580            let over_row = match bound
581                .source(y, bound.at.1, bound.overlay.1)
582                .filter(|_| reached)
583            {
584                Some(sy) => Some(overlay.row(sy).ok_or_else(|| {
585                    PixelsError::graph(format!("composite overlay {area} is missing row {sy}"))
586                })?),
587                None => None,
588            };
589            let target = output.row_mut(y).ok_or_else(|| {
590                PixelsError::graph(format!("composite output is missing row {y}"))
591            })?;
592
593            // The base shows through wherever the overlay is not, so in the
594            // same format it is copied a row at a time and only the pixels
595            // under the overlay are visited below.
596            let same = from == to;
597            if same {
598                let len = target.len().min(source.len());
599                if let (Some(to), Some(from)) = (target.get_mut(..len), source.get(..len)) {
600                    to.copy_from_slice(from);
601                }
602                if over_row.is_none() {
603                    continue;
604                }
605            }
606
607            let pixels = source
608                .chunks_exact(from_bytes)
609                .zip(target.chunks_exact_mut(to_bytes))
610                .zip(&columns);
611            for ((backdrop, slot), column) in pixels {
612                let drawn = over_row
613                    .zip(*column)
614                    .and_then(|(row, at)| row.get(at..at + over_bytes));
615                match drawn {
616                    None if same => {}
617                    None => from_rgba(to_rgba(backdrop, from), slot, to),
618                    Some(drawn) if integer => over_8_bit(drawn, backdrop, slot, colours),
619                    // Replacement in the same format is a copy, which keeps
620                    // even the colour under zero alpha.
621                    Some(drawn) if self.blend == Blend::Source && over == to => {
622                        slot.copy_from_slice(drawn);
623                    }
624                    Some(drawn) => {
625                        let blended = self
626                            .blend
627                            .apply(to_rgba(drawn, over), to_rgba(backdrop, from));
628                        from_rgba(blended, slot, to);
629                    }
630                }
631            }
632        }
633        Ok(())
634    }
635}
636
637/// Source-over for one 8-bit pixel with `colours` colour channels, in
638/// integers. The overlay and the backdrop (whose format `target` is in)
639/// each have an alpha after their colours or not.
640fn over_8_bit(over: &[u8], backdrop: &[u8], target: &mut [u8], colours: usize) {
641    let sa = u32::from(over.get(colours).copied().unwrap_or(255));
642    if sa == 255 {
643        // An opaque pixel replaces whatever is under it.
644        for (slot, &value) in target.iter_mut().zip(over).take(colours) {
645            *slot = value;
646        }
647        if let Some(slot) = target.get_mut(colours) {
648            *slot = 255;
649        }
650        return;
651    }
652    let alpha = colours;
653    let ba = u32::from(backdrop.get(alpha).copied().unwrap_or(255));
654    // Porter-Duff over, on unassociated alpha:
655    //   out_a = sa + ba*(1-sa)
656    //   out_c = (sc*sa + bc*ba*(1-sa)) / out_a
657    // Computing it premultiplied and dividing back out is what avoids the
658    // dark halo the naive form produces over a translucent backdrop.
659    let inverse = 255 - sa;
660    let out_a = sa * 255 + ba * inverse;
661    if out_a == 0 {
662        // Fully transparent result: colour is undefined, so leave it zeroed
663        // rather than dividing by zero.
664        target.fill(0);
665        return;
666    }
667    for channel in 0..alpha {
668        let sc = u32::from(over.get(channel).copied().unwrap_or(0));
669        let bc = u32::from(backdrop.get(channel).copied().unwrap_or(0));
670        let numerator = sc * sa * 255 + bc * ba * inverse;
671        if let Some(slot) = target.get_mut(channel) {
672            *slot = ((numerator + out_a / 2) / out_a).min(255) as u8;
673        }
674    }
675    if let Some(slot) = target.get_mut(alpha) {
676        *slot = ((out_a + 127) / 255).min(255) as u8;
677    }
678}
679
680/// Fetch the two input descriptors a composite was given.
681fn pair(inputs: &[ImageDescriptor]) -> Result<(&ImageDescriptor, &ImageDescriptor)> {
682    match (inputs.first(), inputs.get(1)) {
683        (Some(base), Some(overlay)) => Ok((base, overlay)),
684        _ => Err(PixelsError::graph(format!(
685            "`composite` takes two inputs, got {}",
686            inputs.len()
687        ))),
688    }
689}
690
691#[cfg(test)]
692#[allow(
693    clippy::unwrap_used,
694    clippy::expect_used,
695    clippy::indexing_slicing,
696    clippy::panic,
697    reason = "tests operate on known-good values and assert shapes directly"
698)]
699mod tests {
700    use super::*;
701    use otf_pixels_core::TileBuf;
702
703    fn solid(
704        width: u32,
705        height: u32,
706        format: PixelFormat,
707        pixel: &[u8],
708    ) -> (ImageDescriptor, Vec<u8>) {
709        let descriptor = ImageDescriptor::new(width, height, format).unwrap();
710        let mut bytes = Vec::with_capacity(descriptor.byte_len().unwrap());
711        for _ in 0..(width * height) {
712            bytes.extend_from_slice(pixel);
713        }
714        (descriptor, bytes)
715    }
716
717    /// Composite two whole images.
718    fn apply(
719        op: &Composite,
720        base: (&ImageDescriptor, &[u8]),
721        overlay: (&ImageDescriptor, &[u8]),
722    ) -> Result<(ImageDescriptor, Vec<u8>)> {
723        let inputs = [*base.0, *overlay.0];
724        let out_desc = op.output_descriptor(&inputs)?;
725        let base_buf = TileBuf::from_vec(base.0.region(), base.0.pixel, base.1.to_vec())?;
726        let over_buf = TileBuf::from_vec(overlay.0.region(), overlay.0.pixel, overlay.1.to_vec())?;
727        let mut target = TileBuf::for_image(&out_desc)?;
728        op.compute(
729            &[base_buf.as_tile()?, over_buf.as_tile()?],
730            &mut target.as_tile_mut()?,
731        )?;
732        Ok((out_desc, target.into_bytes()))
733    }
734
735    #[test]
736    fn an_opaque_overlay_replaces_the_backdrop() {
737        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 255]);
738        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 255]);
739        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
740        for pixel in out.chunks_exact(4) {
741            assert_eq!(pixel, [200, 100, 50, 255], "opaque overlay did not replace");
742        }
743    }
744
745    #[test]
746    fn a_fully_transparent_overlay_leaves_the_backdrop_alone() {
747        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 255]);
748        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 0]);
749        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
750        for pixel in out.chunks_exact(4) {
751            assert_eq!(
752                pixel,
753                [10, 20, 30, 255],
754                "transparent overlay changed the base"
755            );
756        }
757    }
758
759    #[test]
760    fn half_alpha_over_an_opaque_backdrop_is_the_midpoint() {
761        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
762        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[255, 255, 255, 128]);
763        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
764        for pixel in out.chunks_exact(4) {
765            assert_eq!(pixel[3], 255, "an opaque backdrop must stay opaque");
766            assert_eq!(pixel[0], 128, "expected the midpoint, got {pixel:?}");
767        }
768    }
769
770    #[test]
771    fn compositing_onto_a_translucent_backdrop_does_not_darken() {
772        // The dark-halo bug: the naive `f*a + b*(1-a)` is only correct over an
773        // opaque backdrop. Over a translucent one it under-weights the
774        // backdrop's colour and the result creeps toward black.
775        let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[255, 255, 255, 128]);
776        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 255, 255, 128]);
777        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
778        for pixel in out.chunks_exact(4) {
779            // White over white is white at any alpha. The naive formula gives
780            // 191 here instead.
781            assert_eq!(
782                &pixel[..3],
783                [255, 255, 255],
784                "white over white darkened to {pixel:?}"
785            );
786            assert!(pixel[3] > 128, "alpha should accumulate, got {}", pixel[3]);
787        }
788    }
789
790    #[test]
791    fn two_transparent_pixels_do_not_divide_by_zero() {
792        let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[9, 9, 9, 0]);
793        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[7, 7, 7, 0]);
794        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
795        for pixel in out.chunks_exact(4) {
796            assert_eq!(pixel[3], 0, "result should stay transparent");
797        }
798    }
799
800    #[test]
801    fn the_overlay_is_placed_at_its_offset() {
802        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
803        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 0, 0, 255]);
804        let op = Composite::at(1, 1, Blend::Over);
805        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
806
807        let pixel_at = |x: usize, y: usize| -> &[u8] { &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4] };
808        assert_eq!(pixel_at(0, 0), [0, 0, 0, 255], "outside the overlay");
809        assert_eq!(pixel_at(1, 1), [255, 0, 0, 255], "inside the overlay");
810        assert_eq!(pixel_at(2, 2), [255, 0, 0, 255], "inside the overlay");
811        assert_eq!(pixel_at(3, 3), [0, 0, 0, 255], "outside the overlay");
812    }
813
814    #[test]
815    fn a_negative_offset_clips_rather_than_failing() {
816        // "Centre a watermark larger than the base" must work without the
817        // caller doing the clipping arithmetic.
818        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
819        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[255, 0, 0, 255]);
820        let op = Composite::at(-2, -2, Blend::Over);
821        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
822
823        let pixel_at = |x: usize, y: usize| -> &[u8] { &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4] };
824        assert_eq!(
825            pixel_at(0, 0),
826            [255, 0, 0, 255],
827            "clipped overlay should cover here"
828        );
829        assert_eq!(pixel_at(3, 3), [0, 0, 0, 255], "beyond the clipped overlay");
830    }
831
832    #[test]
833    fn an_overlay_entirely_outside_the_base_changes_nothing() {
834        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[1, 2, 3, 255]);
835        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 0, 0, 255]);
836        let op = Composite::at(100, 100, Blend::Over);
837        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
838        assert_eq!(out, base, "a missed overlay changed the base");
839    }
840
841    #[test]
842    fn demand_asks_only_for_the_overlay_that_lands() {
843        // A watermark in one corner must not make every tile pull the whole
844        // overlay — that is the difference between a demand-driven engine and
845        // a loop.
846        let base = ImageDescriptor::new(1000, 1000, PixelFormat::Rgba8).unwrap();
847        let overlay = ImageDescriptor::new(50, 50, PixelFormat::Rgba8).unwrap();
848        let op = Composite::at(900, 900, Blend::Over);
849
850        let far = op
851            .input_regions(Region::new(0, 0, 100, 100), &[base, overlay])
852            .unwrap();
853        assert_eq!(
854            far[0],
855            Region::new(0, 0, 100, 100),
856            "base demand is the output"
857        );
858        assert!(
859            far[1].width <= 1 && far[1].height <= 1,
860            "a tile the overlay misses should not demand it: {}",
861            far[1]
862        );
863
864        let near = op
865            .input_regions(Region::new(900, 900, 50, 50), &[base, overlay])
866            .unwrap();
867        assert_eq!(near[1], Region::new(0, 0, 50, 50), "overlapping tile");
868    }
869
870    #[test]
871    fn the_source_blend_ignores_alpha() {
872        let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[9, 9, 9, 255]);
873        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[1, 2, 3, 0]);
874        let op = Composite::at(0, 0, Blend::Source);
875        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
876        for pixel in out.chunks_exact(4) {
877            assert_eq!(pixel, [1, 2, 3, 0], "Source should copy verbatim");
878        }
879    }
880
881    #[test]
882    fn an_opaque_format_composites_as_replacement() {
883        let (base_desc, base) = solid(2, 2, PixelFormat::Rgb8, &[0, 0, 0]);
884        let (over_desc, over) = solid(2, 2, PixelFormat::Rgb8, &[5, 6, 7]);
885        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
886        for pixel in out.chunks_exact(3) {
887            assert_eq!(pixel, [5, 6, 7]);
888        }
889    }
890
891    #[test]
892    fn one_input_is_a_graph_error() {
893        let base = ImageDescriptor::new(4, 4, PixelFormat::Rgba8).unwrap();
894        assert!(Composite::over().output_descriptor(&[base]).is_err());
895        assert_eq!(Composite::over().arity(), 2);
896    }
897
898    #[test]
899    fn the_output_is_independent_of_how_the_image_is_tiled() {
900        let (base_desc, base) = solid(16, 12, PixelFormat::Rgba8, &[40, 80, 120, 200]);
901        let (over_desc, over) = solid(7, 5, PixelFormat::Rgba8, &[255, 0, 0, 128]);
902        let op = Composite::at(3, 2, Blend::Over);
903        let (out_desc, whole) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
904
905        let base_buf = TileBuf::from_vec(base_desc.region(), base_desc.pixel, base).unwrap();
906        let over_buf = TileBuf::from_vec(over_desc.region(), over_desc.pixel, over).unwrap();
907
908        for (tw, th) in [(4_u32, 4_u32), (1, 12), (16, 1), (5, 3)] {
909            let mut target = TileBuf::for_image(&out_desc).unwrap();
910            let mut y = 0;
911            while y < out_desc.height {
912                let h = th.min(out_desc.height - y);
913                let mut x = 0;
914                while x < out_desc.width {
915                    let w = tw.min(out_desc.width - x);
916                    let region = Region::new(x, y, w, h);
917                    let demand = op.input_regions(region, &[base_desc, over_desc]).unwrap();
918
919                    let mut base_cut = TileBuf::zeroed(demand[0], base_desc.pixel).unwrap();
920                    otf_pixels_core::copy_region(
921                        &base_buf.as_tile().unwrap(),
922                        &mut base_cut.as_tile_mut().unwrap(),
923                        demand[0],
924                    )
925                    .unwrap();
926                    let mut over_cut = TileBuf::zeroed(demand[1], over_desc.pixel).unwrap();
927                    otf_pixels_core::copy_region(
928                        &over_buf.as_tile().unwrap(),
929                        &mut over_cut.as_tile_mut().unwrap(),
930                        demand[1],
931                    )
932                    .unwrap();
933
934                    let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
935                    op.compute(
936                        &[base_cut.as_tile().unwrap(), over_cut.as_tile().unwrap()],
937                        &mut sub.as_tile_mut().unwrap(),
938                    )
939                    .unwrap();
940                    otf_pixels_core::copy_region(
941                        &sub.as_tile().unwrap(),
942                        &mut target.as_tile_mut().unwrap(),
943                        region,
944                    )
945                    .unwrap();
946                    x += w;
947                }
948                y += h;
949            }
950            assert_eq!(
951                target.bytes(),
952                whole.as_slice(),
953                "tiling at {tw}x{th} changed the pixels"
954            );
955        }
956    }
957
958    /// An image whose every pixel differs.
959    fn varied(width: u32, height: u32, format: PixelFormat) -> (ImageDescriptor, Vec<u8>) {
960        let descriptor = ImageDescriptor::new(width, height, format).unwrap();
961        let bytes = (0..descriptor.byte_len().unwrap())
962            .map(|i| (i * 37 % 251) as u8)
963            .collect();
964        (descriptor, bytes)
965    }
966
967    /// Run `op` tile by tile at `tw` x `th`, each tile given only the input
968    /// regions it demands.
969    fn tiled(
970        op: &Composite,
971        base: (&ImageDescriptor, &[u8]),
972        overlay: (&ImageDescriptor, &[u8]),
973        (tw, th): (u32, u32),
974    ) -> Vec<u8> {
975        let inputs = [*base.0, *overlay.0];
976        let out = op.output_descriptor(&inputs).unwrap();
977        let base_buf = TileBuf::from_vec(base.0.region(), base.0.pixel, base.1.to_vec()).unwrap();
978        let over_buf =
979            TileBuf::from_vec(overlay.0.region(), overlay.0.pixel, overlay.1.to_vec()).unwrap();
980        let cut = |buf: &TileBuf, region: Region| {
981            let mut part = TileBuf::zeroed(region, buf.pixel()).unwrap();
982            otf_pixels_core::copy_region(
983                &buf.as_tile().unwrap(),
984                &mut part.as_tile_mut().unwrap(),
985                region,
986            )
987            .unwrap();
988            part
989        };
990        let mut target = TileBuf::for_image(&out).unwrap();
991        for y in (0..out.height).step_by(th as usize) {
992            for x in (0..out.width).step_by(tw as usize) {
993                let region = Region::new(x, y, tw.min(out.width - x), th.min(out.height - y));
994                let demand = op.input_regions(region, &inputs).unwrap();
995                let (b, o) = (cut(&base_buf, demand[0]), cut(&over_buf, demand[1]));
996                let mut sub = TileBuf::zeroed(region, out.pixel).unwrap();
997                op.compute(
998                    &[b.as_tile().unwrap(), o.as_tile().unwrap()],
999                    &mut sub.as_tile_mut().unwrap(),
1000                )
1001                .unwrap();
1002                otf_pixels_core::copy_region(
1003                    &sub.as_tile().unwrap(),
1004                    &mut target.as_tile_mut().unwrap(),
1005                    region,
1006                )
1007                .unwrap();
1008            }
1009        }
1010        target.into_bytes()
1011    }
1012
1013    fn close(a: [f32; 4], b: [f32; 4]) -> bool {
1014        a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-5)
1015    }
1016
1017    #[test]
1018    fn the_pdf_modes_mix_as_the_spec_defines() {
1019        let a = [0.5, 0.25, 0.8, 1.0];
1020        let b = [0.4, 0.5, 0.1, 1.0];
1021        let cases = [
1022            (Blend::Multiply, [0.2, 0.125, 0.08, 1.0]),
1023            (Blend::Screen, [0.7, 0.625, 0.82, 1.0]),
1024            (Blend::Darken, [0.4, 0.25, 0.1, 1.0]),
1025            (Blend::Lighten, [0.5, 0.5, 0.8, 1.0]),
1026            (Blend::Difference, [0.1, 0.25, 0.7, 1.0]),
1027            (Blend::Exclusion, [0.5, 0.5, 0.74, 1.0]),
1028            // The base decides: dark bases multiply, light ones screen.
1029            (Blend::Overlay, [0.4, 0.25, 0.16, 1.0]),
1030            // The overlay decides.
1031            (Blend::HardLight, [0.4, 0.25, 0.64, 1.0]),
1032            (Blend::ColourDodge, [0.8, 0.5 / 0.75, 0.5, 1.0]),
1033            (Blend::ColourBurn, [0.0, 0.0, 1.0 - 0.9 / 0.8, 1.0]),
1034        ];
1035        for (blend, expected) in cases {
1036            let got = blend.apply(a, b);
1037            let expected = expected.map(|v: f32| v.clamp(0.0, 1.0));
1038            assert!(
1039                close(got, expected),
1040                "{blend:?}: {got:?}, expected {expected:?}"
1041            );
1042        }
1043        // Soft light at its two branches.
1044        let soft = Blend::SoftLight.apply([0.25, 0.75, 0.5, 1.0], [0.5, 0.16, 0.3, 1.0]);
1045        let d = ((16.0 * 0.16 - 12.0) * 0.16 + 4.0) * 0.16;
1046        assert!(close(
1047            soft,
1048            [0.5 - 0.5 * 0.5 * 0.5, 0.16 + 0.5 * (d - 0.16), 0.3, 1.0]
1049        ));
1050    }
1051
1052    #[test]
1053    fn a_transparent_overlay_leaves_the_base_under_every_mixing_mode() {
1054        let b = [0.3, 0.6, 0.9, 0.7];
1055        for blend in [
1056            Blend::Over,
1057            Blend::Atop,
1058            Blend::Add,
1059            Blend::Saturate,
1060            Blend::Multiply,
1061            Blend::Screen,
1062            Blend::Overlay,
1063            Blend::Darken,
1064            Blend::Lighten,
1065            Blend::ColourDodge,
1066            Blend::ColourBurn,
1067            Blend::HardLight,
1068            Blend::SoftLight,
1069            Blend::Difference,
1070            Blend::Exclusion,
1071            Blend::DestOver,
1072            Blend::Dest,
1073        ] {
1074            let got = blend.apply([1.0, 0.0, 0.5, 0.0], b);
1075            assert!(close(got, b), "{blend:?} changed the base: {got:?}");
1076        }
1077    }
1078
1079    #[test]
1080    fn the_porter_duff_modes_follow_cairo() {
1081        let a = [1.0, 0.0, 0.0, 0.5];
1082        let b = [0.0, 0.0, 1.0, 1.0];
1083        assert_eq!(Blend::Clear.apply(a, b), [0.0; 4]);
1084        assert!(close(Blend::Source.apply(a, b), a));
1085        assert!(close(Blend::Dest.apply(a, b), b));
1086        assert!(
1087            close(Blend::In.apply(a, b), a),
1088            "in keeps the overlay's colour"
1089        );
1090        assert_eq!(
1091            Blend::Out.apply(a, b),
1092            [0.0; 4],
1093            "an opaque base leaves no outside"
1094        );
1095        assert!(close(Blend::Atop.apply(a, b), [0.5, 0.0, 0.5, 1.0]));
1096        assert!(close(Blend::DestIn.apply(a, b), [0.0, 0.0, 1.0, 0.5]));
1097        assert!(close(Blend::DestOut.apply(a, b), [0.0, 0.0, 1.0, 0.5]));
1098        assert!(close(Blend::DestAtop.apply(a, b), [0.0, 0.0, 1.0, 0.5]));
1099        assert!(close(Blend::Xor.apply(a, b), [0.0, 0.0, 1.0, 0.5]));
1100        assert!(close(Blend::DestOver.apply(a, b), b));
1101        // Sums, with alpha capped at one.
1102        assert!(close(Blend::Add.apply(a, b), [0.5, 0.0, 1.0, 1.0]));
1103        assert!(close(
1104            Blend::Saturate.apply([1.0, 0.0, 0.0, 0.5], [0.0, 0.0, 1.0, 0.75]),
1105            [0.25, 0.0, 0.75, 1.0]
1106        ));
1107    }
1108
1109    #[test]
1110    fn the_float_and_integer_source_over_agree_to_a_level() {
1111        for (sa, ba) in [(0, 0), (1, 255), (128, 128), (200, 3), (255, 90)] {
1112            let (over, back) = ([200_u8, 17, 99, sa], [3_u8, 250, 128, ba]);
1113            let mut integer = [0; 4];
1114            over_8_bit(&over, &back, &mut integer, 3);
1115            let mut float = [0; 4];
1116            let blended = Blend::Over.apply(
1117                to_rgba(&over, PixelFormat::Rgba8),
1118                to_rgba(&back, PixelFormat::Rgba8),
1119            );
1120            from_rgba(blended, &mut float, PixelFormat::Rgba8);
1121            for (i, f) in integer.iter().zip(&float) {
1122                assert!(i.abs_diff(*f) <= 1, "{integer:?} against {float:?}");
1123            }
1124            // And onto an opaque backdrop with no alpha channel.
1125            let mut integer = [0; 3];
1126            over_8_bit(&over, &back[..3], &mut integer, 3);
1127            let mut float = [0; 3];
1128            let blended = Blend::Over.apply(
1129                to_rgba(&over, PixelFormat::Rgba8),
1130                to_rgba(&back[..3], PixelFormat::Rgb8),
1131            );
1132            from_rgba(blended, &mut float, PixelFormat::Rgb8);
1133            for (i, f) in integer.iter().zip(&float) {
1134                assert!(
1135                    i.abs_diff(*f) <= 1,
1136                    "{integer:?} against {float:?} with no alpha"
1137                );
1138            }
1139        }
1140    }
1141
1142    #[test]
1143    fn a_translucent_overlay_draws_onto_an_opaque_base() {
1144        // The watermark case: an RGBA PNG over an RGB JPEG.
1145        let (base_desc, base) = solid(2, 2, PixelFormat::Rgb8, &[0, 0, 0]);
1146        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 255, 255, 128]);
1147        let (out, bytes) =
1148            apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
1149        assert_eq!(out.pixel, PixelFormat::Rgb8, "source-over keeps it opaque");
1150        assert!(bytes.iter().all(|&v| v == 128), "{bytes:?}");
1151    }
1152
1153    #[test]
1154    fn the_result_takes_colour_and_alpha_where_it_needs_them() {
1155        let format = |base, overlay, blend| {
1156            let base = ImageDescriptor::new(4, 4, base).unwrap();
1157            let overlay = ImageDescriptor::new(2, 2, overlay).unwrap();
1158            Composite::at(0, 0, blend)
1159                .output_descriptor(&[base, overlay])
1160                .unwrap()
1161                .pixel
1162        };
1163        use PixelFormat::{Gray8, GrayA8, Rgb8, Rgb16, Rgba8, Rgba16, RgbaF32};
1164        assert_eq!(format(Gray8, Rgb8, Blend::Over), Rgb8);
1165        assert_eq!(format(Gray8, GrayA8, Blend::Multiply), Gray8);
1166        assert_eq!(format(Rgb8, Rgb8, Blend::Clear), Rgba8);
1167        assert_eq!(format(Rgb8, Rgb8, Blend::Source), Rgb8);
1168        assert_eq!(format(Rgb8, Rgba8, Blend::Source), Rgba8);
1169        assert_eq!(format(Gray8, Gray8, Blend::DestOut), GrayA8);
1170        assert_eq!(format(Rgb16, Rgba8, Blend::Over), Rgb16, "the base's depth");
1171        assert_eq!(format(Rgba16, RgbaF32, Blend::Over), Rgba16);
1172    }
1173
1174    #[test]
1175    fn clearing_affects_only_the_overlay_area() {
1176        let (base_desc, base) = solid(3, 1, PixelFormat::Rgb8, &[9, 9, 9]);
1177        let (over_desc, over) = solid(1, 1, PixelFormat::Rgb8, &[1, 1, 1]);
1178        let op = Composite::at(1, 0, Blend::Clear);
1179        let (_, bytes) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
1180        assert_eq!(bytes, [9, 9, 9, 255, 0, 0, 0, 0, 9, 9, 9, 255]);
1181    }
1182
1183    #[test]
1184    fn wide_depths_composite() {
1185        let (base_desc, base) = solid(2, 1, PixelFormat::Rgba16, &[0, 0, 0, 0, 0, 0, 255, 255]);
1186        let (over_desc, over) = solid(1, 1, PixelFormat::Rgba8, &[255, 255, 255, 255]);
1187        let (_, bytes) = apply(
1188            &Composite::at(1, 0, Blend::Over),
1189            (&base_desc, &base),
1190            (&over_desc, &over),
1191        )
1192        .unwrap();
1193        assert_eq!(&bytes[..8], [0, 0, 0, 0, 0, 0, 255, 255], "untouched");
1194        assert_eq!(&bytes[8..], [255; 8], "white at 16 bits");
1195    }
1196
1197    #[test]
1198    fn gravity_places_the_overlay_as_sharp_does() {
1199        let base = ImageDescriptor::new(10, 6, PixelFormat::Gray8).unwrap();
1200        let overlay = ImageDescriptor::new(3, 2, PixelFormat::Gray8).unwrap();
1201        let at = |position| Bound::new(Placement::Gravity(position), &base, &overlay).at;
1202        assert_eq!(at(Position::Centre), (3, 2));
1203        assert_eq!(at(Position::North), (3, 0));
1204        assert_eq!(at(Position::NorthEast), (7, 0));
1205        assert_eq!(at(Position::East), (7, 2));
1206        assert_eq!(at(Position::SouthEast), (7, 4));
1207        assert_eq!(at(Position::South), (3, 4));
1208        assert_eq!(at(Position::SouthWest), (0, 4));
1209        assert_eq!(at(Position::West), (0, 2));
1210        assert_eq!(at(Position::NorthWest), (0, 0));
1211        // A larger overlay overhangs evenly, rounding toward zero.
1212        let large = ImageDescriptor::new(13, 9, PixelFormat::Gray8).unwrap();
1213        let bound = Bound::new(Placement::Gravity(Position::Centre), &base, &large);
1214        assert_eq!(bound.at, (-1, -1));
1215    }
1216
1217    #[test]
1218    fn tiles_align_to_the_gravity() {
1219        let tile = |placement| {
1220            let (base_desc, base) = solid(7, 1, PixelFormat::Gray8, &[0]);
1221            let over_desc = ImageDescriptor::new(3, 1, PixelFormat::Gray8).unwrap();
1222            let op = Composite::new(placement, Blend::Over);
1223            apply(&op, (&base_desc, &base), (&over_desc, &[1, 2, 3]))
1224                .unwrap()
1225                .1
1226        };
1227        assert_eq!(
1228            tile(Placement::Tiled(Position::West)),
1229            [1, 2, 3, 1, 2, 3, 1]
1230        );
1231        assert_eq!(
1232            tile(Placement::Tiled(Position::East)),
1233            [3, 1, 2, 3, 1, 2, 3]
1234        );
1235        // Centred: three copies cover seven, and the middle one is centred.
1236        assert_eq!(
1237            tile(Placement::Tiled(Position::Centre)),
1238            [2, 3, 1, 2, 3, 1, 2]
1239        );
1240        assert_eq!(tile(Placement::TiledFrom(1, 0)), [2, 3, 1, 2, 3, 1, 2]);
1241        assert_eq!(tile(Placement::TiledFrom(-1, 0)), [3, 1, 2, 3, 1, 2, 3]);
1242        // An even count is made odd when centred, so a copy sits mid-image.
1243        let base = ImageDescriptor::new(10, 1, PixelFormat::Gray8).unwrap();
1244        let overlay = ImageDescriptor::new(3, 1, PixelFormat::Gray8).unwrap();
1245        let bound = Bound::new(Placement::Tiled(Position::Centre), &base, &overlay);
1246        assert_eq!(bound.at.0, 0, "five copies of 3 cropped by 3 on the left");
1247    }
1248
1249    #[test]
1250    fn a_tiled_tile_demands_only_the_overlay_it_shows() {
1251        let base = ImageDescriptor::new(100, 100, PixelFormat::Rgba8).unwrap();
1252        let overlay = ImageDescriptor::new(10, 10, PixelFormat::Rgba8).unwrap();
1253        let op = Composite::new(Placement::TiledFrom(0, 0), Blend::Over);
1254        op.output_descriptor(&[base, overlay]).unwrap();
1255        let demand = |region| op.input_regions(region, &[base, overlay]).unwrap()[1];
1256        assert_eq!(demand(Region::new(22, 31, 4, 3)), Region::new(2, 1, 4, 3));
1257        assert_eq!(
1258            demand(Region::new(28, 0, 4, 1)),
1259            Region::new(0, 0, 10, 1),
1260            "wraps"
1261        );
1262        assert_eq!(demand(Region::new(0, 0, 50, 50)), Region::new(0, 0, 10, 10));
1263    }
1264
1265    #[test]
1266    fn every_placement_is_independent_of_tiling() {
1267        let (base_desc, base) = varied(23, 17, PixelFormat::Rgb8);
1268        let (over_desc, over) = varied(6, 5, PixelFormat::Rgba8);
1269        for (placement, blend) in [
1270            (Placement::Gravity(Position::SouthEast), Blend::Multiply),
1271            (Placement::At(-2, 13), Blend::Over),
1272            (Placement::Tiled(Position::Centre), Blend::Screen),
1273            (Placement::TiledFrom(4, -3), Blend::DestOut),
1274            (Placement::Tiled(Position::North), Blend::Xor),
1275        ] {
1276            let op = Composite::new(placement, blend);
1277            let whole = tiled(&op, (&base_desc, &base), (&over_desc, &over), (23, 17));
1278            for shape in [(4, 4), (1, 17), (23, 1), (5, 3)] {
1279                assert_eq!(
1280                    tiled(&op, (&base_desc, &base), (&over_desc, &over), shape),
1281                    whole,
1282                    "{placement:?} {blend:?} at {shape:?}"
1283                );
1284            }
1285        }
1286    }
1287
1288    #[test]
1289    fn one_instance_on_two_pairs_of_shapes_is_a_graph_error() {
1290        let op = Composite::over();
1291        let a = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
1292        let b = ImageDescriptor::new(5, 4, PixelFormat::Rgb8).unwrap();
1293        op.output_descriptor(&[a, a]).unwrap();
1294        assert!(op.output_descriptor(&[a, b]).is_err());
1295        assert!(op.clone().output_descriptor(&[a, b]).is_ok());
1296    }
1297}