1use alloc::boxed::Box;
7
8use crate::convert::{ConvertPlan, ConvertScratch, convert_row_buffered};
9use crate::{ChannelLayout, ConvertError, PixelDescriptor};
10use whereat::{At, ResultAtExt};
11
12pub struct RowConverter {
33 plan: ConvertPlan,
34 scratch: ConvertScratch,
35 external: Option<ExternalTransform>,
38}
39
40enum ExternalTransform {
42 Shared(alloc::sync::Arc<dyn crate::cms::RowTransform>),
44 Owned(Box<dyn crate::cms::RowTransformMut>),
46}
47
48impl RowConverter {
49 #[track_caller]
56 pub fn new(from: PixelDescriptor, to: PixelDescriptor) -> Result<Self, At<ConvertError>> {
57 Self::new_explicit_with_cms(from, to, &crate::policy::ConvertOptions::permissive(), None)
58 }
59
60 #[track_caller]
68 pub fn new_explicit(
69 from: PixelDescriptor,
70 to: PixelDescriptor,
71 options: &crate::policy::ConvertOptions,
72 ) -> Result<Self, At<ConvertError>> {
73 Self::new_explicit_with_cms(from, to, options, None)
74 }
75
76 #[track_caller]
89 pub fn new_explicit_with_cms(
90 from: PixelDescriptor,
91 to: PixelDescriptor,
92 options: &crate::policy::ConvertOptions,
93 cms: Option<&dyn crate::cms::PluggableCms>,
94 ) -> Result<Self, At<ConvertError>> {
95 use crate::policy::{AlphaPolicy, DepthPolicy};
96
97 let primaries_differ = from.primaries != to.primaries;
110 if primaries_differ {
111 let src_src = from.color_profile_source();
112 let dst_src = to.color_profile_source();
113 let src_fmt = from.pixel_format();
114 let dst_fmt = to.pixel_format();
115
116 let try_cms = |plugin: &dyn crate::cms::PluggableCms| -> Result<
125 Option<ExternalTransform>,
126 whereat::At<crate::cms::CmsPluginError>,
127 > {
128 if let Some(result) = plugin.build_shared_source_transform(
129 src_src.clone(),
130 dst_src.clone(),
131 src_fmt,
132 dst_fmt,
133 options,
134 ) {
135 return whereat::at_crate!(result).map(|t| Some(ExternalTransform::Shared(t)));
136 }
137 if let Some(result) = plugin.build_source_transform(
138 src_src.clone(),
139 dst_src.clone(),
140 src_fmt,
141 dst_fmt,
142 options,
143 ) {
144 return whereat::at_crate!(result).map(|t| Some(ExternalTransform::Owned(t)));
145 }
146 Ok(None)
147 };
148
149 let plugin_err = |e: whereat::At<crate::cms::CmsPluginError>| {
156 let msg = alloc::format!("{e}");
157 e.map_error(|_| ConvertError::CmsError(msg))
158 };
159
160 let mut external: Option<ExternalTransform> = None;
161 if let Some(plugin) = cms {
162 match try_cms(plugin) {
163 Ok(Some(t)) => external = Some(t),
164 Ok(None) => {}
165 Err(e) => return Err(plugin_err(e)),
166 }
167 }
168 if external.is_none() {
169 match try_cms(&crate::cms_lite::ZenCmsLite) {
170 Ok(Some(t)) => external = Some(t),
171 Ok(None) => {}
172 Err(e) => return Err(plugin_err(e)),
173 }
174 }
175
176 if let Some(external) = external {
177 let drops_alpha = from.alpha().is_some() && to.alpha().is_none();
179 if drops_alpha && options.alpha_policy == AlphaPolicy::Forbid {
180 return Err(whereat::at!(ConvertError::AlphaRemovalForbidden));
181 }
182 let reduces_depth = crate::negotiate::channel_bits(from.channel_type())
184 > crate::negotiate::channel_bits(to.channel_type());
185 if reduces_depth && options.depth_policy == DepthPolicy::Forbid {
186 return Err(whereat::at!(ConvertError::DepthReductionForbidden));
187 }
188 let src_is_rgb = matches!(
189 from.layout(),
190 ChannelLayout::Rgb | ChannelLayout::Rgba | ChannelLayout::Bgra
191 );
192 let dst_is_gray =
193 matches!(to.layout(), ChannelLayout::Gray | ChannelLayout::GrayAlpha);
194 if src_is_rgb && dst_is_gray && options.luma.is_none() {
195 return Err(whereat::at!(ConvertError::RgbToGray));
196 }
197
198 return Ok(Self {
199 plan: ConvertPlan::identity(from, to),
200 scratch: ConvertScratch::new(),
201 external: Some(external),
202 });
203 }
204 }
205
206 let plan = ConvertPlan::new_explicit(from, to, options).at()?;
208 Ok(Self {
209 plan,
210 scratch: ConvertScratch::new(),
211 external: None,
212 })
213 }
214
215 pub fn from_plan(plan: ConvertPlan) -> Self {
217 Self {
218 plan,
219 scratch: ConvertScratch::new(),
220 external: None,
221 }
222 }
223
224 #[inline]
232 pub fn convert_row(&mut self, src: &[u8], dst: &mut [u8], width: u32) {
233 match &mut self.external {
234 Some(ExternalTransform::Shared(arc)) => arc.transform_row(src, dst, width),
235 Some(ExternalTransform::Owned(b)) => b.transform_row(src, dst, width),
236 None => convert_row_buffered(&self.plan, src, dst, width, &mut self.scratch),
237 }
238 }
239
240 #[track_caller]
244 pub fn convert_rows(
245 &mut self,
246 src: &[u8],
247 src_stride: usize,
248 dst: &mut [u8],
249 dst_stride: usize,
250 width: u32,
251 rows: u32,
252 ) -> Result<(), At<ConvertError>> {
253 for y in 0..rows {
254 let src_start = y as usize * src_stride;
255 let src_end = src_start + (width as usize * self.plan.from().bytes_per_pixel());
256 let dst_start = y as usize * dst_stride;
257 let dst_end = dst_start + (width as usize * self.plan.to().bytes_per_pixel());
258
259 if src_end > src.len() || dst_end > dst.len() {
260 return Err(whereat::at!(ConvertError::BufferSize {
261 expected: dst_end,
262 actual: dst.len(),
263 }));
264 }
265
266 self.convert_row(
267 &src[src_start..src_end],
268 &mut dst[dst_start..dst_end],
269 width,
270 );
271 }
272 Ok(())
273 }
274
275 #[inline]
277 #[must_use]
278 pub fn is_identity(&self) -> bool {
279 self.external.is_none() && self.plan.is_identity()
283 }
284
285 #[inline]
287 pub fn from_descriptor(&self) -> PixelDescriptor {
288 self.plan.from()
289 }
290
291 #[inline]
293 pub fn to_descriptor(&self) -> PixelDescriptor {
294 self.plan.to()
295 }
296
297 pub fn compose(&self, other: &Self) -> Option<Self> {
305 self.plan.compose(&other.plan).map(Self::from_plan)
306 }
307
308 pub fn plan(&self) -> &ConvertPlan {
310 &self.plan
311 }
312}
313
314impl Clone for RowConverter {
315 fn clone(&self) -> Self {
316 let external = match &self.external {
320 Some(ExternalTransform::Shared(arc)) => {
321 Some(ExternalTransform::Shared(alloc::sync::Arc::clone(arc)))
322 }
323 Some(ExternalTransform::Owned(_)) | None => None,
324 };
325 Self {
326 plan: self.plan.clone(),
327 scratch: ConvertScratch::new(),
328 external,
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use alloc::vec;
336
337 use super::*;
338 use crate::convert::ConvertPlan;
339 use crate::policy::{AlphaPolicy, ConvertOptions, DepthPolicy};
340 use crate::{AlphaMode, ChannelLayout, ChannelType, ConvertError, TransferFunction};
341
342 fn convert_pixel(
344 from: PixelDescriptor,
345 to: PixelDescriptor,
346 src: &[u8],
347 ) -> alloc::vec::Vec<u8> {
348 let mut conv = RowConverter::new(from, to).unwrap();
349 let dst_bpp = to.bytes_per_pixel();
350 let mut dst = vec![0u8; dst_bpp];
351 conv.convert_row(src, &mut dst, 1);
352 dst
353 }
354
355 #[test]
360 fn identity_conversion() {
361 let desc = PixelDescriptor::RGB8_SRGB;
362 let mut conv = RowConverter::new(desc, desc).unwrap();
363 assert!(conv.is_identity());
364 assert_eq!(conv.from_descriptor(), desc);
365 assert_eq!(conv.to_descriptor(), desc);
366
367 let src = [10u8, 20, 30, 40, 50, 60];
368 let mut dst = [0u8; 6];
369 conv.convert_row(&src, &mut dst, 2);
370 assert_eq!(dst, src);
371 }
372
373 #[test]
378 fn rgb8_to_rgba8() {
379 let dst = convert_pixel(
380 PixelDescriptor::RGB8_SRGB,
381 PixelDescriptor::RGBA8_SRGB,
382 &[100, 150, 200],
383 );
384 assert_eq!(dst, [100, 150, 200, 255]);
385 }
386
387 #[test]
388 fn rgba8_to_rgb8() {
389 let dst = convert_pixel(
390 PixelDescriptor::RGBA8_SRGB,
391 PixelDescriptor::RGB8_SRGB,
392 &[100, 150, 200, 128],
393 );
394 assert_eq!(dst, [100, 150, 200]);
395 }
396
397 #[test]
398 fn bgra8_to_rgba8() {
399 let dst = convert_pixel(
400 PixelDescriptor::BGRA8_SRGB,
401 PixelDescriptor::RGBA8_SRGB,
402 &[200, 150, 100, 255], );
404 assert_eq!(dst, [100, 150, 200, 255]); }
406
407 #[test]
408 fn rgba8_to_bgra8() {
409 let dst = convert_pixel(
410 PixelDescriptor::RGBA8_SRGB,
411 PixelDescriptor::BGRA8_SRGB,
412 &[100, 150, 200, 255],
413 );
414 assert_eq!(dst, [200, 150, 100, 255]);
415 }
416
417 #[test]
418 fn rgb8_to_bgra8() {
419 let dst = convert_pixel(
421 PixelDescriptor::RGB8_SRGB,
422 PixelDescriptor::BGRA8_SRGB,
423 &[100, 150, 200],
424 );
425 assert_eq!(dst, [200, 150, 100, 255]);
426 }
427
428 #[test]
429 fn bgra8_to_rgb8() {
430 let dst = convert_pixel(
432 PixelDescriptor::BGRA8_SRGB,
433 PixelDescriptor::RGB8_SRGB,
434 &[200, 150, 100, 255],
435 );
436 assert_eq!(dst, [100, 150, 200]);
437 }
438
439 #[test]
440 fn gray8_to_rgb8() {
441 let dst = convert_pixel(
442 PixelDescriptor::GRAY8_SRGB,
443 PixelDescriptor::RGB8_SRGB,
444 &[128],
445 );
446 assert_eq!(dst, [128, 128, 128]);
447 }
448
449 #[test]
450 fn gray8_to_rgba8() {
451 let dst = convert_pixel(
452 PixelDescriptor::GRAY8_SRGB,
453 PixelDescriptor::RGBA8_SRGB,
454 &[200],
455 );
456 assert_eq!(dst, [200, 200, 200, 255]);
457 }
458
459 #[test]
460 fn gray8_to_bgra8() {
461 let dst = convert_pixel(
463 PixelDescriptor::GRAY8_SRGB,
464 PixelDescriptor::BGRA8_SRGB,
465 &[128],
466 );
467 assert_eq!(dst, [128, 128, 128, 255]);
469 }
470
471 #[test]
472 fn rgb8_to_gray8() {
473 let dst = convert_pixel(
475 PixelDescriptor::RGB8_SRGB,
476 PixelDescriptor::GRAY8_SRGB,
477 &[255, 0, 0],
478 );
479 assert_eq!(dst, [54]);
481 }
482
483 #[test]
484 fn rgba8_to_gray8() {
485 let dst = convert_pixel(
486 PixelDescriptor::RGBA8_SRGB,
487 PixelDescriptor::GRAY8_SRGB,
488 &[0, 255, 0, 255],
489 );
490 assert_eq!(dst, [182]);
492 }
493
494 #[test]
495 fn bgra8_to_gray8() {
496 let dst = convert_pixel(
498 PixelDescriptor::BGRA8_SRGB,
499 PixelDescriptor::GRAY8_SRGB,
500 &[0, 255, 0, 255], );
502 assert_eq!(dst, [182]);
504 }
505
506 #[test]
511 fn gray8_to_grayalpha8() {
512 let from = PixelDescriptor::new(
513 ChannelType::U8,
514 ChannelLayout::Gray,
515 None,
516 TransferFunction::Srgb,
517 );
518 let to = PixelDescriptor::new(
519 ChannelType::U8,
520 ChannelLayout::GrayAlpha,
521 Some(AlphaMode::Straight),
522 TransferFunction::Srgb,
523 );
524 let dst = convert_pixel(from, to, &[100]);
525 assert_eq!(dst, [100, 255]);
526 }
527
528 #[test]
529 fn grayalpha8_to_gray8() {
530 let from = PixelDescriptor::new(
531 ChannelType::U8,
532 ChannelLayout::GrayAlpha,
533 Some(AlphaMode::Straight),
534 TransferFunction::Srgb,
535 );
536 let to = PixelDescriptor::new(
537 ChannelType::U8,
538 ChannelLayout::Gray,
539 None,
540 TransferFunction::Srgb,
541 );
542 let dst = convert_pixel(from, to, &[100, 200]);
543 assert_eq!(dst, [100]); }
545
546 #[test]
547 fn grayalpha8_to_rgba8() {
548 let from = PixelDescriptor::new(
549 ChannelType::U8,
550 ChannelLayout::GrayAlpha,
551 Some(AlphaMode::Straight),
552 TransferFunction::Srgb,
553 );
554 let dst = convert_pixel(from, PixelDescriptor::RGBA8_SRGB, &[128, 200]);
555 assert_eq!(dst, [128, 128, 128, 200]);
556 }
557
558 #[test]
559 fn grayalpha8_to_rgb8() {
560 let from = PixelDescriptor::new(
561 ChannelType::U8,
562 ChannelLayout::GrayAlpha,
563 Some(AlphaMode::Straight),
564 TransferFunction::Srgb,
565 );
566 let dst = convert_pixel(from, PixelDescriptor::RGB8_SRGB, &[128, 200]);
567 assert_eq!(dst, [128, 128, 128]);
568 }
569
570 #[test]
571 fn grayalpha8_to_bgra8() {
572 let from = PixelDescriptor::new(
574 ChannelType::U8,
575 ChannelLayout::GrayAlpha,
576 Some(AlphaMode::Straight),
577 TransferFunction::Srgb,
578 );
579 let dst = convert_pixel(from, PixelDescriptor::BGRA8_SRGB, &[128, 200]);
580 assert_eq!(dst, [128, 128, 128, 200]);
582 }
583
584 #[test]
589 fn u8_to_u16_roundtrip() {
590 let u8_desc = PixelDescriptor::RGB8_SRGB;
591 let u16_desc = PixelDescriptor::new(
592 ChannelType::U16,
593 ChannelLayout::Rgb,
594 None,
595 TransferFunction::Srgb,
596 );
597 let src = [0u8, 128, 255];
598 let wide = convert_pixel(u8_desc, u16_desc, &src);
599 let wide16: &[u16] = bytemuck::cast_slice(&wide);
600 assert_eq!(wide16[0], 0); assert_eq!(wide16[1], 128 * 257); assert_eq!(wide16[2], 255 * 257); let narrow = convert_pixel(u16_desc, u8_desc, &wide);
606 assert_eq!(narrow, [0, 128, 255]);
607 }
608
609 #[test]
610 fn naive_u8_to_f32() {
611 let u8_desc = PixelDescriptor::new(
613 ChannelType::U8,
614 ChannelLayout::Rgb,
615 None,
616 TransferFunction::Linear,
617 );
618 let f32_desc = PixelDescriptor::new(
619 ChannelType::F32,
620 ChannelLayout::Rgb,
621 None,
622 TransferFunction::Linear,
623 );
624 let dst = convert_pixel(u8_desc, f32_desc, &[0, 128, 255]);
625 let f: &[f32] = bytemuck::cast_slice(&dst);
626 assert!((f[0] - 0.0).abs() < 1e-6);
627 assert!((f[1] - 128.0 / 255.0).abs() < 1e-5);
628 assert!((f[2] - 1.0).abs() < 1e-6);
629 }
630
631 #[test]
632 fn naive_f32_to_u8() {
633 let f32_desc = PixelDescriptor::new(
634 ChannelType::F32,
635 ChannelLayout::Rgb,
636 None,
637 TransferFunction::Linear,
638 );
639 let u8_desc = PixelDescriptor::new(
640 ChannelType::U8,
641 ChannelLayout::Rgb,
642 None,
643 TransferFunction::Linear,
644 );
645 let src_f: [f32; 3] = [0.0, 0.5, 1.0];
646 let src: &[u8] = bytemuck::cast_slice(&src_f);
647 let dst = convert_pixel(f32_desc, u8_desc, src);
648 assert_eq!(dst[0], 0);
649 assert_eq!(dst[1], 128); assert_eq!(dst[2], 255);
651 }
652
653 #[test]
654 fn srgb_u8_to_linear_f32() {
655 let dst = convert_pixel(
656 PixelDescriptor::RGB8_SRGB,
657 PixelDescriptor::new(
658 ChannelType::F32,
659 ChannelLayout::Rgb,
660 None,
661 TransferFunction::Linear,
662 ),
663 &[0, 128, 255],
664 );
665 let f: &[f32] = bytemuck::cast_slice(&dst);
666 assert!((f[0] - 0.0).abs() < 1e-6);
667 assert!((f[1] - 0.2158).abs() < 0.01);
669 assert!((f[2] - 1.0).abs() < 1e-5);
670 }
671
672 #[test]
673 fn linear_f32_to_srgb_u8() {
674 let f32_lin = PixelDescriptor::new(
675 ChannelType::F32,
676 ChannelLayout::Rgb,
677 None,
678 TransferFunction::Linear,
679 );
680 let src_f: [f32; 3] = [0.0, 0.5, 1.0];
681 let src: &[u8] = bytemuck::cast_slice(&src_f);
682 let dst = convert_pixel(f32_lin, PixelDescriptor::RGB8_SRGB, src);
683 assert_eq!(dst[0], 0);
684 assert!((dst[1] as i32 - 188).abs() <= 1);
686 assert_eq!(dst[2], 255);
687 }
688
689 #[test]
690 fn u16_to_f32_and_back() {
691 let u16_desc = PixelDescriptor::new(
692 ChannelType::U16,
693 ChannelLayout::Rgb,
694 None,
695 TransferFunction::Srgb,
696 );
697 let f32_desc = PixelDescriptor::new(
698 ChannelType::F32,
699 ChannelLayout::Rgb,
700 None,
701 TransferFunction::Srgb,
702 );
703 let src16: [u16; 3] = [0, 32768, 65535];
704 let src: &[u8] = bytemuck::cast_slice(&src16);
705 let mid = convert_pixel(u16_desc, f32_desc, src);
706 let f: &[f32] = bytemuck::cast_slice(&mid);
707 assert!((f[0] - 0.0).abs() < 1e-6);
708 assert!((f[1] - 0.5000076).abs() < 1e-4);
709 assert!((f[2] - 1.0).abs() < 1e-6);
710
711 let back = convert_pixel(f32_desc, u16_desc, &mid);
713 let back16: &[u16] = bytemuck::cast_slice(&back);
714 assert_eq!(back16[0], 0);
715 assert!((back16[1] as i32 - 32768).abs() <= 1);
716 assert_eq!(back16[2], 65535);
717 }
718
719 #[test]
724 fn pq_u16_to_linear_f32_roundtrip() {
725 let pq_u16 = PixelDescriptor::new(
726 ChannelType::U16,
727 ChannelLayout::Rgb,
728 None,
729 TransferFunction::Pq,
730 );
731 let lin_f32 = PixelDescriptor::new(
732 ChannelType::F32,
733 ChannelLayout::Rgb,
734 None,
735 TransferFunction::Linear,
736 );
737 let src16: [u16; 3] = [0, 32768, 65535];
738 let src: &[u8] = bytemuck::cast_slice(&src16);
739 let mid = convert_pixel(pq_u16, lin_f32, src);
740 let f: &[f32] = bytemuck::cast_slice(&mid);
741 assert_eq!(f[0], 0.0);
742 assert!(f[1] > 0.0 && f[1] < 1.0);
743
744 let back = convert_pixel(lin_f32, pq_u16, &mid);
746 let back16: &[u16] = bytemuck::cast_slice(&back);
747 assert_eq!(back16[0], 0);
748 assert!((back16[1] as i32 - 32768).abs() <= 2);
749 }
750
751 #[test]
752 fn pq_f32_to_linear_f32_roundtrip() {
753 let pq_f32 = PixelDescriptor::new(
754 ChannelType::F32,
755 ChannelLayout::Rgb,
756 None,
757 TransferFunction::Pq,
758 );
759 let lin_f32 = PixelDescriptor::new(
760 ChannelType::F32,
761 ChannelLayout::Rgb,
762 None,
763 TransferFunction::Linear,
764 );
765 let src_f: [f32; 3] = [0.0, 0.5, 1.0];
766 let src: &[u8] = bytemuck::cast_slice(&src_f);
767 let mid = convert_pixel(pq_f32, lin_f32, src);
768 let back = convert_pixel(lin_f32, pq_f32, &mid);
769 let back_f: &[f32] = bytemuck::cast_slice(&back);
770 for i in 0..3 {
771 assert!(
772 (back_f[i] - src_f[i]).abs() < 1e-4,
773 "PQ F32 roundtrip ch{i}: {:.6} vs {:.6}",
774 back_f[i],
775 src_f[i]
776 );
777 }
778 }
779
780 #[test]
781 fn hlg_u16_to_linear_f32_roundtrip() {
782 let hlg_u16 = PixelDescriptor::new(
783 ChannelType::U16,
784 ChannelLayout::Rgb,
785 None,
786 TransferFunction::Hlg,
787 );
788 let lin_f32 = PixelDescriptor::new(
789 ChannelType::F32,
790 ChannelLayout::Rgb,
791 None,
792 TransferFunction::Linear,
793 );
794 let src16: [u16; 3] = [0, 32768, 65535];
795 let src: &[u8] = bytemuck::cast_slice(&src16);
796 let mid = convert_pixel(hlg_u16, lin_f32, src);
797 let f: &[f32] = bytemuck::cast_slice(&mid);
798 assert_eq!(f[0], 0.0);
799 assert!(f[1] > 0.0);
800
801 let back = convert_pixel(lin_f32, hlg_u16, &mid);
802 let back16: &[u16] = bytemuck::cast_slice(&back);
803 assert_eq!(back16[0], 0);
804 assert!((back16[1] as i32 - 32768).abs() <= 2);
805 }
806
807 #[test]
808 fn hlg_f32_to_linear_f32_roundtrip() {
809 let hlg_f32 = PixelDescriptor::new(
810 ChannelType::F32,
811 ChannelLayout::Rgb,
812 None,
813 TransferFunction::Hlg,
814 );
815 let lin_f32 = PixelDescriptor::new(
816 ChannelType::F32,
817 ChannelLayout::Rgb,
818 None,
819 TransferFunction::Linear,
820 );
821 let src_f: [f32; 3] = [0.0, 0.5, 1.0];
822 let src: &[u8] = bytemuck::cast_slice(&src_f);
823 let mid = convert_pixel(hlg_f32, lin_f32, src);
824 let back = convert_pixel(lin_f32, hlg_f32, &mid);
825 let back_f: &[f32] = bytemuck::cast_slice(&back);
826 for i in 0..3 {
827 assert!(
828 (back_f[i] - src_f[i]).abs() < 1e-4,
829 "HLG F32 roundtrip ch{i}: {:.6} vs {:.6}",
830 back_f[i],
831 src_f[i]
832 );
833 }
834 }
835
836 #[test]
837 fn hlg_pq_conversion_refuses() {
838 let hlg_f32 = PixelDescriptor::new(
844 ChannelType::F32,
845 ChannelLayout::Rgb,
846 None,
847 TransferFunction::Hlg,
848 );
849 let pq_f32 = PixelDescriptor::new(
850 ChannelType::F32,
851 ChannelLayout::Rgb,
852 None,
853 TransferFunction::Pq,
854 );
855 match RowConverter::new(hlg_f32, pq_f32) {
856 Err(e) => assert!(matches!(*e.error(), ConvertError::NoPath { .. })),
857 Ok(_) => panic!("HLG→PQ must refuse, not emit wrong pixels"),
858 }
859 assert!(RowConverter::new(pq_f32, hlg_f32).is_err());
861 assert!(
862 RowConverter::new(
863 PixelDescriptor::RGB16_BT2100_HLG,
864 PixelDescriptor::RGB16_BT2100_PQ
865 )
866 .is_err()
867 );
868 assert!(
870 RowConverter::new(
871 PixelDescriptor::RGB16_BT2100_HLG,
872 PixelDescriptor::RGB8_SRGB
873 )
874 .is_ok()
875 );
876 let lin = PixelDescriptor::new(
877 ChannelType::F32,
878 ChannelLayout::Rgb,
879 None,
880 TransferFunction::Linear,
881 );
882 assert!(RowConverter::new(hlg_f32, lin).is_ok());
883 }
884
885 #[test]
886 fn hdr_u16_to_sdr_u8_pq() {
887 let pq_u16 = PixelDescriptor::new(
889 ChannelType::U16,
890 ChannelLayout::Rgb,
891 None,
892 TransferFunction::Pq,
893 );
894 let src16: [u16; 3] = [32768, 32768, 32768];
895 let src: &[u8] = bytemuck::cast_slice(&src16);
896 let dst = convert_pixel(pq_u16, PixelDescriptor::RGB8_SRGB, src);
897 assert!(dst[0] > 0 && dst[0] < 255);
899 }
900
901 #[test]
902 fn hdr_u16_to_sdr_u8_hlg() {
903 let hlg_u16 = PixelDescriptor::new(
906 ChannelType::U16,
907 ChannelLayout::Rgb,
908 None,
909 TransferFunction::Hlg,
910 );
911 let src16: [u16; 3] = [32768, 32768, 32768];
912 let src: &[u8] = bytemuck::cast_slice(&src16);
913 let dst = convert_pixel(hlg_u16, PixelDescriptor::RGB8_SRGB, src);
914 assert!(dst[0] > 0 && dst[0] < 255);
915 }
916
917 #[test]
922 fn straight_to_premul_u8() {
923 let straight = PixelDescriptor::RGBA8_SRGB;
924 let premul = PixelDescriptor::new(
925 ChannelType::U8,
926 ChannelLayout::Rgba,
927 Some(AlphaMode::Premultiplied),
928 TransferFunction::Srgb,
929 );
930 let dst = convert_pixel(straight, premul, &[200, 100, 50, 128]);
932 assert!((dst[0] as i32 - 100).abs() <= 1);
934 assert!((dst[1] as i32 - 50).abs() <= 1);
935 assert!((dst[2] as i32 - 25).abs() <= 1);
936 assert_eq!(dst[3], 128);
937 }
938
939 #[test]
940 fn premul_to_straight_u8() {
941 let premul = PixelDescriptor::new(
942 ChannelType::U8,
943 ChannelLayout::Rgba,
944 Some(AlphaMode::Premultiplied),
945 TransferFunction::Srgb,
946 );
947 let straight = PixelDescriptor::RGBA8_SRGB;
948 let dst = convert_pixel(premul, straight, &[100, 50, 25, 128]);
950 assert!((dst[0] as i32 - 200).abs() <= 1);
952 assert!((dst[1] as i32 - 100).abs() <= 1);
953 assert!((dst[2] as i32 - 50).abs() <= 1);
954 assert_eq!(dst[3], 128);
955 }
956
957 #[test]
958 fn premul_to_straight_zero_alpha() {
959 let premul = PixelDescriptor::new(
960 ChannelType::U8,
961 ChannelLayout::Rgba,
962 Some(AlphaMode::Premultiplied),
963 TransferFunction::Srgb,
964 );
965 let straight = PixelDescriptor::RGBA8_SRGB;
966 let dst = convert_pixel(premul, straight, &[0, 0, 0, 0]);
967 assert_eq!(dst, [0, 0, 0, 0]);
968 }
969
970 #[test]
971 fn straight_to_premul_f32() {
972 let straight = PixelDescriptor::new(
973 ChannelType::F32,
974 ChannelLayout::Rgba,
975 Some(AlphaMode::Straight),
976 TransferFunction::Linear,
977 );
978 let premul = PixelDescriptor::new(
979 ChannelType::F32,
980 ChannelLayout::Rgba,
981 Some(AlphaMode::Premultiplied),
982 TransferFunction::Linear,
983 );
984 let src_f: [f32; 4] = [1.0, 0.5, 0.25, 0.5];
985 let src: &[u8] = bytemuck::cast_slice(&src_f);
986 let dst = convert_pixel(straight, premul, src);
987 let f: &[f32] = bytemuck::cast_slice(&dst);
988 assert!((f[0] - 0.5).abs() < 1e-6);
989 assert!((f[1] - 0.25).abs() < 1e-6);
990 assert!((f[2] - 0.125).abs() < 1e-6);
991 assert!((f[3] - 0.5).abs() < 1e-6);
992 }
993
994 #[test]
999 fn rgb8_to_oklabf32_does_not_panic() {
1000 let mut conv =
1001 RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::OKLABF32).unwrap();
1002 assert!(!conv.is_identity());
1003
1004 let src = [128u8, 64, 200];
1005 let mut dst = [0u8; 12];
1006 conv.convert_row(&src, &mut dst, 1);
1007
1008 let oklab: [f32; 3] = bytemuck::cast(dst);
1009 assert!(
1010 oklab[0] >= 0.0 && oklab[0] <= 1.0,
1011 "L out of range: {}",
1012 oklab[0]
1013 );
1014 }
1015
1016 #[test]
1017 fn oklabf32_roundtrip() {
1018 let lin = PixelDescriptor::new(
1020 ChannelType::F32,
1021 ChannelLayout::Rgb,
1022 None,
1023 TransferFunction::Linear,
1024 );
1025 let oklab = PixelDescriptor::OKLABF32;
1026
1027 let src_f: [f32; 3] = [0.5, 0.3, 0.8];
1028 let src: &[u8] = bytemuck::cast_slice(&src_f);
1029 let mid = convert_pixel(lin, oklab, src);
1030 let back = convert_pixel(oklab, lin, &mid);
1031 let back_f: &[f32] = bytemuck::cast_slice(&back);
1032 for i in 0..3 {
1033 assert!(
1034 (back_f[i] - src_f[i]).abs() < 1e-4,
1035 "Oklab roundtrip ch{i}: {:.6} vs {:.6}",
1036 back_f[i],
1037 src_f[i]
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn oklabaf32_preserves_alpha() {
1044 let lin_rgba = PixelDescriptor::new(
1045 ChannelType::F32,
1046 ChannelLayout::Rgba,
1047 Some(AlphaMode::Straight),
1048 TransferFunction::Linear,
1049 );
1050 let oklaba = PixelDescriptor::OKLABAF32;
1051 let src_f: [f32; 4] = [0.5, 0.3, 0.8, 0.7];
1052 let src: &[u8] = bytemuck::cast_slice(&src_f);
1053 let mid = convert_pixel(lin_rgba, oklaba, src);
1054 let mid_f: &[f32] = bytemuck::cast_slice(&mid);
1055 assert!(
1056 (mid_f[3] - 0.7).abs() < 1e-6,
1057 "Alpha not preserved in Oklaba"
1058 );
1059
1060 let back = convert_pixel(oklaba, lin_rgba, &mid);
1061 let back_f: &[f32] = bytemuck::cast_slice(&back);
1062 assert!(
1063 (back_f[3] - 0.7).abs() < 1e-6,
1064 "Alpha not preserved on round-trip"
1065 );
1066 for i in 0..3 {
1067 assert!(
1068 (back_f[i] - src_f[i]).abs() < 1e-4,
1069 "Oklaba roundtrip ch{i}: {:.6} vs {:.6}",
1070 back_f[i],
1071 src_f[i]
1072 );
1073 }
1074 }
1075
1076 #[test]
1081 fn convert_rows_basic() {
1082 let mut conv =
1083 RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1084
1085 let src = [10u8, 20, 30, 40, 50, 60];
1086 let src_stride = 3; let mut dst = [0u8; 8]; let dst_stride = 4;
1089
1090 conv.convert_rows(&src, src_stride, &mut dst, dst_stride, 1, 2)
1091 .unwrap();
1092 assert_eq!(&dst[0..4], &[10, 20, 30, 255]);
1093 assert_eq!(&dst[4..8], &[40, 50, 60, 255]);
1094 }
1095
1096 #[test]
1097 fn convert_rows_buffer_too_small() {
1098 let mut conv =
1099 RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1100
1101 let src = [10u8, 20, 30];
1102 let mut dst = [0u8; 4];
1103 let err = conv.convert_rows(&src, 3, &mut dst, 4, 1, 2).unwrap_err();
1104 assert!(matches!(*err.error(), ConvertError::BufferSize { .. }));
1105 }
1106
1107 #[test]
1112 fn new_explicit_alpha_forbid() {
1113 let from = PixelDescriptor::RGBA8_SRGB;
1114 let to = PixelDescriptor::RGB8_SRGB;
1115 let opts = ConvertOptions::forbid_lossy().with_depth_policy(DepthPolicy::Round);
1116 let err = ConvertPlan::new_explicit(from, to, &opts).unwrap_err();
1117 assert_eq!(*err.error(), ConvertError::AlphaRemovalForbidden);
1118 }
1119
1120 #[test]
1126 fn signal_range_crossing_refuses_at_plan_time() {
1127 use zenpixels::SignalRange;
1128 let full = PixelDescriptor::RGB8_SRGB;
1129 let narrow = PixelDescriptor::RGB8_SRGB.with_signal_range(SignalRange::Narrow);
1130
1131 let err = ConvertPlan::new(narrow, full).unwrap_err();
1132 assert!(matches!(*err.error(), ConvertError::NoPath { .. }));
1133 assert!(
1136 err.error().to_string().contains("signal range"),
1137 "NoPath display should name the range crossing: {}",
1138 err.error()
1139 );
1140
1141 let err = ConvertPlan::new(full, narrow).unwrap_err();
1142 assert!(matches!(*err.error(), ConvertError::NoPath { .. }));
1143
1144 let narrow_rgba = PixelDescriptor::RGBA8_SRGB.with_signal_range(SignalRange::Narrow);
1146 ConvertPlan::new(narrow, narrow_rgba).expect("same-range narrow plan must succeed");
1147 }
1148
1149 #[test]
1150 fn new_explicit_depth_forbid() {
1151 let from = PixelDescriptor::new(
1152 ChannelType::U16,
1153 ChannelLayout::Rgb,
1154 None,
1155 TransferFunction::Srgb,
1156 );
1157 let to = PixelDescriptor::RGB8_SRGB;
1158 let opts = ConvertOptions::forbid_lossy().with_alpha_policy(AlphaPolicy::DiscardUnchecked);
1159 let err = ConvertPlan::new_explicit(from, to, &opts).unwrap_err();
1160 assert_eq!(*err.error(), ConvertError::DepthReductionForbidden);
1161 }
1162
1163 #[test]
1164 fn new_explicit_rgb_to_gray_requires_luma() {
1165 let opts = ConvertOptions::forbid_lossy()
1166 .with_alpha_policy(AlphaPolicy::DiscardUnchecked)
1167 .with_depth_policy(DepthPolicy::Round);
1168 let err = ConvertPlan::new_explicit(
1169 PixelDescriptor::RGB8_SRGB,
1170 PixelDescriptor::GRAY8_SRGB,
1171 &opts,
1172 )
1173 .unwrap_err();
1174 assert_eq!(*err.error(), ConvertError::RgbToGray);
1175 }
1176
1177 #[test]
1178 fn new_explicit_allows_when_policies_permit() {
1179 let opts = ConvertOptions::permissive().with_alpha_policy(AlphaPolicy::DiscardUnchecked);
1180 let plan = ConvertPlan::new_explicit(
1181 PixelDescriptor::RGBA8_SRGB,
1182 PixelDescriptor::GRAY8_SRGB,
1183 &opts,
1184 )
1185 .unwrap();
1186 assert!(!plan.is_identity());
1187 }
1188
1189 #[test]
1190 fn clip_out_of_gamut_false_preserves_negatives() {
1191 let p3 = PixelDescriptor::new(
1195 ChannelType::F32,
1196 ChannelLayout::Rgb,
1197 None,
1198 TransferFunction::Srgb,
1199 )
1200 .with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1201 let srgb = PixelDescriptor::new(
1202 ChannelType::F32,
1203 ChannelLayout::Rgb,
1204 None,
1205 TransferFunction::Srgb,
1206 );
1207
1208 let opts = ConvertOptions::permissive().with_clip_out_of_gamut(false);
1209 let mut conv = crate::RowConverter::new_explicit(p3, srgb, &opts).unwrap();
1210
1211 let src: [f32; 3] = [0.0, 1.0, 0.0];
1212 let mut dst = [0.0f32; 3];
1213 conv.convert_row(
1214 bytemuck::cast_slice(&src),
1215 bytemuck::cast_slice_mut(&mut dst),
1216 1,
1217 );
1218 assert!(
1219 dst[0] < 0.0,
1220 "extended range should preserve negative red, got {}",
1221 dst[0]
1222 );
1223 }
1224
1225 #[test]
1226 fn clip_out_of_gamut_true_clamps_negatives() {
1227 let p3 = PixelDescriptor::new(
1229 ChannelType::F32,
1230 ChannelLayout::Rgb,
1231 None,
1232 TransferFunction::Srgb,
1233 )
1234 .with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1235 let srgb = PixelDescriptor::new(
1236 ChannelType::F32,
1237 ChannelLayout::Rgb,
1238 None,
1239 TransferFunction::Srgb,
1240 );
1241
1242 let opts = ConvertOptions::permissive();
1243 assert!(opts.clip_out_of_gamut);
1244 let mut conv = crate::RowConverter::new_explicit(p3, srgb, &opts).unwrap();
1245
1246 let src: [f32; 3] = [0.0, 1.0, 0.0];
1247 let mut dst = [0.0f32; 3];
1248 conv.convert_row(
1249 bytemuck::cast_slice(&src),
1250 bytemuck::cast_slice_mut(&mut dst),
1251 1,
1252 );
1253 assert!(
1254 dst[0] >= 0.0,
1255 "clamped path should not produce negatives, got {}",
1256 dst[0]
1257 );
1258 }
1259
1260 struct PaintRedCms {
1267 accepted: core::sync::atomic::AtomicUsize,
1268 }
1269
1270 struct PaintRedTransform;
1271
1272 impl crate::cms::RowTransformMut for PaintRedTransform {
1273 fn transform_row(&mut self, _src: &[u8], dst: &mut [u8], width: u32) {
1274 for px in dst.chunks_exact_mut(3).take(width as usize) {
1275 px[0] = 255;
1276 px[1] = 0;
1277 px[2] = 0;
1278 }
1279 }
1280 }
1281
1282 impl crate::cms::PluggableCms for PaintRedCms {
1283 fn build_source_transform(
1284 &self,
1285 _src: zenpixels::ColorProfileSource<'_>,
1286 _dst: zenpixels::ColorProfileSource<'_>,
1287 _src_format: zenpixels::PixelFormat,
1288 _dst_format: zenpixels::PixelFormat,
1289 _options: &crate::policy::ConvertOptions,
1290 ) -> Option<
1291 Result<Box<dyn crate::cms::RowTransformMut>, whereat::At<crate::cms::CmsPluginError>>,
1292 > {
1293 self.accepted
1294 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
1295 Some(Ok(Box::new(PaintRedTransform)))
1296 }
1297 }
1298
1299 #[test]
1300 fn pluggable_cms_drives_row_when_profiles_differ() {
1301 let p3 = PixelDescriptor::RGB8_SRGB.with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1304 let srgb = PixelDescriptor::RGB8_SRGB;
1305
1306 let cms = PaintRedCms {
1307 accepted: core::sync::atomic::AtomicUsize::new(0),
1308 };
1309 let opts = ConvertOptions::permissive();
1310 let mut conv =
1311 crate::RowConverter::new_explicit_with_cms(p3, srgb, &opts, Some(&cms)).unwrap();
1312
1313 assert_eq!(cms.accepted.load(core::sync::atomic::Ordering::Relaxed), 1);
1314
1315 let src = [10u8, 20, 30, 40, 50, 60];
1316 let mut dst = [0u8; 6];
1317 conv.convert_row(&src, &mut dst, 2);
1318 assert_eq!(dst, [255, 0, 0, 255, 0, 0]);
1319 }
1320
1321 #[test]
1322 fn pluggable_cms_skipped_when_profiles_match() {
1323 let cms = PaintRedCms {
1326 accepted: core::sync::atomic::AtomicUsize::new(0),
1327 };
1328 let opts = ConvertOptions::permissive();
1329 let conv = crate::RowConverter::new_explicit_with_cms(
1330 PixelDescriptor::RGB8_SRGB,
1331 PixelDescriptor::RGB8_SRGB,
1332 &opts,
1333 Some(&cms),
1334 )
1335 .unwrap();
1336 assert!(conv.is_identity());
1337 assert_eq!(cms.accepted.load(core::sync::atomic::Ordering::Relaxed), 0);
1338 }
1339
1340 #[test]
1341 fn pluggable_cms_shared_path_used_when_offered() {
1342 struct SharedPaintBlueCms;
1345 struct PaintBlueShared;
1346
1347 impl crate::cms::RowTransform for PaintBlueShared {
1348 fn transform_row(&self, _src: &[u8], dst: &mut [u8], width: u32) {
1349 for px in dst.chunks_exact_mut(3).take(width as usize) {
1350 px[0] = 0;
1351 px[1] = 0;
1352 px[2] = 255;
1353 }
1354 }
1355 }
1356
1357 impl crate::cms::PluggableCms for SharedPaintBlueCms {
1358 fn build_source_transform(
1359 &self,
1360 _src: zenpixels::ColorProfileSource<'_>,
1361 _dst: zenpixels::ColorProfileSource<'_>,
1362 _src_format: zenpixels::PixelFormat,
1363 _dst_format: zenpixels::PixelFormat,
1364 _options: &crate::policy::ConvertOptions,
1365 ) -> Option<
1366 Result<
1367 Box<dyn crate::cms::RowTransformMut>,
1368 whereat::At<crate::cms::CmsPluginError>,
1369 >,
1370 > {
1371 panic!("owned path must not be used when shared is offered");
1372 }
1373
1374 fn build_shared_source_transform(
1375 &self,
1376 _src: zenpixels::ColorProfileSource<'_>,
1377 _dst: zenpixels::ColorProfileSource<'_>,
1378 _src_format: zenpixels::PixelFormat,
1379 _dst_format: zenpixels::PixelFormat,
1380 _options: &crate::policy::ConvertOptions,
1381 ) -> Option<
1382 Result<
1383 alloc::sync::Arc<dyn crate::cms::RowTransform>,
1384 whereat::At<crate::cms::CmsPluginError>,
1385 >,
1386 > {
1387 Some(Ok(alloc::sync::Arc::new(PaintBlueShared)))
1388 }
1389 }
1390
1391 let p3 = PixelDescriptor::RGB8_SRGB.with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1392 let srgb = PixelDescriptor::RGB8_SRGB;
1393 let opts = ConvertOptions::permissive();
1394 let conv =
1395 crate::RowConverter::new_explicit_with_cms(p3, srgb, &opts, Some(&SharedPaintBlueCms))
1396 .unwrap();
1397
1398 let mut conv2 = conv.clone();
1400 let mut dst = [0u8; 3];
1401 conv2.convert_row(&[1, 2, 3], &mut dst, 1);
1402 assert_eq!(
1403 dst,
1404 [0, 0, 255],
1405 "cloned converter should inherit shared transform"
1406 );
1407 }
1408
1409 #[test]
1410 fn pluggable_cms_declines_falls_back_to_builtin() {
1411 struct DeclineCms;
1413 impl crate::cms::PluggableCms for DeclineCms {
1414 fn build_source_transform(
1415 &self,
1416 _src: zenpixels::ColorProfileSource<'_>,
1417 _dst: zenpixels::ColorProfileSource<'_>,
1418 _src_format: zenpixels::PixelFormat,
1419 _dst_format: zenpixels::PixelFormat,
1420 _options: &crate::policy::ConvertOptions,
1421 ) -> Option<
1422 Result<
1423 Box<dyn crate::cms::RowTransformMut>,
1424 whereat::At<crate::cms::CmsPluginError>,
1425 >,
1426 > {
1427 None
1428 }
1429 }
1430
1431 let p3 = PixelDescriptor::RGB8_SRGB.with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1432 let srgb = PixelDescriptor::RGB8_SRGB;
1433 let opts = ConvertOptions::permissive();
1434 let mut conv =
1435 crate::RowConverter::new_explicit_with_cms(p3, srgb, &opts, Some(&DeclineCms)).unwrap();
1436
1437 let src = [128u8, 128, 128];
1439 let mut dst = [0u8; 3];
1440 conv.convert_row(&src, &mut dst, 1);
1441 assert_ne!(dst, [255, 0, 0]);
1444 }
1445
1446 #[test]
1451 fn plan_accessors() {
1452 let from = PixelDescriptor::RGB8_SRGB;
1453 let to = PixelDescriptor::RGBA8_SRGB;
1454 let conv = RowConverter::new(from, to).unwrap();
1455 let plan = conv.plan();
1456 assert_eq!(plan.from(), from);
1457 assert_eq!(plan.to(), to);
1458 assert!(!plan.is_identity());
1459 }
1460
1461 #[test]
1462 fn from_plan() {
1463 let plan =
1464 ConvertPlan::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1465 let conv = RowConverter::from_plan(plan);
1466 assert!(!conv.is_identity());
1467 }
1468
1469 #[test]
1474 fn convert_multiple_pixels() {
1475 let mut conv =
1476 RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1477 let src = [10, 20, 30, 40, 50, 60, 70, 80, 90];
1478 let mut dst = [0u8; 12];
1479 conv.convert_row(&src, &mut dst, 3);
1480 assert_eq!(dst, [10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255]);
1481 }
1482
1483 #[test]
1488 fn gray8_to_rgbaf32_linear() {
1489 let from = PixelDescriptor::GRAY8_SRGB;
1491 let to = PixelDescriptor::new(
1492 ChannelType::F32,
1493 ChannelLayout::Rgba,
1494 Some(AlphaMode::Straight),
1495 TransferFunction::Linear,
1496 );
1497 let dst = convert_pixel(from, to, &[128]);
1498 let f: &[f32] = bytemuck::cast_slice(&dst);
1499 assert!((f[0] - 0.2158).abs() < 0.01);
1501 assert!((f[0] - f[1]).abs() < 1e-6);
1502 assert!((f[0] - f[2]).abs() < 1e-6);
1503 assert!((f[3] - 1.0).abs() < 1e-6);
1504 }
1505
1506 #[test]
1507 fn rgba8_to_rgb16() {
1508 let from = PixelDescriptor::RGBA8_SRGB;
1510 let to = PixelDescriptor::new(
1511 ChannelType::U16,
1512 ChannelLayout::Rgb,
1513 None,
1514 TransferFunction::Srgb,
1515 );
1516 let dst = convert_pixel(from, to, &[100, 150, 200, 255]);
1517 let u16s: &[u16] = bytemuck::cast_slice(&dst);
1518 assert_eq!(u16s[0], 100 * 257);
1519 assert_eq!(u16s[1], 150 * 257);
1520 assert_eq!(u16s[2], 200 * 257);
1521 }
1522
1523 #[test]
1528 fn gray_u16_to_rgb_u16() {
1529 let from = PixelDescriptor::new(
1530 ChannelType::U16,
1531 ChannelLayout::Gray,
1532 None,
1533 TransferFunction::Srgb,
1534 );
1535 let to = PixelDescriptor::new(
1536 ChannelType::U16,
1537 ChannelLayout::Rgb,
1538 None,
1539 TransferFunction::Srgb,
1540 );
1541 let src16: [u16; 1] = [40000];
1542 let src: &[u8] = bytemuck::cast_slice(&src16);
1543 let dst = convert_pixel(from, to, src);
1544 let out: &[u16] = bytemuck::cast_slice(&dst);
1545 assert_eq!(out, [40000, 40000, 40000]);
1546 }
1547
1548 #[test]
1549 fn gray_f32_to_rgba_f32() {
1550 let from = PixelDescriptor::new(
1551 ChannelType::F32,
1552 ChannelLayout::Gray,
1553 None,
1554 TransferFunction::Linear,
1555 );
1556 let to = PixelDescriptor::new(
1557 ChannelType::F32,
1558 ChannelLayout::Rgba,
1559 Some(AlphaMode::Straight),
1560 TransferFunction::Linear,
1561 );
1562 let src_f: [f32; 1] = [0.6];
1563 let src: &[u8] = bytemuck::cast_slice(&src_f);
1564 let dst = convert_pixel(from, to, src);
1565 let out: &[f32] = bytemuck::cast_slice(&dst);
1566 assert!((out[0] - 0.6).abs() < 1e-6);
1567 assert!((out[1] - 0.6).abs() < 1e-6);
1568 assert!((out[2] - 0.6).abs() < 1e-6);
1569 assert!((out[3] - 1.0).abs() < 1e-6);
1570 }
1571
1572 #[test]
1573 fn rgb_f32_to_rgba_f32() {
1574 let from = PixelDescriptor::new(
1575 ChannelType::F32,
1576 ChannelLayout::Rgb,
1577 None,
1578 TransferFunction::Linear,
1579 );
1580 let to = PixelDescriptor::new(
1581 ChannelType::F32,
1582 ChannelLayout::Rgba,
1583 Some(AlphaMode::Straight),
1584 TransferFunction::Linear,
1585 );
1586 let src_f: [f32; 3] = [0.2, 0.4, 0.8];
1587 let src: &[u8] = bytemuck::cast_slice(&src_f);
1588 let dst = convert_pixel(from, to, src);
1589 let out: &[f32] = bytemuck::cast_slice(&dst);
1590 assert!((out[0] - 0.2).abs() < 1e-6);
1591 assert!((out[1] - 0.4).abs() < 1e-6);
1592 assert!((out[2] - 0.8).abs() < 1e-6);
1593 assert!((out[3] - 1.0).abs() < 1e-6);
1594 }
1595
1596 #[test]
1597 fn rgba_u16_to_rgb_u16() {
1598 let from = PixelDescriptor::new(
1599 ChannelType::U16,
1600 ChannelLayout::Rgba,
1601 Some(AlphaMode::Straight),
1602 TransferFunction::Srgb,
1603 );
1604 let to = PixelDescriptor::new(
1605 ChannelType::U16,
1606 ChannelLayout::Rgb,
1607 None,
1608 TransferFunction::Srgb,
1609 );
1610 let src16: [u16; 4] = [10000, 20000, 30000, 65535];
1611 let src: &[u8] = bytemuck::cast_slice(&src16);
1612 let dst = convert_pixel(from, to, src);
1613 let out: &[u16] = bytemuck::cast_slice(&dst);
1614 assert_eq!(out, [10000, 20000, 30000]);
1615 }
1616
1617 #[test]
1618 fn gray_alpha_u16_to_rgba_u16() {
1619 let from = PixelDescriptor::new(
1620 ChannelType::U16,
1621 ChannelLayout::GrayAlpha,
1622 Some(AlphaMode::Straight),
1623 TransferFunction::Srgb,
1624 );
1625 let to = PixelDescriptor::new(
1626 ChannelType::U16,
1627 ChannelLayout::Rgba,
1628 Some(AlphaMode::Straight),
1629 TransferFunction::Srgb,
1630 );
1631 let src16: [u16; 2] = [50000, 32768];
1632 let src: &[u8] = bytemuck::cast_slice(&src16);
1633 let dst = convert_pixel(from, to, src);
1634 let out: &[u16] = bytemuck::cast_slice(&dst);
1635 assert_eq!(out, [50000, 50000, 50000, 32768]);
1636 }
1637
1638 #[test]
1639 fn gray_alpha_f32_to_rgb_f32() {
1640 let from = PixelDescriptor::new(
1641 ChannelType::F32,
1642 ChannelLayout::GrayAlpha,
1643 Some(AlphaMode::Straight),
1644 TransferFunction::Linear,
1645 );
1646 let to = PixelDescriptor::new(
1647 ChannelType::F32,
1648 ChannelLayout::Rgb,
1649 None,
1650 TransferFunction::Linear,
1651 );
1652 let src_f: [f32; 2] = [0.75, 0.5];
1653 let src: &[u8] = bytemuck::cast_slice(&src_f);
1654 let dst = convert_pixel(from, to, src);
1655 let out: &[f32] = bytemuck::cast_slice(&dst);
1656 assert!((out[0] - 0.75).abs() < 1e-6);
1657 assert!((out[1] - 0.75).abs() < 1e-6);
1658 assert!((out[2] - 0.75).abs() < 1e-6);
1659 }
1660
1661 #[test]
1662 fn gray_u16_to_gray_alpha_u16() {
1663 let from = PixelDescriptor::new(
1664 ChannelType::U16,
1665 ChannelLayout::Gray,
1666 None,
1667 TransferFunction::Srgb,
1668 );
1669 let to = PixelDescriptor::new(
1670 ChannelType::U16,
1671 ChannelLayout::GrayAlpha,
1672 Some(AlphaMode::Straight),
1673 TransferFunction::Srgb,
1674 );
1675 let src16: [u16; 1] = [12345];
1676 let src: &[u8] = bytemuck::cast_slice(&src16);
1677 let dst = convert_pixel(from, to, src);
1678 let out: &[u16] = bytemuck::cast_slice(&dst);
1679 assert_eq!(out, [12345, 65535]);
1680 }
1681
1682 #[test]
1683 fn gray_alpha_f32_to_gray_f32() {
1684 let from = PixelDescriptor::new(
1685 ChannelType::F32,
1686 ChannelLayout::GrayAlpha,
1687 Some(AlphaMode::Straight),
1688 TransferFunction::Linear,
1689 );
1690 let to = PixelDescriptor::new(
1691 ChannelType::F32,
1692 ChannelLayout::Gray,
1693 None,
1694 TransferFunction::Linear,
1695 );
1696 let src_f: [f32; 2] = [0.33, 0.9];
1697 let src: &[u8] = bytemuck::cast_slice(&src_f);
1698 let dst = convert_pixel(from, to, src);
1699 let out: &[f32] = bytemuck::cast_slice(&dst);
1700 assert!((out[0] - 0.33).abs() < 1e-6);
1701 }
1702
1703 #[test]
1708 fn bt709_linear_f32_roundtrip() {
1709 use crate::TransferFunctionExt;
1710 let tf = TransferFunction::Bt709;
1711 let values = [0.0f32, 0.1, 0.25, 0.5, 0.75, 1.0];
1712 for &v in &values {
1713 let linear = tf.linearize(v);
1714 let back = tf.delinearize(linear);
1715 assert!(
1716 (back - v).abs() < 1e-5,
1717 "Bt709 roundtrip failed for {v}: linearize={linear}, delinearize={back}"
1718 );
1719 }
1720 }
1721
1722 #[test]
1723 fn unknown_transfer_roundtrip() {
1724 use crate::TransferFunctionExt;
1725 let tf = TransferFunction::Unknown;
1726 let values = [0.0f32, 0.1, 0.5, 0.99, 1.0];
1727 for &v in &values {
1728 let linear = tf.linearize(v);
1729 assert!(
1730 (linear - v).abs() < 1e-7,
1731 "Unknown linearize should be identity: {v} -> {linear}"
1732 );
1733 let back = tf.delinearize(linear);
1734 assert!(
1735 (back - v).abs() < 1e-7,
1736 "Unknown delinearize should be identity: {linear} -> {back}"
1737 );
1738 }
1739 }
1740
1741 #[test]
1742 fn oklab_unknown_primaries_returns_error() {
1743 use crate::ColorPrimaries;
1744 let from = PixelDescriptor::new(
1745 ChannelType::F32,
1746 ChannelLayout::Rgb,
1747 None,
1748 TransferFunction::Linear,
1749 )
1750 .with_primaries(ColorPrimaries::Unknown);
1751 let to = PixelDescriptor::new(
1752 ChannelType::F32,
1753 ChannelLayout::Oklab,
1754 None,
1755 TransferFunction::Linear,
1756 )
1757 .with_primaries(ColorPrimaries::Unknown);
1758 let result = RowConverter::new(from, to);
1759 assert!(result.is_err(), "Oklab with Unknown primaries should fail");
1760 }
1761
1762 #[test]
1767 fn compose_roundtrip_cancels_to_identity() {
1768 let a = RowConverter::new(PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGBAF32_LINEAR)
1769 .unwrap();
1770 let b = RowConverter::new(PixelDescriptor::RGBAF32_LINEAR, PixelDescriptor::RGBA8_SRGB)
1771 .unwrap();
1772 let composed = a.compose(&b).unwrap();
1773 assert!(composed.is_identity(), "sRGB→linear→sRGB should cancel");
1774 }
1775
1776 #[test]
1777 fn compose_chain_reduces_steps() {
1778 let a = RowConverter::new(PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGBAF32_LINEAR)
1784 .unwrap();
1785 let srgb_f32 = PixelDescriptor::new(
1786 ChannelType::F32,
1787 ChannelLayout::Rgba,
1788 Some(AlphaMode::Straight),
1789 TransferFunction::Srgb,
1790 );
1791 let b = RowConverter::new(PixelDescriptor::RGBAF32_LINEAR, srgb_f32).unwrap();
1792 let composed = a.compose(&b).unwrap();
1793 assert!(!composed.is_identity());
1794 assert_eq!(composed.from_descriptor(), PixelDescriptor::RGBA8_SRGB);
1795 assert_eq!(composed.to_descriptor(), srgb_f32);
1796 }
1797
1798 #[test]
1799 fn compose_incompatible_returns_none() {
1800 let a = RowConverter::new(PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGBAF32_LINEAR)
1801 .unwrap();
1802 let b = RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1803 assert!(a.compose(&b).is_none(), "RGBAF32_LINEAR != RGB8_SRGB");
1804 }
1805
1806 #[test]
1807 fn compose_premul_roundtrip_cancels() {
1808 let premul = PixelDescriptor::new(
1809 ChannelType::F32,
1810 ChannelLayout::Rgba,
1811 Some(AlphaMode::Premultiplied),
1812 TransferFunction::Linear,
1813 );
1814 let a = RowConverter::new(PixelDescriptor::RGBAF32_LINEAR, premul).unwrap();
1815 let b = RowConverter::new(premul, PixelDescriptor::RGBAF32_LINEAR).unwrap();
1816 let composed = a.compose(&b).unwrap();
1817 assert!(
1818 composed.is_identity(),
1819 "straight→premul→straight should cancel"
1820 );
1821 }
1822
1823 #[test]
1828 #[should_panic(expected = "CMYK pixel data cannot be processed")]
1829 fn cmyk_rejected_by_row_converter() {
1830 let _ = RowConverter::new(PixelDescriptor::CMYK8, PixelDescriptor::RGB8_SRGB);
1831 }
1832}