1use otf_pixels_core::{
18 ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, SampleKind, Tile, TileMut,
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
23#[non_exhaustive]
24pub enum Blend {
25 #[default]
27 Over,
28 Source,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct Composite {
38 x: i64,
39 y: i64,
40 blend: Blend,
41}
42
43impl Composite {
44 #[must_use]
51 pub const fn at(x: i64, y: i64, blend: Blend) -> Self {
52 Self { x, y, blend }
53 }
54
55 #[must_use]
57 pub const fn over() -> Self {
58 Self::at(0, 0, Blend::Over)
59 }
60
61 #[must_use]
63 pub const fn offset(&self) -> (i64, i64) {
64 (self.x, self.y)
65 }
66
67 #[must_use]
69 pub const fn blend(&self) -> Blend {
70 self.blend
71 }
72
73 fn overlay_region(&self, output: Region, overlay: &ImageDescriptor) -> Option<Region> {
76 let left = (i64::from(output.x) - self.x).max(0);
79 let top = (i64::from(output.y) - self.y).max(0);
80 let right = (i64::from(output.x + output.width) - self.x).min(i64::from(overlay.width));
81 let bottom = (i64::from(output.y + output.height) - self.y).min(i64::from(overlay.height));
82
83 if right <= left || bottom <= top {
84 return None;
85 }
86 Some(Region::new(
87 left as u32,
88 top as u32,
89 (right - left) as u32,
90 (bottom - top) as u32,
91 ))
92 }
93}
94
95impl Op for Composite {
96 fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
100 None
101 }
102 fn name(&self) -> &'static str {
103 "composite"
104 }
105
106 fn arity(&self) -> usize {
107 2
108 }
109
110 fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
111 let (base, overlay) = pair(inputs)?;
112 if base.pixel != overlay.pixel {
113 return Err(PixelsError::unsupported(format!(
114 "composite needs matching formats: base is {}, overlay is {}",
115 base.pixel, overlay.pixel
116 )));
117 }
118 if base.pixel.sample_kind() != SampleKind::U8 {
119 return Err(PixelsError::unsupported(format!(
120 "composite is implemented for 8-bit formats; got {}",
121 base.pixel
122 )));
123 }
124 Ok(*base)
127 }
128
129 fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
130 let (_, overlay) = pair(inputs)?;
131 let wanted = self
135 .overlay_region(output, overlay)
136 .unwrap_or_else(|| Region::new(0, 0, 1, 1));
140 Ok(vec![output, wanted])
141 }
142
143 fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
144 let base = inputs
145 .first()
146 .ok_or_else(|| PixelsError::graph("`composite` needs a base tile"))?;
147 let overlay = inputs
148 .get(1)
149 .ok_or_else(|| PixelsError::graph("`composite` needs an overlay tile"))?;
150
151 let format = output.pixel();
152 let channels = format.channels();
153 let region = output.region();
154
155 for y in region.y..region.y + region.height {
157 let (Some(source), Some(target)) = (base.row(y), output.row_mut(y)) else {
158 continue;
159 };
160 let len = target.len().min(source.len());
161 if let (Some(to), Some(from)) = (target.get_mut(..len), source.get(..len)) {
162 to.copy_from_slice(from);
163 }
164 }
165
166 let overlay_area = overlay.region();
167 let has_alpha = matches!(format, PixelFormat::GrayA8 | PixelFormat::Rgba8);
168
169 for y in region.y..region.y + region.height {
170 let source_y = i64::from(y) - self.y;
172 if source_y < 0 {
173 continue;
174 }
175 let source_y = source_y as u32;
176 if source_y < overlay_area.y || source_y >= overlay_area.y + overlay_area.height {
177 continue;
178 }
179 let Some(over_row) = overlay.row(source_y) else {
180 continue;
181 };
182 let Some(target) = output.row_mut(y) else {
183 continue;
184 };
185
186 for x in region.x..region.x + region.width {
187 let source_x = i64::from(x) - self.x;
188 if source_x < 0 {
189 continue;
190 }
191 let source_x = source_x as u32;
192 if source_x < overlay_area.x || source_x >= overlay_area.x + overlay_area.width {
193 continue;
194 }
195
196 let from = (source_x - overlay_area.x) as usize * channels;
197 let to = (x - region.x) as usize * channels;
198 let Some(over) = over_row.get(from..from + channels) else {
199 continue;
200 };
201
202 match self.blend {
203 Blend::Source => {
204 if let Some(slot) = target.get_mut(to..to + channels) {
205 slot.copy_from_slice(over);
206 }
207 }
208 Blend::Over => {
209 if !has_alpha {
210 if let Some(slot) = target.get_mut(to..to + channels) {
213 slot.copy_from_slice(over);
214 }
215 continue;
216 }
217 let alpha = channels - 1;
218 let sa = u32::from(over.get(alpha).copied().unwrap_or(255));
219 let ba = u32::from(target.get(to + alpha).copied().unwrap_or(255));
220 let inverse = 255 - sa;
227 let out_a = sa * 255 + ba * inverse;
228 if out_a == 0 {
229 for channel in 0..channels {
232 if let Some(slot) = target.get_mut(to + channel) {
233 *slot = 0;
234 }
235 }
236 continue;
237 }
238 for channel in 0..alpha {
239 let sc = u32::from(over.get(channel).copied().unwrap_or(0));
240 let bc = u32::from(target.get(to + channel).copied().unwrap_or(0));
241 let numerator = sc * sa * 255 + bc * ba * inverse;
242 let value = (numerator + out_a / 2) / out_a;
243 if let Some(slot) = target.get_mut(to + channel) {
244 *slot = value.min(255) as u8;
245 }
246 }
247 if let Some(slot) = target.get_mut(to + alpha) {
248 *slot = ((out_a + 127) / 255).min(255) as u8;
249 }
250 }
251 }
252 }
253 }
254 Ok(())
255 }
256}
257
258fn pair(inputs: &[ImageDescriptor]) -> Result<(&ImageDescriptor, &ImageDescriptor)> {
260 match (inputs.first(), inputs.get(1)) {
261 (Some(base), Some(overlay)) => Ok((base, overlay)),
262 _ => Err(PixelsError::graph(format!(
263 "`composite` takes two inputs, got {}",
264 inputs.len()
265 ))),
266 }
267}
268
269#[cfg(test)]
270#[allow(
271 clippy::unwrap_used,
272 clippy::expect_used,
273 clippy::indexing_slicing,
274 clippy::panic,
275 reason = "tests operate on known-good values and assert shapes directly"
276)]
277mod tests {
278 use super::*;
279 use otf_pixels_core::TileBuf;
280
281 fn solid(
282 width: u32,
283 height: u32,
284 format: PixelFormat,
285 pixel: &[u8],
286 ) -> (ImageDescriptor, Vec<u8>) {
287 let descriptor = ImageDescriptor::new(width, height, format).unwrap();
288 let mut bytes = Vec::with_capacity(descriptor.byte_len().unwrap());
289 for _ in 0..(width * height) {
290 bytes.extend_from_slice(pixel);
291 }
292 (descriptor, bytes)
293 }
294
295 fn apply(
297 op: &Composite,
298 base: (&ImageDescriptor, &[u8]),
299 overlay: (&ImageDescriptor, &[u8]),
300 ) -> Result<(ImageDescriptor, Vec<u8>)> {
301 let inputs = [*base.0, *overlay.0];
302 let out_desc = op.output_descriptor(&inputs)?;
303 let base_buf = TileBuf::from_vec(base.0.region(), base.0.pixel, base.1.to_vec())?;
304 let over_buf = TileBuf::from_vec(overlay.0.region(), overlay.0.pixel, overlay.1.to_vec())?;
305 let mut target = TileBuf::for_image(&out_desc)?;
306 op.compute(
307 &[base_buf.as_tile()?, over_buf.as_tile()?],
308 &mut target.as_tile_mut()?,
309 )?;
310 Ok((out_desc, target.into_bytes()))
311 }
312
313 #[test]
314 fn an_opaque_overlay_replaces_the_backdrop() {
315 let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 255]);
316 let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 255]);
317 let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
318 for pixel in out.chunks_exact(4) {
319 assert_eq!(pixel, [200, 100, 50, 255], "opaque overlay did not replace");
320 }
321 }
322
323 #[test]
324 fn a_fully_transparent_overlay_leaves_the_backdrop_alone() {
325 let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 255]);
326 let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 0]);
327 let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
328 for pixel in out.chunks_exact(4) {
329 assert_eq!(
330 pixel,
331 [10, 20, 30, 255],
332 "transparent overlay changed the base"
333 );
334 }
335 }
336
337 #[test]
338 fn half_alpha_over_an_opaque_backdrop_is_the_midpoint() {
339 let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
340 let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[255, 255, 255, 128]);
341 let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
342 for pixel in out.chunks_exact(4) {
343 assert_eq!(pixel[3], 255, "an opaque backdrop must stay opaque");
344 assert_eq!(pixel[0], 128, "expected the midpoint, got {pixel:?}");
345 }
346 }
347
348 #[test]
349 fn compositing_onto_a_translucent_backdrop_does_not_darken() {
350 let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[255, 255, 255, 128]);
354 let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 255, 255, 128]);
355 let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
356 for pixel in out.chunks_exact(4) {
357 assert_eq!(
360 &pixel[..3],
361 [255, 255, 255],
362 "white over white darkened to {pixel:?}"
363 );
364 assert!(pixel[3] > 128, "alpha should accumulate, got {}", pixel[3]);
365 }
366 }
367
368 #[test]
369 fn two_transparent_pixels_do_not_divide_by_zero() {
370 let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[9, 9, 9, 0]);
371 let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[7, 7, 7, 0]);
372 let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
373 for pixel in out.chunks_exact(4) {
374 assert_eq!(pixel[3], 0, "result should stay transparent");
375 }
376 }
377
378 #[test]
379 fn the_overlay_is_placed_at_its_offset() {
380 let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
381 let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 0, 0, 255]);
382 let op = Composite::at(1, 1, Blend::Over);
383 let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
384
385 let pixel_at = |x: usize, y: usize| -> &[u8] { &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4] };
386 assert_eq!(pixel_at(0, 0), [0, 0, 0, 255], "outside the overlay");
387 assert_eq!(pixel_at(1, 1), [255, 0, 0, 255], "inside the overlay");
388 assert_eq!(pixel_at(2, 2), [255, 0, 0, 255], "inside the overlay");
389 assert_eq!(pixel_at(3, 3), [0, 0, 0, 255], "outside the overlay");
390 }
391
392 #[test]
393 fn a_negative_offset_clips_rather_than_failing() {
394 let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
397 let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[255, 0, 0, 255]);
398 let op = Composite::at(-2, -2, Blend::Over);
399 let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
400
401 let pixel_at = |x: usize, y: usize| -> &[u8] { &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4] };
402 assert_eq!(
403 pixel_at(0, 0),
404 [255, 0, 0, 255],
405 "clipped overlay should cover here"
406 );
407 assert_eq!(pixel_at(3, 3), [0, 0, 0, 255], "beyond the clipped overlay");
408 }
409
410 #[test]
411 fn an_overlay_entirely_outside_the_base_changes_nothing() {
412 let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[1, 2, 3, 255]);
413 let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 0, 0, 255]);
414 let op = Composite::at(100, 100, Blend::Over);
415 let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
416 assert_eq!(out, base, "a missed overlay changed the base");
417 }
418
419 #[test]
420 fn demand_asks_only_for_the_overlay_that_lands() {
421 let base = ImageDescriptor::new(1000, 1000, PixelFormat::Rgba8).unwrap();
425 let overlay = ImageDescriptor::new(50, 50, PixelFormat::Rgba8).unwrap();
426 let op = Composite::at(900, 900, Blend::Over);
427
428 let far = op
429 .input_regions(Region::new(0, 0, 100, 100), &[base, overlay])
430 .unwrap();
431 assert_eq!(
432 far[0],
433 Region::new(0, 0, 100, 100),
434 "base demand is the output"
435 );
436 assert!(
437 far[1].width <= 1 && far[1].height <= 1,
438 "a tile the overlay misses should not demand it: {}",
439 far[1]
440 );
441
442 let near = op
443 .input_regions(Region::new(900, 900, 50, 50), &[base, overlay])
444 .unwrap();
445 assert_eq!(near[1], Region::new(0, 0, 50, 50), "overlapping tile");
446 }
447
448 #[test]
449 fn the_source_blend_ignores_alpha() {
450 let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[9, 9, 9, 255]);
451 let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[1, 2, 3, 0]);
452 let op = Composite::at(0, 0, Blend::Source);
453 let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
454 for pixel in out.chunks_exact(4) {
455 assert_eq!(pixel, [1, 2, 3, 0], "Source should copy verbatim");
456 }
457 }
458
459 #[test]
460 fn an_opaque_format_composites_as_replacement() {
461 let (base_desc, base) = solid(2, 2, PixelFormat::Rgb8, &[0, 0, 0]);
462 let (over_desc, over) = solid(2, 2, PixelFormat::Rgb8, &[5, 6, 7]);
463 let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
464 for pixel in out.chunks_exact(3) {
465 assert_eq!(pixel, [5, 6, 7]);
466 }
467 }
468
469 #[test]
470 fn mismatched_formats_are_an_error() {
471 let base = ImageDescriptor::new(4, 4, PixelFormat::Rgba8).unwrap();
472 let overlay = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
473 assert!(
474 Composite::over()
475 .output_descriptor(&[base, overlay])
476 .is_err()
477 );
478 }
479
480 #[test]
481 fn wide_formats_are_unsupported_rather_than_wrong() {
482 let base = ImageDescriptor::new(4, 4, PixelFormat::Rgba16).unwrap();
483 assert!(Composite::over().output_descriptor(&[base, base]).is_err());
484 }
485
486 #[test]
487 fn one_input_is_a_graph_error() {
488 let base = ImageDescriptor::new(4, 4, PixelFormat::Rgba8).unwrap();
489 assert!(Composite::over().output_descriptor(&[base]).is_err());
490 assert_eq!(Composite::over().arity(), 2);
491 }
492
493 #[test]
494 fn the_output_is_independent_of_how_the_image_is_tiled() {
495 let (base_desc, base) = solid(16, 12, PixelFormat::Rgba8, &[40, 80, 120, 200]);
496 let (over_desc, over) = solid(7, 5, PixelFormat::Rgba8, &[255, 0, 0, 128]);
497 let op = Composite::at(3, 2, Blend::Over);
498 let (out_desc, whole) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
499
500 let base_buf = TileBuf::from_vec(base_desc.region(), base_desc.pixel, base).unwrap();
501 let over_buf = TileBuf::from_vec(over_desc.region(), over_desc.pixel, over).unwrap();
502
503 for (tw, th) in [(4_u32, 4_u32), (1, 12), (16, 1), (5, 3)] {
504 let mut target = TileBuf::for_image(&out_desc).unwrap();
505 let mut y = 0;
506 while y < out_desc.height {
507 let h = th.min(out_desc.height - y);
508 let mut x = 0;
509 while x < out_desc.width {
510 let w = tw.min(out_desc.width - x);
511 let region = Region::new(x, y, w, h);
512 let demand = op.input_regions(region, &[base_desc, over_desc]).unwrap();
513
514 let mut base_cut = TileBuf::zeroed(demand[0], base_desc.pixel).unwrap();
515 otf_pixels_core::copy_region(
516 &base_buf.as_tile().unwrap(),
517 &mut base_cut.as_tile_mut().unwrap(),
518 demand[0],
519 )
520 .unwrap();
521 let mut over_cut = TileBuf::zeroed(demand[1], over_desc.pixel).unwrap();
522 otf_pixels_core::copy_region(
523 &over_buf.as_tile().unwrap(),
524 &mut over_cut.as_tile_mut().unwrap(),
525 demand[1],
526 )
527 .unwrap();
528
529 let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
530 op.compute(
531 &[base_cut.as_tile().unwrap(), over_cut.as_tile().unwrap()],
532 &mut sub.as_tile_mut().unwrap(),
533 )
534 .unwrap();
535 otf_pixels_core::copy_region(
536 &sub.as_tile().unwrap(),
537 &mut target.as_tile_mut().unwrap(),
538 region,
539 )
540 .unwrap();
541 x += w;
542 }
543 y += h;
544 }
545 assert_eq!(
546 target.bytes(),
547 whole.as_slice(),
548 "tiling at {tw}x{th} changed the pixels"
549 );
550 }
551 }
552}