1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
47#[non_exhaustive]
48pub enum Blend {
49 Clear,
51 Source,
53 #[default]
55 Over,
56 In,
58 Out,
60 Atop,
62 Dest,
64 DestOver,
66 DestIn,
68 DestOut,
70 DestAtop,
72 Xor,
74 Add,
76 Saturate,
78 Multiply,
80 Screen,
82 Overlay,
84 Darken,
86 Lighten,
88 ColourDodge,
90 ColourBurn,
92 HardLight,
94 SoftLight,
96 Difference,
98 Exclusion,
100}
101
102impl Blend {
103 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 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 fn apply(self, a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
172 let (aa, ab) = (a[3], b[3]);
173 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 _ => 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227#[non_exhaustive]
228pub enum Placement {
229 At(i64, i64),
232 Gravity(Position),
236 Tiled(Position),
239 TiledFrom(i64, i64),
243}
244
245impl Default for Placement {
246 fn default() -> Self {
248 Self::Gravity(Position::Centre)
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254struct Bound {
255 base: (u32, u32),
256 overlay: (u32, u32),
257 at: (i64, i64),
260 tiled: bool,
261}
262
263const 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
279const 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
294fn 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 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 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 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 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#[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 Self::new(self.placement, self.blend)
413 }
414}
415
416impl Composite {
417 #[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 #[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 #[must_use]
440 pub const fn over() -> Self {
441 Self::at(0, 0, Blend::Over)
442 }
443
444 #[must_use]
446 pub const fn placement(&self) -> Placement {
447 self.placement
448 }
449
450 #[must_use]
452 pub const fn blend(&self) -> Blend {
453 self.blend
454 }
455
456 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 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 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 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 let wanted = bound
522 .overlay_region(output)
523 .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 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 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 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 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
637fn 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 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 let inverse = 255 - sa;
660 let out_a = sa * 255 + ba * inverse;
661 if out_a == 0 {
662 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
680fn 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 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 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 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 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 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 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 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 (Blend::Overlay, [0.4, 0.25, 0.16, 1.0]),
1030 (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 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 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 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 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 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 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 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}