1use std::borrow::Cow;
82use std::sync::Arc;
83
84use whereat::At;
85use zencodec::CodecErrorExt;
86use zencodec::decode::{
87 AnimationFrameDecoder, Decode, DecodeJob, DecodeOutput, DecodeRowSink, DecoderConfig,
88 DynDecoderConfig, SinkError, StreamingDecode,
89};
90use zencodec::encode::{AnimationFrameEncoder, EncodeJob, Encoder, EncoderConfig, Fidelity};
91use zencodec::exif::Exif;
92use zencodec::gainmap::{
93 DecodedGainMap, GainMapChannel, GainMapInfo, GainMapParams, GainMapPresence, GainMapRender,
94 GainMapSource,
95};
96use zencodec::{
97 Cicp, CodecError, ColorAuthority, ContentLightLevel, ErrorCategory, ImageFormat,
98 MasteringDisplay, Metadata, MetadataFields, MetadataPolicy, Orientation, SourceColor,
99 UnsupportedOperation,
100};
101use zenpixels::{
102 ColorContext, PixelBuffer, PixelDescriptor, PixelSlice, PixelSliceMut, TransferFunction,
103};
104
105mod adversarial;
106mod contracts;
107pub use adversarial::{
108 ExpectedError, PrefixCase, PrefixOutcome, TimingCase, check_animation_cases,
109 check_decode_prefix_cases,
110};
111pub(crate) mod fixtures;
112#[cfg(test)]
115pub(crate) mod minimal;
116pub mod reference;
117pub use contracts::{
118 check_animation_durations, check_animation_timing, check_animation_timing_unsupported,
119 check_decode_policy_admission, check_encode_policy_admission,
120 check_incremental_capability_honesty, check_incremental_contract, check_incremental_decoder,
121 check_inventory_input_limit, check_metadata_rewrite, check_read_at_contract,
122 check_seekable_capability_honesty,
123};
124
125#[cfg(test)]
126pub(crate) use minimal::{MinimalDecoderConfig, MinimalEncoderConfig};
127pub use reference::{
128 RefError, ReferenceDecoderConfig, ReferenceEncoderConfig, ReferenceZcrDecoderConfig, ZCR_FORMAT,
129};
130
131#[derive(Debug, Clone)]
137pub struct Failure {
138 pub check: &'static str,
140 pub detail: String,
142}
143
144impl std::fmt::Display for Failure {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 write!(f, "[{}] {}", self.check, self.detail)
147 }
148}
149
150impl std::error::Error for Failure {}
151
152pub type Conformance = Result<(), Failure>;
154
155fn fail(check: &'static str, detail: impl Into<String>) -> Failure {
156 Failure {
157 check,
158 detail: detail.into(),
159 }
160}
161
162pub struct TestImage {
168 width: u32,
169 height: u32,
170 desc: PixelDescriptor,
171 data: Vec<u8>,
172}
173
174impl TestImage {
175 pub fn rgba8_gradient(width: u32, height: u32) -> Self {
178 Self::gradient(width, height, PixelDescriptor::RGBA8_SRGB, 4, 0)
179 }
180
181 pub fn rgb8_gradient(width: u32, height: u32) -> Self {
183 Self::gradient(width, height, PixelDescriptor::RGB8_SRGB, 3, 0)
184 }
185
186 pub fn rgba8_gradient_seeded(width: u32, height: u32, seed: u8) -> Self {
189 Self::gradient(width, height, PixelDescriptor::RGBA8_SRGB, 4, seed)
190 }
191
192 pub fn rgb8_gradient_seeded(width: u32, height: u32, seed: u8) -> Self {
195 Self::gradient(width, height, PixelDescriptor::RGB8_SRGB, 3, seed)
196 }
197
198 pub(crate) fn rgb16_gradient(width: u32, height: u32, desc: PixelDescriptor) -> Self {
203 assert!(width > 0 && height > 0, "test image must be non-empty");
204 assert_eq!(
205 desc.bytes_per_pixel(),
206 6,
207 "rgb16_gradient needs a U16 RGB descriptor"
208 );
209 let mut data = vec![0u8; width as usize * height as usize * 6];
210 for y in 0..height as usize {
211 for x in 0..width as usize {
212 let p = (y * width as usize + x) * 6;
213 let r = (x * 1031 + y * 517) as u16;
214 let g = (x * 257 + y * 3079) as u16;
215 let b = ((x ^ y) * 4111 + 12345) as u16;
216 data[p..p + 2].copy_from_slice(&r.to_ne_bytes());
217 data[p + 2..p + 4].copy_from_slice(&g.to_ne_bytes());
218 data[p + 4..p + 6].copy_from_slice(&b.to_ne_bytes());
219 }
220 }
221 Self {
222 width,
223 height,
224 desc,
225 data,
226 }
227 }
228
229 fn gradient(width: u32, height: u32, desc: PixelDescriptor, bpp: usize, seed: u8) -> Self {
230 assert!(width > 0 && height > 0, "test image must be non-empty");
231 let s = seed as usize;
232 let mut data = vec![0u8; width as usize * height as usize * bpp];
233 for y in 0..height as usize {
234 for x in 0..width as usize {
235 let p = (y * width as usize + x) * bpp;
236 data[p] = (x * 7 + y * 3 + s) as u8; data[p + 1] = (x * 3 + y * 11 + s * 2) as u8; data[p + 2] = ((x ^ y) + s) as u8; if bpp == 4 {
240 data[p + 3] = 255 - (x + y + s) as u8; }
242 }
243 }
244 Self {
245 width,
246 height,
247 desc,
248 data,
249 }
250 }
251
252 fn row_bytes(&self) -> usize {
253 self.width as usize * self.desc.bytes_per_pixel()
254 }
255
256 pub fn as_slice(&self) -> PixelSlice<'_> {
258 PixelSlice::new(
259 &self.data,
260 self.width,
261 self.height,
262 self.row_bytes(),
263 self.desc,
264 )
265 .expect("test image dimensions are valid")
266 }
267
268 fn strip(&self, y: u32, h: u32) -> PixelSlice<'_> {
269 let rb = self.row_bytes();
270 let bytes = &self.data[y as usize * rb..(y as usize + h as usize) * rb];
271 PixelSlice::new(bytes, self.width, h, rb, self.desc).expect("strip dimensions are valid")
272 }
273
274 fn pixels(&self) -> Pixels {
275 grab(self.as_slice())
276 }
277}
278
279#[derive(PartialEq, Eq)]
285struct Pixels {
286 width: u32,
287 rows: u32,
288 desc: PixelDescriptor,
289 bytes: Vec<u8>,
290}
291
292impl std::fmt::Debug for Pixels {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294 write!(
295 f,
296 "Pixels {{ {}x{} {:?}, {} bytes }}",
297 self.width,
298 self.rows,
299 self.desc,
300 self.bytes.len()
301 )
302 }
303}
304
305fn grab(ps: PixelSlice<'_>) -> Pixels {
306 grab_ref(&ps)
307}
308
309fn render(p: &Pixels, orientation: Orientation) -> Pixels {
314 let bpp = p.desc.bytes_per_pixel();
315 let (w, h) = (p.width, p.rows);
316 let (ow, oh) = orientation.output_dimensions(w, h);
317 let in_rb = w as usize * bpp;
318 let out_rb = ow as usize * bpp;
319 let mut bytes = vec![0u8; oh as usize * out_rb];
320 for sy in 0..h {
321 for sx in 0..w {
322 let (dx, dy) = orientation.forward_map(sx, sy, w, h);
323 let si = sy as usize * in_rb + sx as usize * bpp;
324 let di = dy as usize * out_rb + dx as usize * bpp;
325 bytes[di..di + bpp].copy_from_slice(&p.bytes[si..si + bpp]);
326 }
327 }
328 Pixels {
329 width: ow,
330 rows: oh,
331 desc: p.desc,
332 bytes,
333 }
334}
335
336fn grab_ref(ps: &PixelSlice<'_>) -> Pixels {
339 let rb = ps.width() as usize * ps.descriptor().bytes_per_pixel();
340 let mut bytes = Vec::with_capacity(rb * ps.rows() as usize);
341 for y in 0..ps.rows() {
342 bytes.extend_from_slice(&ps.row(y)[..rb]);
343 }
344 Pixels {
345 width: ps.width(),
346 rows: ps.rows(),
347 desc: ps.descriptor(),
348 bytes,
349 }
350}
351
352#[derive(Default)]
358struct CollectSink {
359 width: u32,
360 rows: u32,
361 desc: Option<PixelDescriptor>,
362 buf: Vec<u8>,
363}
364
365impl DecodeRowSink for CollectSink {
366 fn begin(
367 &mut self,
368 width: u32,
369 height: u32,
370 descriptor: PixelDescriptor,
371 ) -> Result<(), SinkError> {
372 self.width = width;
373 self.rows = height;
374 self.desc = Some(descriptor);
375 self.buf = vec![0u8; width as usize * height as usize * descriptor.bytes_per_pixel()];
376 Ok(())
377 }
378
379 fn provide_next_buffer(
380 &mut self,
381 y: u32,
382 height: u32,
383 width: u32,
384 descriptor: PixelDescriptor,
385 ) -> Result<PixelSliceMut<'_>, SinkError> {
386 let stride = width as usize * descriptor.bytes_per_pixel();
387 let end = (y as usize + height as usize) * stride;
388 if self.buf.len() < end {
389 self.buf.resize(end, 0);
390 }
391 self.width = width;
392 self.desc = Some(descriptor);
393 self.rows = self.rows.max(y + height);
394 let off = y as usize * stride;
395 Ok(
397 PixelSliceMut::new(&mut self.buf[off..end], width, height, stride, descriptor)
398 .expect("collect sink buffer dimensions are valid"),
399 )
400 }
401}
402
403impl CollectSink {
404 fn into_pixels(self) -> Result<Pixels, String> {
405 let desc = self.desc.ok_or("sink received no buffers")?;
406 Ok(Pixels {
407 width: self.width,
408 rows: self.rows,
409 desc,
410 bytes: self.buf,
411 })
412 }
413}
414
415fn enc_oneshot<E>(
420 cfg: &E,
421 img: &TestImage,
422 meta: Metadata,
423 policy: MetadataPolicy,
424) -> Result<Vec<u8>, String>
425where
426 E: EncoderConfig,
427 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
428{
429 let enc = cfg
430 .clone()
431 .job()
432 .with_metadata_policy(meta, policy)
433 .encoder()
434 .map_err(|e| e.to_string())?;
435 Ok(enc
436 .encode(img.as_slice())
437 .map_err(|e| e.to_string())?
438 .into_vec())
439}
440
441fn enc_push_rows<E>(cfg: &E, img: &TestImage) -> Result<Vec<u8>, String>
442where
443 E: EncoderConfig,
444 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
445{
446 let mut enc = cfg
447 .clone()
448 .job()
449 .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
450 .encoder()
451 .map_err(|e| e.to_string())?;
452 let strip = enc.preferred_strip_height().max(1);
453 let mut y = 0;
454 while y < img.height {
455 let h = strip.min(img.height - y);
456 enc.push_rows(img.strip(y, h)).map_err(|e| e.to_string())?;
457 y += h;
458 }
459 Ok(enc.finish().map_err(|e| e.to_string())?.into_vec())
460}
461
462fn dec_oneshot<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<(Pixels, Metadata), String> {
463 let out = cfg
464 .clone()
465 .job()
466 .decoder(Cow::Borrowed(bytes), &[])
467 .map_err(|e| e.to_string())?
468 .decode()
469 .map_err(|e| e.to_string())?;
470 Ok((grab(out.pixels()), out.metadata()))
471}
472
473fn dec_streaming<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Pixels, String> {
474 let mut sd = cfg
475 .clone()
476 .job()
477 .streaming_decoder(Cow::Borrowed(bytes), &[])
478 .map_err(|e| e.to_string())?;
479 let mut width = 0;
480 let mut rows = 0;
481 let mut desc = None;
482 let mut bytes_out = Vec::new();
483 while let Some((_, strip)) = sd.next_batch().map_err(|e| e.to_string())? {
484 let p = grab(strip);
485 if desc.is_none() {
486 width = p.width;
487 desc = Some(p.desc);
488 }
489 rows += p.rows;
490 bytes_out.extend_from_slice(&p.bytes);
491 }
492 Ok(Pixels {
493 width,
494 rows,
495 desc: desc.ok_or("streaming decoder yielded no strips")?,
496 bytes: bytes_out,
497 })
498}
499
500fn dec_push<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Pixels, String> {
501 let mut sink = CollectSink::default();
502 cfg.clone()
503 .job()
504 .push_decoder(Cow::Borrowed(bytes), &mut sink, &[])
505 .map_err(|e| e.to_string())?;
506 sink.into_pixels()
507}
508
509pub fn check_pixel_roundtrip<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
517where
518 E: EncoderConfig,
519 D: DecoderConfig,
520 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
521{
522 const CHECK: &str = "pixel_roundtrip";
523 let bytes = enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact)
524 .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
525 let (got, _) = dec_oneshot(&dec, &bytes).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
526 if got != img.pixels() {
527 return Err(fail(
528 CHECK,
529 format!(
530 "decoded pixels differ from the {}x{} input",
531 img.width, img.height
532 ),
533 ));
534 }
535 Ok(())
536}
537
538pub fn check_streaming_output_honesty<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
581where
582 E: EncoderConfig,
583 D: DecoderConfig,
584 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
585{
586 use zencodec::encode::OutputTiming;
587 const CHECK: &str = "streaming_output_honesty";
588 let caps = E::capabilities();
589 if !caps.push_rows() {
590 if caps.streaming_output() {
591 return Err(fail(CHECK, "declares streaming_output without push_rows"));
592 }
593 return Ok(());
594 }
595 let desc = img.strip(0, 1).descriptor();
596 #[derive(Clone, Copy, PartialEq)]
598 enum Drain {
599 Never,
601 All,
603 Partial,
606 Take,
609 }
610 struct Run {
611 timing: OutputTiming,
612 file: Vec<u8>,
613 points: Vec<(u32, usize)>,
616 }
617 let encoder = || {
618 enc.clone()
619 .job()
620 .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
621 .encoder()
622 .map_err(|e| e.to_string())
623 };
624 let run_on = |mut e: <E::Job as EncodeJob>::Enc, drain: Drain| -> Result<Option<Run>, String> {
628 let timing = match e.begin(img.width, img.height, desc) {
629 Ok(t) => t,
630 Err(err)
631 if err.unsupported_operation() == Some(&UnsupportedOperation::RowLevelEncode) =>
632 {
633 return Ok(None);
634 }
635 Err(err) => return Err(format!("begin: {err}")),
636 };
637 let mut out = Vec::new();
638 let mut cycle = 0usize;
639 let mut take =
640 |e: &mut <E::Job as EncodeJob>::Enc, out: &mut Vec<u8>| -> Result<(), String> {
641 if drain == Drain::Take {
642 cycle += 1;
643 let mut chunk = if cycle.is_multiple_of(2) {
644 Vec::new()
645 } else {
646 vec![0xA5]
647 };
648 let kept = chunk.len();
649 let len = e.pending_output().len();
650 e.take_output(&mut chunk);
651 if chunk.len() != kept + len || chunk[..kept] != [0xA5][..kept] {
652 return Err(format!(
653 "take_output of {len} pending bytes into a vector of {kept} \
654 left it {} long, or changed what it held",
655 chunk.len()
656 ));
657 }
658 if !e.pending_output().is_empty() {
659 return Err("take_output left bytes pending".into());
660 }
661 out.extend_from_slice(&chunk[kept..]);
662 return Ok(());
663 }
664 let pending = e.pending_output();
665 let len = pending.len();
666 let n = match drain {
667 Drain::Never => return Ok(()),
668 Drain::Take => unreachable!("handled above"),
669 Drain::All => len,
670 Drain::Partial => {
671 cycle += 1;
672 match cycle % 5 {
673 0 => 1.min(len),
674 1 => 7.min(len),
675 2 => len.saturating_sub(1),
676 3 => len + 5, _ => len / 2,
678 }
679 }
680 };
681 out.extend_from_slice(&pending[..n.min(len)]);
682 e.consume_output(n);
683 let left = e.pending_output().len();
684 if left != len - n.min(len) {
685 return Err(format!(
686 "consume_output({n}) of {len} pending left {left} (want {})",
687 len - n.min(len)
688 ));
689 }
690 Ok(())
691 };
692 let mut points = vec![(0, out.len() + e.pending_output().len())];
693 take(&mut e, &mut out)?;
694 let strip = e
695 .preferred_strip_height()
696 .max(1)
697 .min(img.height.div_ceil(4).max(1));
698 let mut y = 0;
699 while y < img.height {
700 let h = strip.min(img.height - y);
701 e.push_rows(img.strip(y, h)).map_err(|e| e.to_string())?;
702 y += h;
703 points.push((y, out.len() + e.pending_output().len()));
704 take(&mut e, &mut out)?;
705 }
706 out.extend_from_slice(e.pending_output());
709 let n = e.pending_output().len();
710 e.consume_output(n);
711 out.extend_from_slice(&e.finish().map_err(|e| e.to_string())?.into_vec());
712 Ok(Some(Run {
713 timing,
714 file: out,
715 points,
716 }))
717 };
718 let run = |drain: Drain| -> Result<Option<Run>, String> { run_on(encoder()?, drain) };
719 let Some(drained) = run(Drain::All).map_err(|e| fail(CHECK, format!("drained encode: {e}")))?
720 else {
721 return Ok(());
722 };
723 let timing = drained.timing;
724 if timing.streams() && !caps.streaming_output() {
725 return Err(fail(
726 CHECK,
727 "begin reported streaming output but streaming_output is not declared",
728 ));
729 }
730 let Some(lag) = timing.lag_rows() else {
731 if drained.points.iter().any(|&(_, n)| n > 0) {
732 return Err(fail(
733 CHECK,
734 "pending_output returned bytes but begin reported AT_FINISH",
735 ));
736 }
737 return Ok(());
738 };
739 if lag >= img.height {
740 return Err(fail(
741 CHECK,
742 format!(
743 "begin reported streaming with a lag of {lag} rows on a {}-row image: \
744 an encoder that holds every row reports AT_FINISH",
745 img.height
746 ),
747 ));
748 }
749 let strip = drained.points.get(1).map_or(1, |p| p.0);
750 if img.height <= lag + 2 * strip {
751 return Err(fail(
752 CHECK,
753 format!(
754 "a {}-row image cannot verify a lag of {lag} rows in strips of {strip}: \
755 use an image taller than {} rows",
756 img.height,
757 lag + 2 * strip
758 ),
759 ));
760 }
761 let points = &drained.points[..drained.points.len() - 1];
765 for (i, &(rows, bytes)) in points.iter().enumerate() {
766 if let Some(&(later, more)) = points[i + 1..].iter().find(|&&(r, _)| r > rows + lag)
767 && more <= bytes
768 {
769 return Err(fail(
770 CHECK,
771 format!(
772 "begin reported a lag of {lag} rows, but from {rows} to {later} rows \
773 pushed no bytes became pending ({bytes} available at both)"
774 ),
775 ));
776 }
777 }
778 let whole = run(Drain::Never)
779 .map_err(|e| fail(CHECK, format!("undrained encode: {e}")))?
780 .ok_or_else(|| fail(CHECK, "begin declined row-level encode on the second run"))?;
781 let partial = run(Drain::Partial)
782 .map_err(|e| fail(CHECK, format!("partially drained encode: {e}")))?
783 .ok_or_else(|| fail(CHECK, "begin declined row-level encode on the third run"))?;
784 let taken = run(Drain::Take)
785 .map_err(|e| fail(CHECK, format!("encode drained with take_output: {e}")))?
786 .ok_or_else(|| fail(CHECK, "begin declined row-level encode on the fourth run"))?;
787 if whole.timing != timing || partial.timing != timing || taken.timing != timing {
788 return Err(fail(
789 CHECK,
790 "begin reported a different timing on another run",
791 ));
792 }
793 if drained.file != whole.file {
794 return Err(fail(
795 CHECK,
796 "draining pending_output changed the encoded bytes",
797 ));
798 }
799 if partial.file != whole.file {
800 return Err(fail(
801 CHECK,
802 "draining uneven prefixes of pending_output changed the encoded bytes",
803 ));
804 }
805 if taken.file != whole.file {
806 return Err(fail(
807 CHECK,
808 "draining with take_output changed the encoded bytes",
809 ));
810 }
811 let mut e = encoder().map_err(|e| fail(CHECK, e))?;
813 e.begin(img.width, img.height, desc)
814 .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
815 if e.begin(img.width, img.height, desc).is_ok() {
816 return Err(fail(CHECK, "a second begin was accepted"));
817 }
818 let mut e = encoder().map_err(|e| fail(CHECK, e))?;
819 e.begin(img.width, img.height, desc)
820 .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
821 if e.encode(img.strip(0, img.height)).is_ok() {
822 return Err(fail(CHECK, "encode after a streaming begin was accepted"));
823 }
824 let bpp = desc.bytes_per_pixel();
828 let mut refusal_checked = false;
829 for (w, h) in [(0, img.height), (img.width, 0)] {
830 let empty = vec![0u8; w as usize * bpp * h as usize];
831 let slice = PixelSlice::new(&empty, w, h, w as usize * bpp, desc)
832 .map_err(|e| fail(CHECK, format!("{w}x{h} slice: {e}")))?;
833 let encoded = encoder().map_err(|e| fail(CHECK, e))?.encode(slice).is_ok();
834 let mut e = encoder().map_err(|e| fail(CHECK, e))?;
835 let begun = e.begin(w, h, desc);
836 if let Err(err) = &begun
837 && err.unsupported_operation() == Some(&UnsupportedOperation::RowLevelEncode)
838 {
839 continue;
840 }
841 if begun.is_ok() != encoded {
842 return Err(fail(
843 CHECK,
844 format!(
845 "begin({w}, {h}) {} but encode of a {w}x{h} image {}",
846 if begun.is_ok() {
847 "was accepted"
848 } else {
849 "was refused"
850 },
851 if encoded { "succeeds" } else { "fails" }
852 ),
853 ));
854 }
855 if begun.is_err() && !refusal_checked {
856 refusal_checked = true;
857 let after = run_on(e, Drain::All)
858 .map_err(|m| {
859 fail(
860 CHECK,
861 format!(
862 "after a refused begin({w}, {h}), the encode of the real \
863 {}x{} image on the same encoder failed: {m}",
864 img.width, img.height
865 ),
866 )
867 })?
868 .ok_or_else(|| {
869 fail(
870 CHECK,
871 format!("after a refused begin({w}, {h}), begin declined row-level encode"),
872 )
873 })?;
874 if after.timing != timing || after.file != whole.file {
875 return Err(fail(
876 CHECK,
877 format!(
878 "a refused begin({w}, {h}) changed the encode that followed on \
879 the same encoder"
880 ),
881 ));
882 }
883 }
884 }
885 let one = enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact)
886 .map_err(|e| fail(CHECK, format!("one-shot encode: {e}")))?;
887 let (want, _) = dec_oneshot(&dec, &one).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
888 let (got, _) = dec_oneshot(&dec, &drained.file)
889 .map_err(|e| fail(CHECK, format!("decode streamed: {e}")))?;
890 if got != want {
891 return Err(fail(
892 CHECK,
893 "streamed encode decodes to different pixels than the one-shot encode",
894 ));
895 }
896 Ok(())
897}
898
899fn streaming_check_image<E>(enc: &E) -> TestImage
903where
904 E: EncoderConfig,
905 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
906{
907 let mut height = 24u32;
908 for _ in 0..4 {
909 let Ok(mut e) = enc.clone().job().encoder() else {
910 break;
911 };
912 let Ok(timing) = e.begin(40, height, PixelDescriptor::RGBA8_SRGB) else {
913 break;
914 };
915 let Some(lag) = timing.lag_rows() else {
916 break;
917 };
918 let strip = e
919 .preferred_strip_height()
920 .max(1)
921 .min(height.div_ceil(4).max(1));
922 if height > lag + 2 * strip {
923 break;
924 }
925 height = lag + 2 * strip.max(e.preferred_strip_height().max(1)) + 8;
926 }
927 TestImage::rgba8_gradient(40, height)
928}
929
930pub fn check_cross_path_pixel_equivalence<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
936where
937 E: EncoderConfig,
938 D: DecoderConfig,
939 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
940{
941 const CHECK: &str = "cross_path_pixel_equivalence";
942 let want = img.pixels();
943
944 let canonical = enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact)
946 .map_err(|e| fail(CHECK, format!("canonical encode: {e}")))?;
947
948 let mut decoded: Vec<(&str, Pixels)> = Vec::new();
949 decoded.push((
950 "decode",
951 dec_oneshot(&dec, &canonical)
952 .map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?
953 .0,
954 ));
955 decoded.push((
956 "push_decoder",
957 dec_push(&dec, &canonical).map_err(|e| fail(CHECK, format!("push decode: {e}")))?,
958 ));
959 if D::capabilities().streaming() {
960 decoded.push((
961 "streaming",
962 dec_streaming(&dec, &canonical)
963 .map_err(|e| fail(CHECK, format!("streaming decode: {e}")))?,
964 ));
965 }
966
967 for (name, px) in &decoded {
968 if *px != want {
969 return Err(fail(
970 CHECK,
971 format!("decode path `{name}` diverged from the input image"),
972 ));
973 }
974 }
975
976 if E::capabilities().push_rows() {
978 let pr =
979 enc_push_rows(&enc, img).map_err(|e| fail(CHECK, format!("push_rows encode: {e}")))?;
980 let (got, _) = dec_oneshot(&dec, &pr)
981 .map_err(|e| fail(CHECK, format!("decode push_rows output: {e}")))?;
982 if got != want {
983 return Err(fail(
984 CHECK,
985 "push_rows encode produced different pixels than one-shot encode".to_string(),
986 ));
987 }
988 }
989
990 if E::capabilities().encode_from() {
991 let ef = run_encode_from(&enc, img)
992 .map_err(|e| fail(CHECK, format!("encode_from encode: {e}")))?;
993 let (got, _) = dec_oneshot(&dec, &ef)
994 .map_err(|e| fail(CHECK, format!("decode encode_from output: {e}")))?;
995 if got != want {
996 return Err(fail(
997 CHECK,
998 "encode_from (pull-source) produced different pixels than one-shot encode"
999 .to_string(),
1000 ));
1001 }
1002 }
1003
1004 Ok(())
1005}
1006
1007fn encode_animation<E>(cfg: &E, frames: &[TestImage], meta: Metadata) -> Result<Vec<u8>, String>
1008where
1009 E: EncoderConfig,
1010 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1011{
1012 let mut a = cfg
1013 .clone()
1014 .job()
1015 .with_metadata_policy(meta, MetadataPolicy::PreserveExact)
1016 .with_loop_count(Some(0))
1017 .animation_frame_encoder()
1018 .map_err(|e| e.to_string())?;
1019 for (i, f) in frames.iter().enumerate() {
1020 a.push_frame(f.as_slice(), 40 + i as u32 * 10, None)
1021 .map_err(|e| e.to_string())?;
1022 }
1023 Ok(a.finish(None).map_err(|e| e.to_string())?.into_vec())
1024}
1025
1026fn decode_anim_borrowed<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Vec<Pixels>, String> {
1027 let mut d = cfg
1028 .clone()
1029 .job()
1030 .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1031 .map_err(|e| e.to_string())?;
1032 let mut out = Vec::new();
1033 while let Some(frame) = d.render_next_frame(None).map_err(|e| e.to_string())? {
1035 out.push(grab_ref(frame.pixels()));
1036 }
1037 Ok(out)
1038}
1039
1040fn decode_anim_owned<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Vec<Pixels>, String> {
1041 let mut d = cfg
1042 .clone()
1043 .job()
1044 .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1045 .map_err(|e| e.to_string())?;
1046 let mut out = Vec::new();
1047 while let Some(frame) = d.render_next_frame_owned(None).map_err(|e| e.to_string())? {
1048 out.push(grab(frame.pixels()));
1049 }
1050 Ok(out)
1051}
1052
1053fn decode_anim_sink<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Vec<Pixels>, String> {
1054 let mut d = cfg
1055 .clone()
1056 .job()
1057 .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1058 .map_err(|e| e.to_string())?;
1059 let mut out = Vec::new();
1060 loop {
1061 let mut sink = CollectSink::default();
1062 match d
1063 .render_next_frame_to_sink(None, &mut sink)
1064 .map_err(|e| e.to_string())?
1065 {
1066 Some(_info) => out.push(sink.into_pixels()?),
1067 None => break,
1068 }
1069 }
1070 Ok(out)
1071}
1072
1073pub fn check_animation_cross_path_equivalence<E, D>(
1085 enc: E,
1086 dec: D,
1087 frames: &[TestImage],
1088) -> Conformance
1089where
1090 E: EncoderConfig,
1091 D: DecoderConfig,
1092 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1093{
1094 const CHECK: &str = "animation_cross_path_equivalence";
1095 if !E::capabilities().animation() || !D::capabilities().animation() {
1096 return Ok(()); }
1098 if frames.is_empty() {
1099 return Err(fail(CHECK, "no frames supplied"));
1100 }
1101
1102 let bytes = encode_animation(&enc, frames, Metadata::none())
1103 .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
1104 let want: Vec<Pixels> = frames.iter().map(|f| f.pixels()).collect();
1105
1106 let mut borrowed = dec
1109 .clone()
1110 .job()
1111 .animation_frame_decoder(Cow::Borrowed(&bytes), &[])
1112 .map_err(|e| fail(CHECK, e.to_string()))?;
1113 let mut owned = dec
1114 .clone()
1115 .job()
1116 .animation_frame_decoder(Cow::Borrowed(&bytes), &[])
1117 .map_err(|e| fail(CHECK, e.to_string()))?;
1118 for index in 0..frames.len() {
1119 let expected = zencodec::animation::FrameDuration::from_millis(40 + index as u32 * 10);
1120 let a = borrowed
1121 .render_next_frame(None)
1122 .map_err(|e| fail(CHECK, e.to_string()))?
1123 .ok_or_else(|| fail(CHECK, "missing borrowed frame"))?;
1124 let b = owned
1125 .render_next_frame_owned(None)
1126 .map_err(|e| fail(CHECK, e.to_string()))?
1127 .ok_or_else(|| fail(CHECK, "missing owned frame"))?;
1128 if a.duration() != expected || b.duration() != expected {
1129 return Err(fail(
1130 CHECK,
1131 format!(
1132 "frame {index} lost timing: {:?}/{:?}, expected {expected:?}",
1133 a.duration(),
1134 b.duration()
1135 ),
1136 ));
1137 }
1138 }
1139
1140 let paths = [
1141 ("render_next_frame", decode_anim_borrowed(&dec, &bytes)),
1142 ("render_next_frame_owned", decode_anim_owned(&dec, &bytes)),
1143 ("render_next_frame_to_sink", decode_anim_sink(&dec, &bytes)),
1144 ];
1145 for (name, res) in paths {
1146 let got = res.map_err(|e| fail(CHECK, format!("{name}: {e}")))?;
1147 if got.len() != want.len() {
1148 return Err(fail(
1149 CHECK,
1150 format!(
1151 "{name} produced {} frames, expected {}",
1152 got.len(),
1153 want.len()
1154 ),
1155 ));
1156 }
1157 for (i, (g, w)) in got.iter().zip(&want).enumerate() {
1158 if g != w {
1159 return Err(fail(
1160 CHECK,
1161 format!("{name} frame {i} pixels differ from the input"),
1162 ));
1163 }
1164 }
1165 }
1166 Ok(())
1167}
1168
1169pub fn check_animation_streaming_output_honesty<E, D>(
1193 enc: E,
1194 dec: D,
1195 frames: &[TestImage],
1196) -> Conformance
1197where
1198 E: EncoderConfig,
1199 D: DecoderConfig,
1200 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1201{
1202 use zencodec::encode::AnimationOutputTiming;
1203 const CHECK: &str = "animation_streaming_output_honesty";
1204 if !E::capabilities().animation() {
1205 return Ok(());
1206 }
1207 let Some(first) = frames.first() else {
1208 return Err(fail(CHECK, "no frames supplied"));
1209 };
1210 let (width, height) = (first.width, first.height);
1211 let desc = first.strip(0, 1).descriptor();
1212 let count = frames.len() as u32;
1213 let encoder = || {
1214 enc.clone()
1215 .job()
1216 .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
1217 .with_loop_count(Some(0))
1218 .animation_frame_encoder()
1219 .map_err(|e| e.to_string())
1220 };
1221 #[derive(Clone, Copy, PartialEq)]
1222 enum Drain {
1223 Never,
1224 All,
1225 Take,
1226 }
1227 struct Run {
1228 timing: AnimationOutputTiming,
1229 file: Vec<u8>,
1230 points: Vec<(u32, usize)>,
1231 }
1232 let run_on = |mut a: <E::Job as EncodeJob>::AnimationFrameEnc,
1235 declare: Option<u32>,
1236 drain: Drain|
1237 -> Result<Option<Run>, String> {
1238 let timing = match a.begin(width, height, desc, declare) {
1239 Ok(t) => t,
1240 Err(err) if err.unsupported_operation().is_some() => return Ok(None),
1241 Err(err) => return Err(format!("begin: {err}")),
1242 };
1243 let mut out = Vec::new();
1244 let take = |a: &mut <E::Job as EncodeJob>::AnimationFrameEnc, out: &mut Vec<u8>| match drain
1245 {
1246 Drain::Never => {}
1247 Drain::All => {
1248 let n = a.pending_output().len();
1249 out.extend_from_slice(a.pending_output());
1250 a.consume_output(n);
1251 }
1252 Drain::Take => {
1253 let mut chunk = Vec::new();
1254 a.take_output(&mut chunk);
1255 out.extend_from_slice(&chunk);
1256 }
1257 };
1258 let mut points = vec![(0, a.pending_output().len())];
1259 take(&mut a, &mut out);
1260 for (i, f) in frames.iter().enumerate() {
1261 a.push_frame(f.as_slice(), 40 + i as u32 * 10, None)
1262 .map_err(|e| format!("push_frame {i}: {e}"))?;
1263 points.push((i as u32 + 1, out.len() + a.pending_output().len()));
1264 take(&mut a, &mut out);
1265 }
1266 if !a.pending_output().is_empty() && drain != Drain::Never {
1267 return Err("bytes pending right after a full drain".into());
1268 }
1269 out.extend_from_slice(&a.finish(None).map_err(|e| e.to_string())?.into_vec());
1271 Ok(Some(Run {
1272 timing,
1273 file: out,
1274 points,
1275 }))
1276 };
1277 let run = |declare: Option<u32>, drain: Drain| -> Result<Option<Run>, String> {
1278 run_on(encoder()?, declare, drain)
1279 };
1280 let plain = encode_animation(&enc, frames, Metadata::none())
1284 .map_err(|e| fail(CHECK, format!("encode without begin: {e}")))?;
1285 let decode_timed =
1286 |bytes: &[u8]| -> Result<Vec<(Pixels, zencodec::animation::FrameDuration)>, String> {
1287 let mut d = dec
1288 .clone()
1289 .job()
1290 .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1291 .map_err(|e| e.to_string())?;
1292 let mut out = Vec::new();
1293 while let Some(f) = d.render_next_frame(None).map_err(|e| e.to_string())? {
1294 out.push((grab_ref(f.pixels()), f.duration()));
1295 }
1296 Ok(out)
1297 };
1298 let want = decode_timed(&plain).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
1299 for declare in [Some(count), None] {
1300 let label = if declare.is_some() {
1301 "with the frame count"
1302 } else {
1303 "without the frame count"
1304 };
1305 let Some(drained) =
1306 run(declare, Drain::All).map_err(|e| fail(CHECK, format!("{label}: {e}")))?
1307 else {
1308 continue;
1309 };
1310 let Some(lag) = drained.timing.lag_frames() else {
1311 if drained.points.iter().any(|&(_, n)| n > 0) {
1312 return Err(fail(
1313 CHECK,
1314 format!("{label}: pending_output returned bytes but begin reported AT_FINISH"),
1315 ));
1316 }
1317 continue;
1318 };
1319 if declare.is_some() && lag >= count {
1320 return Err(fail(
1321 CHECK,
1322 format!(
1323 "{label}: begin reported a lag of {lag} frames for {count} frames: \
1324 an encoder that holds every frame reports AT_FINISH"
1325 ),
1326 ));
1327 }
1328 if count <= lag + 1 {
1329 return Err(fail(
1330 CHECK,
1331 format!(
1332 "{count} frames cannot verify a lag of {lag} frames: supply more than {}",
1333 lag + 1
1334 ),
1335 ));
1336 }
1337 let points = &drained.points[..drained.points.len() - 1];
1338 for (i, &(n, bytes)) in points.iter().enumerate() {
1339 if let Some(&(later, more)) = points[i + 1..].iter().find(|&&(m, _)| m > n + lag)
1340 && more <= bytes
1341 {
1342 return Err(fail(
1343 CHECK,
1344 format!(
1345 "{label}: begin reported a lag of {lag} frames, but from {n} to \
1346 {later} frames pushed no bytes became pending"
1347 ),
1348 ));
1349 }
1350 }
1351 for (name, drain) in [("undrained", Drain::Never), ("take_output", Drain::Take)] {
1352 let other = run(declare, drain)
1353 .map_err(|e| fail(CHECK, format!("{label}, {name}: {e}")))?
1354 .ok_or_else(|| fail(CHECK, format!("{label}: begin declined on another run")))?;
1355 if other.timing != drained.timing {
1356 return Err(fail(
1357 CHECK,
1358 format!("{label}: begin reported a different timing on another run"),
1359 ));
1360 }
1361 if other.file != drained.file {
1362 return Err(fail(
1363 CHECK,
1364 format!("{label}: the {name} encode differs from the drained one"),
1365 ));
1366 }
1367 }
1368 let got = decode_timed(&drained.file)
1369 .map_err(|e| fail(CHECK, format!("{label}: decode: {e}")))?;
1370 if got.len() != want.len() {
1371 return Err(fail(
1372 CHECK,
1373 format!(
1374 "{label}: the streamed animation has {} frames, the encode without \
1375 begin {}",
1376 got.len(),
1377 want.len()
1378 ),
1379 ));
1380 }
1381 for (index, ((gp, gd), (wp, wd))) in got.iter().zip(&want).enumerate() {
1382 if gp != wp {
1383 return Err(fail(
1384 CHECK,
1385 format!(
1386 "{label}: frame {index} of the streamed animation decodes to \
1387 different pixels than the encode without begin"
1388 ),
1389 ));
1390 }
1391 if gd != wd {
1392 return Err(fail(
1393 CHECK,
1394 format!("{label}: frame {index} lasts {gd:?} streamed, {wd:?} without begin"),
1395 ));
1396 }
1397 }
1398 let mut a = encoder().map_err(|e| fail(CHECK, e))?;
1399 a.begin(width, height, desc, declare)
1400 .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
1401 if a.begin(width, height, desc, declare).is_ok() {
1402 return Err(fail(CHECK, format!("{label}: a second begin was accepted")));
1403 }
1404 let mut a = encoder().map_err(|e| fail(CHECK, e))?;
1406 if let Err(err) = a.begin(0, height, desc, declare)
1407 && err.unsupported_operation().is_none()
1408 {
1409 let after = run_on(a, declare, Drain::All)
1410 .map_err(|e| {
1411 fail(
1412 CHECK,
1413 format!(
1414 "{label}: after a refused begin(0, {height}), the encode on the \
1415 same encoder failed: {e}"
1416 ),
1417 )
1418 })?
1419 .ok_or_else(|| {
1420 fail(
1421 CHECK,
1422 format!("{label}: after a refused begin(0, {height}), begin declined"),
1423 )
1424 })?;
1425 if after.timing != drained.timing || after.file != drained.file {
1426 return Err(fail(
1427 CHECK,
1428 format!(
1429 "{label}: a refused begin(0, {height}) changed the encode that \
1430 followed on the same encoder"
1431 ),
1432 ));
1433 }
1434 }
1435 if declare.is_some() {
1436 let mut a = encoder().map_err(|e| fail(CHECK, e))?;
1437 a.begin(width, height, desc, declare)
1438 .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
1439 for (i, f) in frames[..frames.len() - 1].iter().enumerate() {
1440 a.push_frame(f.as_slice(), 40 + i as u32 * 10, None)
1441 .map_err(|e| fail(CHECK, format!("push_frame {i}: {e}")))?;
1442 }
1443 if a.finish(None).is_ok() {
1444 return Err(fail(
1445 CHECK,
1446 format!(
1447 "finish after {} of {count} declared frames was accepted",
1448 count - 1
1449 ),
1450 ));
1451 }
1452 }
1453 }
1454 Ok(())
1455}
1456
1457fn animation_streaming_frames<E>(enc: &E) -> Vec<TestImage>
1460where
1461 E: EncoderConfig,
1462 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1463{
1464 let mut count = 6u32;
1465 for _ in 0..3 {
1466 let lag = enc
1467 .clone()
1468 .job()
1469 .with_loop_count(Some(0))
1470 .animation_frame_encoder()
1471 .ok()
1472 .and_then(|mut a| {
1473 a.begin(24, 16, PixelDescriptor::RGBA8_SRGB, Some(count))
1474 .ok()
1475 })
1476 .and_then(|t| t.lag_frames());
1477 match lag {
1478 Some(lag) if count <= lag + 1 => count = (lag + 4).min(200),
1479 _ => break,
1480 }
1481 }
1482 (0..count)
1483 .map(|i| TestImage::rgba8_gradient_seeded(24, 16, (i * 60 % 256) as u8))
1484 .collect()
1485}
1486
1487pub fn check_metadata_no_leak<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
1507where
1508 E: EncoderConfig,
1509 D: DecoderConfig,
1510 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
1511{
1512 const CHECK: &str = "metadata_no_leak";
1513 let rich = Metadata::none()
1514 .with_exif(fixtures::rich_exif_le())
1515 .with_xmp(fixtures::sample_xmp())
1516 .with_icc(fixtures::sample_icc())
1517 .with_cicp(Cicp::SRGB);
1518
1519 let policies = [
1520 ("Web", MetadataPolicy::Web),
1521 ("ColorAndRotation", MetadataPolicy::ColorAndRotation),
1522 (
1523 "Web + camera/timestamps",
1524 MetadataPolicy::Custom(
1525 MetadataPolicy::Web.fields().with_exif(
1526 zencodec::ExifPolicy::ATTRIBUTED_ORIENTATION
1527 .with_camera(zencodec::Retention::Keep)
1528 .with_datetimes(zencodec::Retention::Keep),
1529 ),
1530 ),
1531 ),
1532 ("PreserveExact", MetadataPolicy::PreserveExact),
1533 (
1534 "Custom(DISCARD_ALL)",
1535 MetadataPolicy::Custom(MetadataFields::DISCARD_ALL),
1536 ),
1537 ];
1538
1539 for (name, policy) in policies {
1540 #[allow(deprecated)] let expected = rich.clone().filtered(&policy);
1542 let bytes = enc_oneshot(&enc, img, rich.clone(), policy)
1543 .map_err(|e| fail(CHECK, format!("[{name}] encode: {e}")))?;
1544 let (_, decoded) =
1545 dec_oneshot(&dec, &bytes).map_err(|e| fail(CHECK, format!("[{name}] decode: {e}")))?;
1546 let clean = enc_oneshot(&enc, img, Metadata::none(), policy)
1550 .map_err(|e| fail(CHECK, format!("[{name}] clean encode: {e}")))?;
1551 let (_, intrinsic) = dec_oneshot(&dec, &clean)
1552 .map_err(|e| fail(CHECK, format!("[{name}] clean decode: {e}")))?;
1553 assert_no_leak(CHECK, name, &decoded, &expected, &intrinsic)?;
1554 }
1555 Ok(())
1556}
1557
1558fn assert_no_leak(
1559 check: &'static str,
1560 policy: &str,
1561 decoded: &Metadata,
1562 expected: &Metadata,
1563 intrinsic: &Metadata,
1564) -> Conformance {
1565 if decoded.icc_profile.is_some()
1566 && expected.icc_profile.is_none()
1567 && decoded.icc_profile != intrinsic.icc_profile
1568 {
1569 return Err(fail(
1570 check,
1571 format!("[{policy}] ICC profile in output but the policy dropped it"),
1572 ));
1573 }
1574 if decoded.xmp.is_some() && expected.xmp.is_none() {
1575 return Err(fail(
1576 check,
1577 format!("[{policy}] XMP in output but the policy dropped it"),
1578 ));
1579 }
1580 if decoded.cicp.is_some() && expected.cicp.is_none() && decoded.cicp != intrinsic.cicp {
1581 return Err(fail(
1582 check,
1583 format!("[{policy}] CICP color signaling in output but the policy dropped it"),
1584 ));
1585 }
1586 if decoded.content_light_level.is_some() && expected.content_light_level.is_none() {
1587 return Err(fail(
1588 check,
1589 format!("[{policy}] HDR content-light-level in output but the policy dropped it"),
1590 ));
1591 }
1592 if decoded.mastering_display.is_some() && expected.mastering_display.is_none() {
1593 return Err(fail(
1594 check,
1595 format!("[{policy}] HDR mastering-display in output but the policy dropped it"),
1596 ));
1597 }
1598
1599 match &decoded.exif {
1600 None => {} Some(d) => {
1602 if expected.exif.is_none() {
1603 return Err(fail(
1604 check,
1605 format!("[{policy}] EXIF in output but the policy dropped the whole blob"),
1606 ));
1607 }
1608 let want = expected.exif.as_deref().and_then(Exif::parse);
1609 if let Some(dx) = Exif::parse(d.as_ref()) {
1610 let want_gps = want.as_ref().is_some_and(Exif::has_gps);
1611 if dx.has_gps() && !want_gps {
1612 return Err(fail(
1613 check,
1614 format!(
1615 "[{policy}] GPS data in output EXIF but the policy dropped it (privacy leak)"
1616 ),
1617 ));
1618 }
1619 let want_thumb = want.as_ref().is_some_and(Exif::has_thumbnail);
1620 if dx.has_thumbnail() && !want_thumb {
1621 return Err(fail(
1622 check,
1623 format!(
1624 "[{policy}] thumbnail in output EXIF but the policy dropped it (privacy leak)"
1625 ),
1626 ));
1627 }
1628 let want_rights = want
1629 .as_ref()
1630 .is_some_and(|w| w.copyright().is_some() || w.artist().is_some());
1631 if (dx.copyright().is_some() || dx.artist().is_some()) && !want_rights {
1632 return Err(fail(
1633 check,
1634 format!(
1635 "[{policy}] rights tags in output EXIF but the policy dropped them"
1636 ),
1637 ));
1638 }
1639 let want_camera = want.as_ref().is_some_and(Exif::has_camera);
1640 if dx.has_camera() && !want_camera {
1641 return Err(fail(
1642 check,
1643 format!(
1644 "[{policy}] camera-identity tags (Make/Model/MakerNote/serial/…) in output EXIF but the policy dropped them (privacy leak)"
1645 ),
1646 ));
1647 }
1648 for (name, present, wanted) in [
1651 (
1652 "device identifiers",
1653 dx.has_device_ids(),
1654 want.as_ref().is_some_and(Exif::has_device_ids),
1655 ),
1656 (
1657 "camera owner",
1658 dx.has_camera_owner(),
1659 want.as_ref().is_some_and(Exif::has_camera_owner),
1660 ),
1661 (
1662 "image identity",
1663 dx.has_image_unique_id(),
1664 want.as_ref().is_some_and(Exif::has_image_unique_id),
1665 ),
1666 (
1667 "UTC offsets",
1668 dx.has_time_offsets(),
1669 want.as_ref().is_some_and(Exif::has_time_offsets),
1670 ),
1671 ] {
1672 if present && !wanted {
1673 return Err(fail(
1674 check,
1675 format!(
1676 "[{policy}] {name} in output EXIF but the policy dropped it (privacy leak)"
1677 ),
1678 ));
1679 }
1680 }
1681 let want_datetimes = want.as_ref().is_some_and(Exif::has_datetimes);
1682 if dx.has_datetimes() && !want_datetimes {
1683 return Err(fail(
1684 check,
1685 format!(
1686 "[{policy}] capture-timestamp tags in output EXIF but the policy dropped them (privacy leak)"
1687 ),
1688 ));
1689 }
1690 } else {
1691 return Err(fail(
1696 check,
1697 format!(
1698 "[{policy}] output EXIF is unparseable — cannot verify the policy's drops, and it may hide raw GPS/camera bytes"
1699 ),
1700 ));
1701 }
1702 }
1703 }
1704 Ok(())
1705}
1706
1707pub fn check_orientation_roundtrip<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
1725where
1726 E: EncoderConfig,
1727 D: DecoderConfig,
1728 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
1729{
1730 const CHECK: &str = "orientation_roundtrip";
1731 let orientations = [
1732 Orientation::Rotate90,
1733 Orientation::Rotate180,
1734 Orientation::Rotate270,
1735 Orientation::FlipH,
1736 Orientation::FlipV,
1737 ];
1738 let policies = [
1739 ("Web", MetadataPolicy::Web),
1740 ("ColorAndRotation", MetadataPolicy::ColorAndRotation),
1741 ("PreserveExact", MetadataPolicy::PreserveExact),
1742 ];
1743 let input = img.pixels();
1744 for ori in orientations {
1745 let want = render(&input, ori);
1747 for (name, policy) in policies {
1748 let meta = Metadata::none().with_orientation(ori);
1749 let bytes = enc_oneshot(&enc, img, meta, policy)
1750 .map_err(|e| fail(CHECK, format!("[{name}] encode {ori:?}: {e}")))?;
1751 let (px, decoded) = dec_oneshot(&dec, &bytes)
1752 .map_err(|e| fail(CHECK, format!("[{name}] decode {ori:?}: {e}")))?;
1753 if render(&px, decoded.orientation) != want {
1754 return Err(fail(
1755 CHECK,
1756 format!(
1757 "[{name}] orientation {ori:?}: displayed image not preserved (decoded orientation = {:?}; \
1758 a reader applying the tag would show loss, or a double-rotation if the codec also baked it)",
1759 decoded.orientation
1760 ),
1761 ));
1762 }
1763 }
1764 }
1765 Ok(())
1766}
1767
1768fn hdr_envelope() -> (ContentLightLevel, MasteringDisplay) {
1775 (
1776 ContentLightLevel::new(1000, 400),
1777 MasteringDisplay::HDR10_REFERENCE,
1778 )
1779}
1780
1781fn hdr_close(got: f32, want: f32) -> bool {
1785 let tol = if want.abs() < 1.0 {
1786 2e-5
1787 } else {
1788 want.abs() * 1e-5
1789 };
1790 (got - want).abs() <= tol
1791}
1792
1793fn mastering_display_matches(got: &MasteringDisplay, want: &MasteringDisplay) -> bool {
1794 got.primaries_xy
1795 .iter()
1796 .zip(&want.primaries_xy)
1797 .all(|(g, w)| hdr_close(g[0], w[0]) && hdr_close(g[1], w[1]))
1798 && hdr_close(got.white_point_xy[0], want.white_point_xy[0])
1799 && hdr_close(got.white_point_xy[1], want.white_point_xy[1])
1800 && hdr_close(got.max_luminance, want.max_luminance)
1801 && hdr_close(got.min_luminance, want.min_luminance)
1802}
1803
1804pub fn check_native_hdr_roundtrip<E, D>(enc: E, dec: D) -> Conformance
1833where
1834 E: EncoderConfig,
1835 D: DecoderConfig,
1836 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
1837{
1838 const CHECK: &str = "native_hdr_roundtrip";
1839 let (ec, dc) = (E::capabilities(), D::capabilities());
1840 if !ec.hdr() || !dc.hdr() {
1841 return Ok(()); }
1843 let sixteen = ec.native_16bit() && dc.native_16bit();
1844 let (cll, md) = hdr_envelope();
1845 let cases = [
1846 (
1847 "PQ",
1848 Cicp::BT2100_PQ,
1849 PixelDescriptor::RGB16_BT2100_PQ,
1850 TransferFunction::Pq,
1851 ),
1852 (
1853 "HLG",
1854 Cicp::BT2100_HLG,
1855 PixelDescriptor::RGB16_BT2100_HLG,
1856 TransferFunction::Hlg,
1857 ),
1858 ];
1859 for (name, cicp, desc16, transfer) in cases {
1860 let img = if sixteen {
1861 TestImage::rgb16_gradient(20, 14, desc16)
1862 } else {
1863 TestImage::rgb8_gradient(20, 14)
1864 };
1865 let meta = Metadata::none()
1866 .with_cicp(cicp)
1867 .with_content_light_level(cll)
1868 .with_mastering_display(md);
1869 let bytes = enc_oneshot(&enc, &img, meta, MetadataPolicy::PreserveExact)
1870 .map_err(|e| fail(CHECK, format!("[{name}] encode: {e}")))?;
1871 let out =
1872 dec_output(&dec, &bytes).map_err(|e| fail(CHECK, format!("[{name}] decode: {e}")))?;
1873
1874 let got_px = grab(out.pixels());
1875 let want_px = img.pixels();
1876 if (got_px.width, got_px.rows) != (want_px.width, want_px.rows)
1877 || got_px.bytes != want_px.bytes
1878 {
1879 return Err(fail(
1880 CHECK,
1881 format!(
1882 "[{name}] decoded {}-bit pixels differ from the input",
1883 if sixteen { 16 } else { 8 }
1884 ),
1885 ));
1886 }
1887 let desc = got_px.desc;
1888 if desc.transfer() != transfer {
1889 return Err(fail(
1890 CHECK,
1891 format!(
1892 "[{name}] the decoded buffer is labelled {:?}, not {transfer:?} — the descriptor \
1893 must describe the current pixels; stamp it from the file's CICP",
1894 desc.transfer()
1895 ),
1896 ));
1897 }
1898 if sixteen && desc != desc16 {
1899 return Err(fail(
1900 CHECK,
1901 format!("[{name}] 16-bit decode descriptor is {desc:?}, expected {desc16:?}"),
1902 ));
1903 }
1904
1905 let got = out.metadata();
1906 if ec.cicp() && dc.cicp() && got.cicp != Some(cicp) {
1907 return Err(fail(
1908 CHECK,
1909 format!(
1910 "[{name}] CICP {cicp:?} did not survive PreserveExact (decoded {:?})",
1911 got.cicp
1912 ),
1913 ));
1914 }
1915 match got.content_light_level {
1916 Some(c) if c == cll => {}
1917 other => {
1918 return Err(fail(
1919 CHECK,
1920 format!(
1921 "[{name}] content_light_level {cll:?} did not survive PreserveExact \
1922 (decoded {other:?}) — hdr is declared on both ends, so the HDR envelope \
1923 must reach the encoder and come back"
1924 ),
1925 ));
1926 }
1927 }
1928 match got.mastering_display {
1929 Some(m) if mastering_display_matches(&m, &md) => {}
1930 other => {
1931 return Err(fail(
1932 CHECK,
1933 format!(
1934 "[{name}] mastering_display {md:?} did not survive PreserveExact within \
1935 container precision (decoded {other:?})"
1936 ),
1937 ));
1938 }
1939 }
1940 }
1941 Ok(())
1942}
1943
1944#[derive(Clone, Copy)]
1950struct GainMapCase {
1951 name: &'static str,
1952 channels: u8,
1953 backward: bool,
1955}
1956
1957const GAIN_MAP_CASES: [GainMapCase; 3] = [
1961 GainMapCase {
1962 name: "1ch forward",
1963 channels: 1,
1964 backward: false,
1965 },
1966 GainMapCase {
1967 name: "3ch forward",
1968 channels: 3,
1969 backward: false,
1970 },
1971 GainMapCase {
1972 name: "1ch backward",
1973 channels: 1,
1974 backward: true,
1975 },
1976];
1977
1978const GAIN_MAP_W: u32 = 10;
1981const GAIN_MAP_H: u32 = 7;
1982
1983fn gain_map_params(case: GainMapCase) -> GainMapParams {
1989 let ch = |i: usize| GainMapChannel {
1990 min: -1.0 + i as f64 * 0.25,
1991 max: 2.0 + i as f64 * 0.5,
1992 gamma: 1.0 + i as f64 * 0.5,
1993 base_offset: 1.0 / 64.0,
1994 alternate_offset: 1.0 / 32.0,
1995 };
1996 let mut p = GainMapParams::default();
1997 p.channels = if case.channels == 1 {
1998 [ch(0); 3]
1999 } else {
2000 [ch(0), ch(1), ch(2)]
2001 };
2002 if case.backward {
2005 p.base_hdr_headroom = 2.0;
2006 p.alternate_hdr_headroom = 0.0;
2007 p.backward_direction = true;
2008 } else {
2009 p.base_hdr_headroom = 0.0;
2010 p.alternate_hdr_headroom = 2.0;
2011 }
2012 p
2013}
2014
2015fn gain_map_pixels(case: GainMapCase) -> Pixels {
2019 let c = case.channels as usize;
2020 let mut bytes = vec![0u8; GAIN_MAP_W as usize * GAIN_MAP_H as usize * c];
2021 for y in 0..GAIN_MAP_H as usize {
2022 for x in 0..GAIN_MAP_W as usize {
2023 for k in 0..c {
2024 bytes[(y * GAIN_MAP_W as usize + x) * c + k] =
2025 (16 + x * 13 + y * 17 + k * 40) as u8;
2026 }
2027 }
2028 }
2029 Pixels {
2030 width: GAIN_MAP_W,
2031 rows: GAIN_MAP_H,
2032 desc: if c == 1 {
2033 PixelDescriptor::GRAY8
2034 } else {
2035 PixelDescriptor::RGB8
2036 },
2037 bytes,
2038 }
2039}
2040
2041const GAIN_MAP_ALTERNATE_CICP: Cicp = Cicp::BT2100_PQ;
2043
2044fn gain_map_fixture(case: GainMapCase) -> DecodedGainMap {
2047 let px = gain_map_pixels(case);
2048 let buf = PixelBuffer::from_vec(px.bytes, px.width, px.rows, px.desc)
2049 .expect("gain-map fixture dimensions are valid");
2050 let info = GainMapInfo::new(gain_map_params(case), GAIN_MAP_W, GAIN_MAP_H, case.channels)
2051 .with_alternate_cicp(GAIN_MAP_ALTERNATE_CICP);
2052 DecodedGainMap::new(buf, info)
2053}
2054
2055fn foreign_format<E: EncoderConfig>() -> ImageFormat {
2058 [ImageFormat::Bmp, ImageFormat::Pnm, ImageFormat::Qoi]
2059 .into_iter()
2060 .find(|f| *f != E::format())
2061 .expect("three candidates cannot all equal one format")
2062}
2063
2064fn enc_with_gain_map<E>(cfg: &E, img: &TestImage, gm: DecodedGainMap) -> Result<Vec<u8>, String>
2066where
2067 E: EncoderConfig,
2068 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2069{
2070 let enc = cfg
2071 .clone()
2072 .job()
2073 .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
2074 .with_gain_map_pixels(gm)
2075 .map_err(|e| format!("with_gain_map_pixels: {e}"))?
2076 .encoder()
2077 .map_err(|e| e.to_string())?;
2078 Ok(enc
2079 .encode(img.as_slice())
2080 .map_err(|e| e.to_string())?
2081 .into_vec())
2082}
2083
2084fn enc_push_rows_with_gain_map<E>(
2088 cfg: &E,
2089 img: &TestImage,
2090 gm: DecodedGainMap,
2091) -> Result<Vec<u8>, String>
2092where
2093 E: EncoderConfig,
2094 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2095{
2096 let mut enc = cfg
2097 .clone()
2098 .job()
2099 .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
2100 .with_gain_map_pixels(gm)
2101 .map_err(|e| format!("with_gain_map_pixels: {e}"))?
2102 .encoder()
2103 .map_err(|e| e.to_string())?;
2104 let strip = enc.preferred_strip_height().max(1);
2105 let mut y = 0;
2106 while y < img.height {
2107 let h = strip.min(img.height - y);
2108 enc.push_rows(img.strip(y, h)).map_err(|e| e.to_string())?;
2109 y += h;
2110 }
2111 Ok(enc.finish().map_err(|e| e.to_string())?.into_vec())
2112}
2113
2114fn dec_render<D: DecoderConfig>(
2116 cfg: &D,
2117 bytes: &[u8],
2118 render: GainMapRender,
2119) -> Result<DecodeOutput, D::Error> {
2120 cfg.clone()
2121 .job()
2122 .with_gain_map_render(render)
2123 .decoder(Cow::Borrowed(bytes), &[])?
2124 .decode()
2125}
2126
2127struct GainMapObserved {
2129 pixels: Pixels,
2130 info: GainMapInfo,
2131}
2132
2133impl GainMapObserved {
2134 fn of(gm: &DecodedGainMap) -> Self {
2135 Self {
2136 pixels: grab(gm.pixels.as_slice()),
2137 info: gm.metadata.clone(),
2138 }
2139 }
2140}
2141
2142fn mean_abs_error(a: &[u8], b: &[u8]) -> f64 {
2144 if a.is_empty() || a.len() != b.len() {
2145 return f64::INFINITY;
2146 }
2147 let sum: u64 = a
2148 .iter()
2149 .zip(b)
2150 .map(|(x, y)| u64::from(x.abs_diff(*y)))
2151 .sum();
2152 sum as f64 / a.len() as f64
2153}
2154
2155const LOSSY_GAIN_MAP_MAE_BOUND: f64 = 24.0;
2162
2163fn compare_gain_map(
2167 got: &GainMapObserved,
2168 want_px: &Pixels,
2169 want_info: &GainMapInfo,
2170 lossless: bool,
2171 what: &str,
2172) -> Result<(), String> {
2173 if (got.pixels.width, got.pixels.rows) != (want_px.width, want_px.rows) {
2174 return Err(format!(
2175 "{what}: gain-map geometry is {}x{}, expected {}x{}",
2176 got.pixels.width, got.pixels.rows, want_px.width, want_px.rows
2177 ));
2178 }
2179 let got_ch = got.pixels.desc.channels() as u8;
2180 if got_ch != want_info.channels {
2181 return Err(format!(
2182 "{what}: gain map has {got_ch} channels ({:?}), expected {}",
2183 got.pixels.desc, want_info.channels
2184 ));
2185 }
2186 if got.info.width != want_px.width || got.info.height != want_px.rows {
2187 return Err(format!(
2188 "{what}: GainMapInfo says {}x{} but the pixels are {}x{}",
2189 got.info.width, got.info.height, want_px.width, want_px.rows
2190 ));
2191 }
2192 if got.info.channels != want_info.channels {
2193 return Err(format!(
2194 "{what}: GainMapInfo.channels is {}, expected {}",
2195 got.info.channels, want_info.channels
2196 ));
2197 }
2198 if got.info.params != want_info.params {
2199 return Err(format!(
2200 "{what}: ISO 21496-1 params did not survive exactly (all fixture values are \
2201 dyadic, so this is a serialization/parse defect, not rounding):\n got {:?}\n want {:?}",
2202 got.info.params, want_info.params
2203 ));
2204 }
2205 if got.info.alternate_cicp != want_info.alternate_cicp {
2206 return Err(format!(
2207 "{what}: alternate_cicp is {:?}, expected {:?}",
2208 got.info.alternate_cicp, want_info.alternate_cicp
2209 ));
2210 }
2211 if got.pixels.desc.bytes_per_pixel() != want_px.desc.bytes_per_pixel() {
2212 return Err(format!(
2213 "{what}: gain map came back as {:?}, expected an 8-bit {}-channel layout",
2214 got.pixels.desc, want_info.channels
2215 ));
2216 }
2217 if lossless {
2218 if got.pixels.bytes != want_px.bytes {
2219 return Err(format!(
2220 "{what}: gain-map pixels differ from the input on a lossless encoder"
2221 ));
2222 }
2223 } else {
2224 let mae = mean_abs_error(&got.pixels.bytes, &want_px.bytes);
2225 if mae > LOSSY_GAIN_MAP_MAE_BOUND {
2226 return Err(format!(
2227 "{what}: gain-map pixels came back with mean abs error {mae:.1} (bound \
2228 {LOSSY_GAIN_MAP_MAE_BOUND}) — blank, garbled, or the wrong map"
2229 ));
2230 }
2231 }
2232 Ok(())
2233}
2234
2235pub fn check_gain_map_roundtrip<E, D>(enc: E, dec: D) -> Conformance
2285where
2286 E: EncoderConfig,
2287 D: DecoderConfig,
2288 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2289{
2290 const CHECK: &str = "gain_map_roundtrip";
2291 let (ec, dc) = (E::capabilities(), D::capabilities());
2292 let mut v: Vec<String> = Vec::new();
2293
2294 if !ec.gain_map() {
2296 let case = GAIN_MAP_CASES[0];
2297 match enc
2298 .clone()
2299 .job()
2300 .with_gain_map_pixels(gain_map_fixture(case))
2301 {
2302 Ok(_) => v.push(
2303 "encoder does not declare gain_map, yet with_gain_map_pixels succeeded (hidden \
2304 capability, or a silently dropped gain map)"
2305 .into(),
2306 ),
2307 Err(e) if e.unsupported_operation() == Some(&UnsupportedOperation::GainMapEncode) => {}
2308 Err(e) => v.push(format!(
2309 "encoder does not declare gain_map: with_gain_map_pixels must fail with \
2310 UnsupportedOperation::GainMapEncode, got: {e}"
2311 )),
2312 }
2313 let src = GainMapSource::new(
2314 vec![0u8; 16],
2315 E::format(),
2316 GainMapInfo::new(gain_map_params(case), GAIN_MAP_W, GAIN_MAP_H, 1),
2317 );
2318 match enc.clone().job().with_gain_map_encoded(src) {
2319 Ok(_) => v.push(
2320 "encoder does not declare gain_map, yet with_gain_map_encoded succeeded".into(),
2321 ),
2322 Err(e) if e.unsupported_operation() == Some(&UnsupportedOperation::GainMapEncode) => {}
2323 Err(e) => v.push(format!(
2324 "encoder does not declare gain_map: with_gain_map_encoded must fail with \
2325 UnsupportedOperation::GainMapEncode, got: {e}"
2326 )),
2327 }
2328 return if v.is_empty() {
2329 Ok(())
2330 } else {
2331 Err(fail(CHECK, v.join("; ")))
2332 };
2333 }
2334
2335 let base = TestImage::rgb8_gradient(20, 14);
2337 let lossless = enc.is_lossless() == Some(true);
2338
2339 let foreign = GainMapSource::new(
2341 vec![0u8; 16],
2342 foreign_format::<E>(),
2343 GainMapInfo::new(
2344 gain_map_params(GAIN_MAP_CASES[0]),
2345 GAIN_MAP_W,
2346 GAIN_MAP_H,
2347 1,
2348 ),
2349 );
2350 if enc.clone().job().with_gain_map_encoded(foreign).is_ok() {
2351 v.push(format!(
2352 "with_gain_map_encoded accepted a gain map in a foreign format ({:?}) — the \
2353 contract is to reject a format mismatch so the caller decodes to pixels",
2354 foreign_format::<E>()
2355 ));
2356 }
2357
2358 for case in GAIN_MAP_CASES {
2359 let name = case.name;
2360 let want_px = gain_map_pixels(case);
2361 let want_info = gain_map_fixture(case).metadata;
2362
2363 let bytes = match enc_with_gain_map(&enc, &base, gain_map_fixture(case)) {
2364 Ok(b) => b,
2365 Err(e) => {
2366 v.push(format!("[{name}] encode with gain map: {e}"));
2367 continue;
2368 }
2369 };
2370
2371 if !dc.gain_map() {
2372 match dec_render(&dec, &bytes, GainMapRender::Components) {
2374 Ok(out) if out.extras::<DecodedGainMap>().is_some() => v.push(format!(
2375 "[{name}] decoder does not declare gain_map, yet Components surfaced a \
2376 DecodedGainMap (hidden capability)"
2377 )),
2378 Ok(_) => {}
2379 Err(e) if e.unsupported_operation().is_some() => {}
2380 Err(e) => v.push(format!(
2381 "[{name}] decoder does not declare gain_map: a Components decode must \
2382 decode the base or fail with UnsupportedOperation, got: {e}"
2383 )),
2384 }
2385 continue;
2386 }
2387
2388 match dec.clone().job().probe(&bytes) {
2390 Err(e) => v.push(format!("[{name}] probe: {e}")),
2391 Ok(info) => {
2392 if !info.supplements.gain_map {
2393 v.push(format!(
2394 "[{name}] probe: supplements.gain_map is false on a file with a gain map"
2395 ));
2396 }
2397 match &info.gain_map {
2398 GainMapPresence::Absent => v.push(format!(
2399 "[{name}] probe: gain_map presence is Absent on a file with a gain map"
2400 )),
2401 GainMapPresence::Available(i) => {
2402 if (i.width, i.height, i.channels)
2403 != (GAIN_MAP_W, GAIN_MAP_H, case.channels)
2404 {
2405 v.push(format!(
2406 "[{name}] probe: GainMapInfo is {}x{}x{}, expected {}x{}x{}",
2407 i.width,
2408 i.height,
2409 i.channels,
2410 GAIN_MAP_W,
2411 GAIN_MAP_H,
2412 case.channels
2413 ));
2414 }
2415 if i.params != want_info.params {
2416 v.push(format!(
2417 "[{name}] probe: ISO 21496-1 params differ from the input:\n got {:?}\n want {:?}",
2418 i.params, want_info.params
2419 ));
2420 }
2421 }
2422 _ => {} }
2424 }
2425 }
2426
2427 match dec_render(&dec, &bytes, GainMapRender::BaseOnly) {
2429 Err(e) => v.push(format!("[{name}] BaseOnly decode: {e}")),
2430 Ok(out) => {
2431 if out.extras::<DecodedGainMap>().is_some() {
2432 v.push(format!(
2433 "[{name}] BaseOnly (the default) surfaced a DecodedGainMap — gain-map \
2434 decode is opt-in"
2435 ));
2436 }
2437 let px = grab(out.pixels());
2438 if (px.width, px.rows) != (base.width, base.height) {
2439 v.push(format!(
2440 "[{name}] BaseOnly decoded {}x{}, expected the {}x{} base",
2441 px.width, px.rows, base.width, base.height
2442 ));
2443 } else if lossless && px.bytes != base.pixels().bytes {
2444 v.push(format!(
2445 "[{name}] BaseOnly base pixels differ from the input on a lossless \
2446 encoder — attaching a gain map must not disturb the base"
2447 ));
2448 }
2449 }
2450 }
2451
2452 let first = match dec_render(&dec, &bytes, GainMapRender::Components) {
2454 Err(e) => {
2455 v.push(format!("[{name}] Components decode: {e}"));
2456 None
2457 }
2458 Ok(mut out) => match out.take_extras::<DecodedGainMap>() {
2459 None => {
2460 v.push(format!(
2461 "[{name}] decoder declares gain_map, but Components surfaced no \
2462 DecodedGainMap"
2463 ));
2464 None
2465 }
2466 Some(gm) => {
2467 let obs = GainMapObserved::of(&gm);
2468 if let Err(e) =
2469 compare_gain_map(&obs, &want_px, &want_info, lossless, "Components")
2470 {
2471 v.push(format!("[{name}] {e}"));
2472 }
2473 Some((gm, obs))
2474 }
2475 },
2476 };
2477
2478 if let Some((_, first_obs)) = &first {
2480 match dec
2481 .clone()
2482 .job()
2483 .with_extract_gain_map(true)
2484 .decoder(Cow::Borrowed(&bytes), &[])
2485 .and_then(|d| d.decode())
2486 {
2487 Err(e) => v.push(format!("[{name}] with_extract_gain_map(true) decode: {e}")),
2488 Ok(out) => match out.extras::<DecodedGainMap>() {
2489 None => v.push(format!(
2490 "[{name}] with_extract_gain_map(true) surfaced no DecodedGainMap, but \
2491 Components did — the two must be equivalent"
2492 )),
2493 Some(gm) => {
2494 let obs = GainMapObserved::of(gm);
2495 if obs.pixels != first_obs.pixels || obs.info != first_obs.info {
2496 v.push(format!(
2497 "[{name}] with_extract_gain_map(true) and Components surfaced \
2498 different gain maps"
2499 ));
2500 }
2501 }
2502 },
2503 }
2504 }
2505
2506 let recon = GainMapRender::ReconstructHdr {
2509 target_headroom: None,
2510 };
2511 match dec_render(&dec, &bytes, recon) {
2512 Ok(out) => {
2513 let desc = out.pixels().descriptor();
2514 let hdr_labelled = matches!(
2515 desc.transfer(),
2516 TransferFunction::Pq | TransferFunction::Hlg | TransferFunction::Linear
2517 );
2518 if dc.reconstructs_hdr() {
2519 if !hdr_labelled {
2520 v.push(format!(
2521 "[{name}] reconstructs_hdr is declared, but ReconstructHdr produced a \
2522 buffer labelled {:?} — the output must be an HDR pixel format",
2523 desc.transfer()
2524 ));
2525 }
2526 let sc = &out.info().source_color;
2527 if sc.mastering_display.is_none() || sc.content_light_level.is_none() {
2528 v.push(format!(
2529 "[{name}] reconstructs_hdr: the reconstructed output must carry the \
2530 luminance envelope (mastering_display + content_light_level) on \
2531 source_color — without it a native-HDR transcode loses it"
2532 ));
2533 }
2534 } else if matches!(
2535 desc.transfer(),
2536 TransferFunction::Pq | TransferFunction::Hlg
2537 ) {
2538 v.push(format!(
2539 "[{name}] reconstructs_hdr is NOT declared, yet ReconstructHdr returned a \
2540 buffer labelled {:?} — an SDR base must not be relabelled HDR; surface \
2541 Components or fail with UnsupportedOperation",
2542 desc.transfer()
2543 ));
2544 }
2545 }
2546 Err(e) if dc.reconstructs_hdr() => v.push(format!(
2547 "[{name}] reconstructs_hdr is declared, but ReconstructHdr failed: {e}"
2548 )),
2549 Err(e) if e.unsupported_operation().is_some() => {}
2550 Err(e) => v.push(format!(
2551 "[{name}] ReconstructHdr without reconstructs_hdr must surface Components or \
2552 fail with UnsupportedOperation, got: {e}"
2553 )),
2554 }
2555
2556 if let Some((gm, first_obs)) = first {
2558 let base2 = TestImage::rgb8_gradient(20, 14);
2559 match enc_with_gain_map(&enc, &base2, gm).and_then(|b| {
2560 dec_render(&dec, &b, GainMapRender::Components).map_err(|e| e.to_string())
2561 }) {
2562 Err(e) => v.push(format!(
2563 "[{name}] transcode (decode → with_gain_map_pixels): {e}"
2564 )),
2565 Ok(out) => match out.extras::<DecodedGainMap>() {
2566 None => v.push(format!(
2567 "[{name}] transcode: the re-encoded file surfaced no DecodedGainMap"
2568 )),
2569 Some(gm2) => {
2570 let obs2 = GainMapObserved::of(gm2);
2571 if let Err(e) = compare_gain_map(
2572 &obs2,
2573 &first_obs.pixels,
2574 &first_obs.info,
2575 lossless,
2576 "transcode generation 2",
2577 ) {
2578 v.push(format!("[{name}] {e}"));
2579 }
2580 }
2581 },
2582 }
2583 }
2584
2585 if ec.push_rows() {
2587 match enc_push_rows_with_gain_map(&enc, &base, gain_map_fixture(case)).and_then(|b| {
2588 dec_render(&dec, &b, GainMapRender::Components).map_err(|e| e.to_string())
2589 }) {
2590 Err(e) => v.push(format!("[{name}] push_rows encode with gain map: {e}")),
2591 Ok(out) => match out.extras::<DecodedGainMap>() {
2592 None => v.push(format!(
2593 "[{name}] push_rows: the incrementally encoded file surfaced no \
2594 DecodedGainMap — the gain map was lost on the push_rows path"
2595 )),
2596 Some(gm) => {
2597 if let Err(e) = compare_gain_map(
2598 &GainMapObserved::of(gm),
2599 &want_px,
2600 &want_info,
2601 lossless,
2602 "push_rows",
2603 ) {
2604 v.push(format!("[{name}] {e}"));
2605 }
2606 }
2607 },
2608 }
2609 }
2610 }
2611
2612 if v.is_empty() {
2613 Ok(())
2614 } else {
2615 Err(fail(CHECK, v.join("; ")))
2616 }
2617}
2618
2619fn color_context_metadata() -> Metadata {
2628 Metadata::none()
2629 .with_icc(fixtures::sample_icc())
2630 .with_cicp(Cicp::SRGB)
2631}
2632
2633fn dec_output<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<DecodeOutput, String> {
2634 cfg.clone()
2635 .job()
2636 .decoder(Cow::Borrowed(bytes), &[])
2637 .map_err(|e| e.to_string())?
2638 .decode()
2639 .map_err(|e| e.to_string())
2640}
2641
2642fn describe_ctx(ctx: Option<&ColorContext>) -> String {
2643 match ctx {
2644 None => "no ColorContext".into(),
2645 Some(c) => format!(
2646 "ColorContext {{ icc: {}, cicp: {:?} }}",
2647 c.icc
2648 .as_ref()
2649 .map_or("none".to_string(), |i| format!("{} bytes", i.len())),
2650 c.cicp
2651 ),
2652 }
2653}
2654
2655fn same_ctx(a: Option<&Arc<ColorContext>>, b: Option<&Arc<ColorContext>>) -> bool {
2656 a.map(|x| &**x) == b.map(|x| &**x)
2657}
2658
2659fn validate_buffer_context(
2670 ctx: Option<&ColorContext>,
2671 desc: PixelDescriptor,
2672) -> Result<(), String> {
2673 let Some(ctx) = ctx else {
2674 return Ok(());
2675 };
2676 if ctx.icc.is_none() && ctx.cicp.is_none() {
2677 return Err(
2678 "an attached ColorContext carries neither ICC nor CICP — attach nothing rather \
2679 than an empty description"
2680 .into(),
2681 );
2682 }
2683 if let Some(icc) = &ctx.icc {
2684 let model = desc.color_model();
2685 match zenpixels::icc::profile_color_space(icc) {
2686 Some(class) if class == model => {}
2687 Some(class) => {
2688 return Err(format!(
2689 "an ICC of device class {class:?} is attached to a {model:?} buffer ({desc:?}) — \
2690 an ICC only rides a layout its class describes (RGB ↔ Rgb/Rgba/Bgra, GRAY ↔ \
2691 Gray/GrayAlpha); derive its CICP and carry that alone, or emit a layout the \
2692 profile describes"
2693 ));
2694 }
2695 None => {
2696 return Err(format!(
2697 "an ICC whose device class (header bytes 16..20) is unreadable is attached to a \
2698 {model:?} buffer — it is not class-valid for any layout; do not attach it"
2699 ));
2700 }
2701 }
2702 }
2703 Ok(())
2704}
2705
2706pub fn check_color_context_consistency<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
2728where
2729 E: EncoderConfig,
2730 D: DecoderConfig,
2731 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2732 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
2733{
2734 const CHECK: &str = "color_context_consistency";
2735 let meta = color_context_metadata();
2736 let bytes = enc_oneshot(&enc, img, meta.clone(), MetadataPolicy::PreserveExact)
2737 .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
2738 let out = dec_output(&dec, &bytes).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
2739 let desc = out.pixels().descriptor();
2740 let oneshot = out.into_buffer().color_context().cloned();
2741 validate_buffer_context(oneshot.as_deref(), desc)
2742 .map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?;
2743
2744 if D::capabilities().streaming() {
2745 let mut sd = dec
2746 .clone()
2747 .job()
2748 .streaming_decoder(Cow::Borrowed(&bytes), &[])
2749 .map_err(|e| fail(CHECK, format!("streaming decoder: {e}")))?;
2750 while let Some((y, strip)) = sd
2751 .next_batch()
2752 .map_err(|e| fail(CHECK, format!("streaming next_batch: {e}")))?
2753 {
2754 validate_buffer_context(strip.color_context().map(|c| &**c), strip.descriptor())
2755 .map_err(|e| fail(CHECK, format!("streaming strip at y={y}: {e}")))?;
2756 if !same_ctx(strip.color_context(), oneshot.as_ref()) {
2757 return Err(fail(
2758 CHECK,
2759 format!(
2760 "streaming strip at y={y} carries {} but the one-shot decode carries {} — \
2761 strip/scratch buffers must re-attach the context at emission",
2762 describe_ctx(strip.color_context().map(|c| &**c)),
2763 describe_ctx(oneshot.as_deref())
2764 ),
2765 ));
2766 }
2767 }
2768 }
2769
2770 if E::capabilities().animation() && D::capabilities().animation() {
2771 let frames = [
2772 TestImage::rgba8_gradient_seeded(img.width, img.height, 0),
2773 TestImage::rgba8_gradient_seeded(img.width, img.height, 90),
2774 ];
2775 let anim = encode_animation(&enc, &frames, meta)
2776 .map_err(|e| fail(CHECK, format!("animation encode: {e}")))?;
2777 let primary = dec_output(&dec, &anim)
2778 .map_err(|e| fail(CHECK, format!("one-shot decode of the animation: {e}")))?;
2779 let pdesc = primary.pixels().descriptor();
2780 let primary_ctx = primary.into_buffer().color_context().cloned();
2781 validate_buffer_context(primary_ctx.as_deref(), pdesc)
2782 .map_err(|e| fail(CHECK, format!("one-shot decode of the animation: {e}")))?;
2783
2784 let mismatch = |path: &str, i: u32, got: Option<&Arc<ColorContext>>| {
2785 fail(
2786 CHECK,
2787 format!(
2788 "{path} frame {i} carries {} but the one-shot decode of the same file carries {} — \
2789 every frame must carry the context its pixels are described by",
2790 describe_ctx(got.map(|c| &**c)),
2791 describe_ctx(primary_ctx.as_deref())
2792 ),
2793 )
2794 };
2795
2796 let mut d = dec
2797 .clone()
2798 .job()
2799 .animation_frame_decoder(Cow::Borrowed(&anim), &[])
2800 .map_err(|e| fail(CHECK, format!("animation decoder: {e}")))?;
2801 while let Some(frame) = d
2802 .render_next_frame(None)
2803 .map_err(|e| fail(CHECK, format!("render_next_frame: {e}")))?
2804 {
2805 let i = frame.frame_index();
2806 let px = frame.pixels();
2807 validate_buffer_context(px.color_context().map(|c| &**c), px.descriptor())
2808 .map_err(|e| fail(CHECK, format!("render_next_frame frame {i}: {e}")))?;
2809 if !same_ctx(px.color_context(), primary_ctx.as_ref()) {
2810 return Err(mismatch("render_next_frame", i, px.color_context()));
2811 }
2812 }
2813
2814 let mut d = dec
2815 .clone()
2816 .job()
2817 .animation_frame_decoder(Cow::Borrowed(&anim), &[])
2818 .map_err(|e| fail(CHECK, format!("animation decoder: {e}")))?;
2819 while let Some(frame) = d
2820 .render_next_frame_owned(None)
2821 .map_err(|e| fail(CHECK, format!("render_next_frame_owned: {e}")))?
2822 {
2823 let i = frame.frame_index();
2824 let px = frame.pixels();
2825 validate_buffer_context(px.color_context().map(|c| &**c), px.descriptor())
2826 .map_err(|e| fail(CHECK, format!("render_next_frame_owned frame {i}: {e}")))?;
2827 if !same_ctx(px.color_context(), primary_ctx.as_ref()) {
2828 return Err(mismatch("render_next_frame_owned", i, px.color_context()));
2829 }
2830 }
2831 }
2832 Ok(())
2833}
2834
2835pub fn check_color_context_attached<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
2853where
2854 E: EncoderConfig,
2855 D: DecoderConfig,
2856 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2857{
2858 const CHECK: &str = "color_context_attached";
2859 let bytes = enc_oneshot(
2860 &enc,
2861 img,
2862 color_context_metadata(),
2863 MetadataPolicy::PreserveExact,
2864 )
2865 .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
2866 let out = dec_output(&dec, &bytes).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
2867 let src = out.info().source_color.clone();
2868 let desc = out.pixels().descriptor();
2869 let ctx = out.into_buffer().color_context().cloned();
2870
2871 if src.icc_profile.is_none() && src.cicp.is_none() {
2872 return Ok(()); }
2874 let Some(ctx) = ctx else {
2875 return Err(fail(
2876 CHECK,
2877 format!(
2878 "the decoder read colour back into ImageInfo.source_color (icc: {}, cicp: {:?}) \
2879 but attached no ColorContext to the output buffer — attach \
2880 SourceColor::to_color_context(), class-gated, after the descriptor is final",
2881 src.icc_profile
2882 .as_ref()
2883 .map_or("none".to_string(), |i| format!("{} bytes", i.len())),
2884 src.cicp
2885 ),
2886 ));
2887 };
2888 validate_buffer_context(Some(&ctx), desc)
2889 .map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?;
2890
2891 let src_icc_class_valid = src
2892 .icc_profile
2893 .as_deref()
2894 .is_some_and(|icc| zenpixels::icc::profile_color_space(icc) == Some(desc.color_model()));
2895 match src.color_authority {
2896 ColorAuthority::Icc if src_icc_class_valid => {
2897 if ctx.icc.as_deref() != src.icc_profile.as_deref() {
2898 return Err(fail(
2899 CHECK,
2900 format!(
2901 "ICC is the authority and the source profile is class-valid for the {:?} \
2902 output, so the buffer context must carry those ICC bytes as-is; it carries {}",
2903 desc.color_model(),
2904 describe_ctx(Some(&ctx))
2905 ),
2906 ));
2907 }
2908 }
2909 ColorAuthority::Cicp if src.cicp.is_some() => {
2910 if ctx.cicp != src.cicp {
2911 return Err(fail(
2912 CHECK,
2913 format!(
2914 "CICP is the authority ({:?}) but the buffer context carries {}",
2915 src.cicp,
2916 describe_ctx(Some(&ctx))
2917 ),
2918 ));
2919 }
2920 }
2921 ColorAuthority::Icc | ColorAuthority::Cicp => {}
2924 }
2925
2926 if D::capabilities().streaming() {
2927 let mut sd = dec
2928 .clone()
2929 .job()
2930 .streaming_decoder(Cow::Borrowed(&bytes), &[])
2931 .map_err(|e| fail(CHECK, format!("streaming decoder: {e}")))?;
2932 let first = sd
2933 .next_batch()
2934 .map_err(|e| fail(CHECK, format!("streaming next_batch: {e}")))?;
2935 if let Some((y, strip)) = first
2936 && strip.color_context().is_none()
2937 {
2938 return Err(fail(
2939 CHECK,
2940 format!(
2941 "the one-shot buffer carries a ColorContext but the streaming strip at y={y} \
2942 carries none — re-attach the context on every emitted strip"
2943 ),
2944 ));
2945 }
2946 }
2947 Ok(())
2948}
2949
2950pub fn expected_color_authority(
2976 format: ImageFormat,
2977 has_cicp: bool,
2978 has_icc: bool,
2979) -> Option<ColorAuthority> {
2980 use ImageFormat::*;
2981 let cicp_first = if has_cicp {
2982 ColorAuthority::Cicp
2983 } else {
2984 ColorAuthority::Icc
2985 };
2986 Some(match format {
2987 Png | Jxl | Heic | Hdr => cicp_first,
2988 Avif => {
2989 if has_icc {
2990 ColorAuthority::Icc
2991 } else {
2992 cicp_first
2993 }
2994 }
2995 Jpeg | WebP | Gif | Tiff | Bmp | Ico | Pnm | Farbfeld | Qoi | Tga | Dng | Raw | Pdf => {
2996 ColorAuthority::Icc
2997 }
2998 _ => return None,
3001 })
3002}
3003
3004pub fn check_source_color_authority(format: ImageFormat, sc: &SourceColor) -> Conformance {
3018 const CHECK: &str = "source_color_authority";
3019 let Some(want) = expected_color_authority(format, sc.cicp.is_some(), sc.icc_profile.is_some())
3020 else {
3021 return Ok(());
3022 };
3023 if sc.color_authority == want {
3024 return Ok(());
3025 }
3026 Err(fail(
3027 CHECK,
3028 format!(
3029 "{format:?} read cicp: {}, icc: {} — the format spec makes {want:?} authoritative, \
3030 but source_color.color_authority is {:?}",
3031 sc.cicp.map_or("none".to_string(), |c| format!("{c:?}")),
3032 sc.icc_profile
3033 .as_ref()
3034 .map_or("none".to_string(), |i| format!("{} bytes", i.len())),
3035 sc.color_authority
3036 ),
3037 ))
3038}
3039
3040pub fn check_color_authority_spec<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
3049where
3050 E: EncoderConfig,
3051 D: DecoderConfig,
3052 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3053{
3054 const CHECK: &str = "color_authority_spec";
3055 let ec = E::capabilities();
3056 let format = E::format();
3057 let mut mixes: Vec<(&str, Metadata)> = vec![("no colour metadata", Metadata::none())];
3058 if ec.icc() {
3059 mixes.push((
3060 "icc only",
3061 Metadata::none().with_icc(fixtures::sample_icc()),
3062 ));
3063 }
3064 if ec.cicp() {
3065 mixes.push(("cicp only", Metadata::none().with_cicp(Cicp::SRGB)));
3066 }
3067 if ec.icc() && ec.cicp() {
3068 mixes.push((
3069 "icc + cicp",
3070 Metadata::none()
3071 .with_icc(fixtures::sample_icc())
3072 .with_cicp(Cicp::SRGB),
3073 ));
3074 }
3075 let mut v: Vec<String> = Vec::new();
3076 for (name, meta) in mixes {
3077 let bytes = match enc_oneshot(&enc, img, meta, MetadataPolicy::PreserveExact) {
3078 Ok(b) => b,
3079 Err(e) => {
3080 v.push(format!("{name}: encode failed: {e}"));
3081 continue;
3082 }
3083 };
3084 match dec_output(&dec, &bytes) {
3085 Err(e) => v.push(format!("{name}: decode failed: {e}")),
3086 Ok(out) => {
3087 if let Err(f) = check_source_color_authority(format, &out.info().source_color) {
3088 v.push(format!("{name}, decode: {}", f.detail));
3089 }
3090 }
3091 }
3092 match dec.clone().job().probe(&bytes) {
3093 Err(e) => v.push(format!("{name}: probe failed: {e}")),
3094 Ok(info) => {
3095 if let Err(f) = check_source_color_authority(format, &info.source_color) {
3096 v.push(format!("{name}, probe: {}", f.detail));
3097 }
3098 }
3099 }
3100 }
3101 if v.is_empty() {
3102 Ok(())
3103 } else {
3104 Err(fail(CHECK, v.join("; ")))
3105 }
3106}
3107
3108fn classify<T, Er>(name: &str, declared: bool, res: Result<T, Er>, v: &mut Vec<String>)
3114where
3115 Er: std::error::Error + 'static,
3116{
3117 match (declared, res) {
3118 (true, Ok(_)) => {}
3119 (true, Err(e)) => v.push(format!(
3120 "{name}: declared supported, but the operation failed: {e}"
3121 )),
3122 (false, Ok(_)) => v.push(format!(
3123 "{name}: not declared, but the operation succeeded (hidden capability)"
3124 )),
3125 (false, Err(e)) => {
3126 if e.unsupported_operation().is_none() {
3127 v.push(format!(
3128 "{name}: not declared; expected UnsupportedOperation when used, got a different error: {e}"
3129 ));
3130 }
3131 }
3132 }
3133}
3134
3135fn run_push_rows<E>(cfg: &E, img: &TestImage) -> Result<Vec<u8>, E::Error>
3136where
3137 E: EncoderConfig,
3138 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3139{
3140 let mut e = cfg
3141 .clone()
3142 .job()
3143 .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
3144 .encoder()?;
3145 let strip = e.preferred_strip_height().max(1);
3146 let mut y = 0;
3147 while y < img.height {
3148 let h = strip.min(img.height - y);
3149 e.push_rows(img.strip(y, h))?;
3150 y += h;
3151 }
3152 Ok(e.finish()?.into_vec())
3153}
3154
3155fn run_encode_from<E>(cfg: &E, img: &TestImage) -> Result<Vec<u8>, E::Error>
3156where
3157 E: EncoderConfig,
3158 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3159{
3160 let e = cfg
3161 .clone()
3162 .job()
3163 .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
3164 .encoder()?;
3165 let rb = img.row_bytes();
3166 let mut next = 0u32;
3167 let mut src = |_y: u32, mut buf: PixelSliceMut<'_>| -> usize {
3168 if next >= img.height {
3169 return 0;
3170 }
3171 let want = buf.rows().min(img.height - next);
3172 for r in 0..want {
3173 let s = (next + r) as usize * rb;
3174 let dst = buf.row_mut(r);
3175 let n = dst.len().min(rb);
3176 dst[..n].copy_from_slice(&img.data[s..s + n]);
3177 }
3178 next += want;
3179 want as usize
3180 };
3181 Ok(e.encode_from(&mut src)?.into_vec())
3182}
3183
3184fn check_animation_encode_honesty<E>(cfg: &E, img: &TestImage, declared: bool, v: &mut Vec<String>)
3191where
3192 E: EncoderConfig,
3193 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
3194{
3195 match cfg
3196 .clone()
3197 .job()
3198 .with_loop_count(Some(0))
3199 .animation_frame_encoder()
3200 {
3201 Ok(mut a) => {
3202 let frames: Result<(), String> = (|| {
3204 a.push_frame(img.as_slice(), 100, None)
3205 .map_err(|e| e.to_string())?;
3206 a.push_frame(img.as_slice(), 100, None)
3207 .map_err(|e| e.to_string())?;
3208 a.finish(None).map_err(|e| e.to_string())?;
3209 Ok(())
3210 })();
3211 match (declared, frames) {
3212 (true, Ok(())) => {}
3213 (true, Err(e)) => v.push(format!("encode animation: declared supported, but the operation failed: {e}")),
3214 (false, _) => v.push(
3215 "encode animation: not declared, but animation_frame_encoder() succeeded (hidden capability)".into(),
3216 ),
3217 }
3218 }
3219 Err(e) => {
3220 if declared {
3221 v.push(format!("encode animation: declared supported, but animation_frame_encoder() failed: {e}"));
3222 } else if e.unsupported_operation().is_none() {
3223 v.push(format!(
3224 "encode animation: not declared; expected UnsupportedOperation when used, got a different error: {e}"
3225 ));
3226 }
3227 }
3228 }
3229}
3230
3231fn run_streaming<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<(), D::Error> {
3232 let mut sd = cfg
3233 .clone()
3234 .job()
3235 .streaming_decoder(Cow::Borrowed(bytes), &[])?;
3236 while sd.next_batch()?.is_some() {}
3237 Ok(())
3238}
3239
3240fn run_animation_decode<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<(), D::Error> {
3241 let mut ad = cfg
3242 .clone()
3243 .job()
3244 .animation_frame_decoder(Cow::Borrowed(bytes), &[])?;
3245 while ad.render_next_frame(None)?.is_some() {}
3246 Ok(())
3247}
3248
3249pub fn check_capability_honesty<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
3281where
3282 E: EncoderConfig,
3283 D: DecoderConfig,
3284 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3285 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
3286{
3287 const CHECK: &str = "capability_honesty";
3288 let ec = E::capabilities();
3289 let dc = D::capabilities();
3290 let mut v: Vec<String> = Vec::new();
3291
3292 classify(
3294 "encode push_rows",
3295 ec.push_rows(),
3296 run_push_rows(&enc, img),
3297 &mut v,
3298 );
3299 classify(
3300 "encode encode_from",
3301 ec.encode_from(),
3302 run_encode_from(&enc, img),
3303 &mut v,
3304 );
3305 check_animation_encode_honesty(&enc, img, ec.animation(), &mut v);
3306
3307 let toggled = enc.clone().with_lossless(true).is_lossless();
3311 match (ec.lossless(), toggled) {
3312 (true, Some(true)) => {}
3313 (true, other) => v.push(format!(
3314 "encode lossless: declared, but with_lossless(true) gives is_lossless() = {other:?} (expected Some(true))"
3315 )),
3316 (false, None) => {}
3317 (false, other) => v.push(format!(
3318 "encode lossless: not declared, but with_lossless(true) gives is_lossless() = {other:?} (expected None)"
3319 )),
3320 }
3321
3322 match enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact) {
3324 Err(e) => v.push(format!(
3325 "could not produce a canonical encode for decode checks: {e}"
3326 )),
3327 Ok(canonical) => {
3328 classify(
3329 "decode streaming",
3330 dc.streaming(),
3331 run_streaming(&dec, &canonical),
3332 &mut v,
3333 );
3334 classify(
3335 "decode animation",
3336 dc.animation(),
3337 run_animation_decode(&dec, &canonical),
3338 &mut v,
3339 );
3340 if dc.cheap_probe()
3341 && let Err(e) = dec.clone().job().probe(&canonical)
3342 {
3343 v.push(format!(
3344 "decode cheap_probe: declared, but probe() failed: {e}"
3345 ));
3346 }
3347 }
3348 }
3349
3350 check_metadata_channel_honesty(
3357 ec,
3358 dc,
3359 |metadata| {
3360 enc_oneshot(&enc, img, metadata, MetadataPolicy::PreserveExact)
3361 .and_then(|bytes| dec_oneshot(&dec, &bytes))
3362 .map(|(_, metadata)| metadata)
3363 },
3364 &mut v,
3365 );
3366
3367 if ec.native_alpha() && dc.native_alpha() {
3369 let rgba = TestImage::rgba8_gradient(img.width.max(2), img.height.max(2));
3370 match enc_oneshot(&enc, &rgba, Metadata::none(), MetadataPolicy::PreserveExact)
3371 .and_then(|b| dec_oneshot(&dec, &b))
3372 {
3373 Err(e) => v.push(format!(
3374 "native_alpha: declared, but an RGBA8 round trip failed: {e}"
3375 )),
3376 Ok((px, _)) => {
3377 if px != rgba.pixels() {
3378 v.push("native_alpha: declared, but RGBA8 pixels (alpha included) did not round-trip".into());
3379 }
3380 }
3381 }
3382 }
3383
3384 if v.is_empty() {
3385 Ok(())
3386 } else {
3387 Err(fail(CHECK, v.join("; ")))
3388 }
3389}
3390
3391fn check_metadata_channel_honesty(
3392 ec: &zencodec::encode::EncodeCapabilities,
3393 dc: &zencodec::decode::DecodeCapabilities,
3394 mut roundtrip: impl FnMut(Metadata) -> Result<Metadata, String>,
3395 violations: &mut Vec<String>,
3396) {
3397 let channels = [
3398 (
3399 "icc",
3400 ec.icc(),
3401 dc.icc(),
3402 Metadata::none().with_icc(fixtures::sample_icc()),
3403 (|m: &Metadata| m.icc_profile.is_some()) as fn(&Metadata) -> bool,
3404 ),
3405 (
3406 "exif",
3407 ec.exif(),
3408 dc.exif(),
3409 Metadata::none().with_exif(fixtures::rich_exif_le()),
3410 |m: &Metadata| m.exif.is_some(),
3411 ),
3412 (
3413 "xmp",
3414 ec.xmp(),
3415 dc.xmp(),
3416 Metadata::none().with_xmp(fixtures::sample_xmp()),
3417 |m: &Metadata| m.xmp.is_some(),
3418 ),
3419 (
3420 "cicp",
3421 ec.cicp(),
3422 dc.cicp(),
3423 Metadata::none().with_cicp(Cicp::SRGB),
3424 |m: &Metadata| m.cicp.is_some(),
3425 ),
3426 ];
3427 for (name, enc_writes, dec_reads, metadata, present) in channels {
3428 if !dec_reads {
3429 continue; }
3431 match roundtrip(metadata) {
3432 Err(e) => violations.push(format!("{name}: metadata-channel round trip failed: {e}")),
3433 Ok(actual) if enc_writes && !present(&actual) => violations.push(format!(
3434 "{name}: declared by encoder+decoder, but did not survive a PreserveExact round trip"
3435 )),
3436 Ok(actual) if !enc_writes && present(&actual) => violations.push(format!(
3437 "{name}: encoder declared it does NOT support this channel, yet it survived a round trip (hidden capability)"
3438 )),
3439 Ok(_) => {}
3440 }
3441 }
3442}
3443
3444pub fn check_fidelity_honesty<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
3479where
3480 E: EncoderConfig,
3481 D: DecoderConfig,
3482 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3483{
3484 const CHECK: &str = "fidelity_honesty";
3485 let ec = E::capabilities();
3486 let mut v: Vec<String> = Vec::new();
3487
3488 let exact = |cfg: &E| -> Result<bool, String> {
3491 let bytes = enc_oneshot(cfg, img, Metadata::none(), MetadataPolicy::PreserveExact)?;
3492 let (px, _) = dec_oneshot(&dec, &bytes)?;
3493 Ok(px == img.pixels())
3494 };
3495 let legacy_agrees = |name: &str, cfg: &E, resolved: Option<Fidelity>, v: &mut Vec<String>| {
3496 if let (Some(legacy), Some(f)) = (cfg.is_lossless(), resolved)
3497 && legacy != f.is_lossless()
3498 {
3499 v.push(format!(
3500 "{name}: is_lossless() = Some({legacy}) disagrees with resolved_target_fidelity() = {f:?}"
3501 ));
3502 }
3503 };
3504
3505 let ll = enc.clone().with_fidelity(Fidelity::Lossless);
3507 let r = ll.resolved_target_fidelity();
3508 if ec.lossless() {
3509 if r != Some(Fidelity::Lossless) {
3510 v.push(format!(
3511 "Lossless: `lossless` is declared, but the request resolved to {r:?} (expected Some(Lossless))"
3512 ));
3513 }
3514 } else if r == Some(Fidelity::Lossless) {
3515 v.push(
3516 "Lossless: `lossless` is NOT declared, yet the request resolved to Some(Lossless)"
3517 .into(),
3518 );
3519 }
3520 legacy_agrees("Lossless", &ll, r, &mut v);
3521 match exact(&ll) {
3522 Err(e) => v.push(format!("Lossless: round trip failed: {e}")),
3523 Ok(false) if r == Some(Fidelity::Lossless) => v.push(
3524 "Lossless: resolved to Lossless but the decoded pixels differ from the input".into(),
3525 ),
3526 Ok(_) => {}
3527 }
3528
3529 let q = ec.quality_range().map_or(75.0, |[lo, hi]| (lo + hi) / 2.0);
3531 let requests = [
3532 ("Lossy(CodecSpecificQuality)", Fidelity::codec_quality(q)),
3533 ("Lossy(ApproxSsim2)", Fidelity::ssim2(80.0)),
3534 ("Lossy(ApproxButteraugli)", Fidelity::butteraugli(1.5)),
3535 ("Lossy(ApproxZensimB)", Fidelity::zensim_b(80.0)),
3536 ];
3537 for (name, req) in requests {
3538 let cfg = enc
3539 .clone()
3540 .with_fidelity(Fidelity::Lossless)
3541 .with_fidelity(req);
3542 let r = cfg.resolved_target_fidelity();
3543 if ec.lossy() {
3544 match r {
3545 Some(Fidelity::Lossy(_)) => {}
3546 Some(Fidelity::Lossless) => v.push(format!(
3547 "{name}: `lossy` is declared, but the request resolved to Lossless (a prior Lossless request leaked, or lossy is not honored)"
3548 )),
3549 None if ec.quality_range().is_some() => v.push(format!(
3550 "{name}: `lossy` + a quality_range are declared, but the request resolved to None (report the target it mapped to)"
3551 )),
3552 _ => {}
3553 }
3554 } else if let Some(Fidelity::Lossy(t)) = r {
3555 v.push(format!(
3556 "{name}: `lossy` is NOT declared, yet the request resolved to Lossy({t:?}) — promote to Lossless (and report it) or report None"
3557 ));
3558 }
3559 legacy_agrees(name, &cfg, r, &mut v);
3560 match exact(&cfg) {
3561 Err(e) => v.push(format!("{name}: round trip failed: {e}")),
3562 Ok(false) if r == Some(Fidelity::Lossless) => v.push(format!(
3563 "{name}: resolved to Lossless but the decoded pixels differ from the input"
3564 )),
3565 Ok(_) => {}
3566 }
3567 }
3568
3569 if v.is_empty() {
3570 Ok(())
3571 } else {
3572 Err(fail(CHECK, v.join("; ")))
3573 }
3574}
3575
3576pub fn assert_uses_codec_error_envelope<E, D>()
3607where
3608 E: EncoderConfig<Error = At<CodecError>>,
3609 E::Job: EncodeJob<Error = At<CodecError>>,
3610 <E::Job as EncodeJob>::Enc: Encoder<Error = At<CodecError>>,
3611 D: DecoderConfig<Error = At<CodecError>>,
3612 for<'a> D::Job<'a>: DecodeJob<'a, Error = At<CodecError>>,
3613 for<'a> <D::Job<'a> as DecodeJob<'a>>::Dec: Decode<Error = At<CodecError>>,
3614{
3615}
3616
3617pub fn check_decode_error_envelope<D>(dec: D, malformed: &[u8]) -> Conformance
3639where
3640 D: DecoderConfig + 'static,
3641{
3642 const CHECK: &str = "decode_error_envelope";
3643 let dyn_cfg: &dyn DynDecoderConfig = &dec;
3644 let erased = match dyn_cfg.dyn_job().probe(malformed) {
3645 Err(e) => e,
3646 Ok(_) => {
3647 return Err(fail(
3648 CHECK,
3649 "probe() accepted the supplied `malformed` input — pass bytes this codec rejects, \
3650 so an error is actually produced to inspect",
3651 ));
3652 }
3653 };
3654 if erased.error_category().is_none() {
3655 return Err(fail(
3656 CHECK,
3657 format!(
3658 "ErrorCategory did not survive dyn-dispatch erasure: the decoder's `type Error` is \
3659 not `At<CodecError>` (Pattern A — a native error enum erases to a bare `dyn Error`, \
3660 which cannot be downcast to recover the category). Switch the zencodec trait impls \
3661 to `type Error = At<CodecError>`. Erased error was: {erased}"
3662 ),
3663 ));
3664 }
3665 if erased.codec_error().and_then(CodecError::codec).is_none() {
3666 return Err(fail(
3667 CHECK,
3668 "the CodecError envelope survived erasure but carries no codec name — make the native \
3669 error's `CategorizedError::codec_name()` return `Some(\"<codec>\")`",
3670 ));
3671 }
3672 Ok(())
3673}
3674
3675pub(crate) fn is_incomplete_input_category(cat: ErrorCategory) -> bool {
3696 matches!(cat, ErrorCategory::Image(_))
3702}
3703
3704fn truncation_lengths(len: usize) -> Vec<usize> {
3710 let mut lens: Vec<usize> = vec![0, 1, 2, 3, 4, 8, 16];
3711 for (num, den) in [(1, 8), (1, 4), (3, 8), (1, 2), (5, 8), (3, 4), (7, 8)] {
3712 lens.push(len * num / den);
3713 }
3714 lens.push(len.saturating_sub(1));
3715 lens.retain(|&n| n < len);
3716 lens.sort_unstable();
3717 lens.dedup();
3718 lens
3719}
3720
3721pub fn check_decode_truncation_series<D>(dec: D, valid: &[u8]) -> Conformance
3762where
3763 D: DecoderConfig + 'static,
3764{
3765 const CHECK: &str = "decode_truncation_series";
3766 let len = valid.len();
3767 if len < 2 {
3768 return Err(fail(
3769 CHECK,
3770 "supply a real encoded image of at least a few bytes",
3771 ));
3772 }
3773
3774 let mut violations: Vec<String> = Vec::new();
3775 let mut reported_pattern_a = false;
3776
3777 for n in truncation_lengths(len) {
3778 let truncated = &valid[..n];
3779 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3785 let dyn_cfg: &dyn DynDecoderConfig = &dec;
3786 let mut sink = CollectSink::default();
3787 dyn_cfg
3788 .dyn_job()
3789 .push_decode(Cow::Borrowed(truncated), &mut sink, &[])
3790 }));
3791
3792 match outcome {
3793 Err(_panic) => violations.push(format!(
3794 "decode panicked on truncation at {n}/{len} bytes — truncated input must never panic"
3795 )),
3796 Ok(Ok(_output)) => {
3797 if n <= len / 4 {
3798 violations.push(format!(
3799 "decode SUCCEEDED on a truncated header ({n} of {len} bytes) — a truncated \
3800 valid image must not silently decode (pixels are sacred)"
3801 ));
3802 }
3803 }
3805 Ok(Err(erased)) => match erased.error_category() {
3806 None => {
3807 if !reported_pattern_a {
3811 reported_pattern_a = true;
3812 violations.push(format!(
3813 "ErrorCategory did not survive dyn-dispatch erasure: the decoder's \
3814 `type Error` is not `At<CodecError>` (Pattern A — a native error enum \
3815 erases to a bare `dyn Error`, which cannot be downcast to recover the \
3816 category). Switch the zencodec trait impls to `type Error = \
3817 At<CodecError>`. First erased error (truncation at {n}/{len} bytes) \
3818 was: {erased}"
3819 ));
3820 }
3821 }
3822 Some(cat) => {
3823 if !is_incomplete_input_category(cat) {
3824 violations.push(format!(
3825 "truncation at {n}/{len} bytes categorized as {cat:?}; a truncated input \
3826 is incomplete client data and must map to an incomplete-input category \
3827 (UnexpectedEof/MalformedImage/UnsupportedImageType/UnsupportedImageFeature), \
3828 never {cat:?} — that misattributes it (e.g. Internal reads as a codec \
3829 bug/5xx; OutOfMemory means the codec allocated from an unvalidated length \
3830 in truncated data)"
3831 ));
3832 }
3833 }
3835 },
3836 }
3837 }
3838
3839 if violations.is_empty() {
3840 Ok(())
3841 } else {
3842 Err(fail(CHECK, violations.join("; ")))
3843 }
3844}
3845
3846pub fn check_inventory<D>(dec: D, valid: &[u8]) -> Conformance
3871where
3872 D: DecoderConfig,
3873{
3874 use zencodec::inventory::{Disposition, Inventory};
3875 const CHECK: &str = "inventory";
3876
3877 let run = |data: &[u8]| -> Result<Inventory, String> {
3878 let job = dec.clone().job();
3879 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.inventory(data))) {
3880 Err(_) => Err(format!("inventory panicked on {} bytes", data.len())),
3881 Ok(Err(e)) => Err(format!("inventory failed on {} bytes: {e}", data.len())),
3882 Ok(Ok(inv)) => Ok(inv),
3883 }
3884 };
3885
3886 if !D::capabilities().inventory() {
3887 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3888 dec.clone().job().inventory(valid)
3889 }))
3890 .map_err(|_| fail(CHECK, "disabled inventory panicked"))?;
3891 let err = result
3892 .err()
3893 .ok_or_else(|| fail(CHECK, "undeclared inventory succeeded"))?;
3894 if err.unsupported_operation() != Some(&UnsupportedOperation::Inventory) {
3895 return Err(fail(
3896 CHECK,
3897 format!("wrong disabled-inventory error: {err}"),
3898 ));
3899 }
3900 return Ok(());
3901 }
3902
3903 let formats = D::formats();
3904 let checked = |data: &[u8], what: &str| -> Result<Inventory, Failure> {
3905 let inv = run(data).map_err(|e| fail(CHECK, format!("{what}: {e}")))?;
3906 if inv.input_len() != data.len() as u64 {
3907 return Err(fail(
3908 CHECK,
3909 format!(
3910 "{what}: inventory covers {} bytes, the input has {}",
3911 inv.input_len(),
3912 data.len()
3913 ),
3914 ));
3915 }
3916 if !formats.contains(&inv.format()) {
3917 return Err(fail(
3918 CHECK,
3919 format!(
3920 "{what}: inventory format {} is not one this decoder declares",
3921 inv.format()
3922 ),
3923 ));
3924 }
3925 inv.validate()
3926 .map_err(|e| fail(CHECK, format!("{what}: invalid inventory: {e}\n{inv}")))?;
3927 Ok(inv)
3928 };
3929
3930 let inv = checked(valid, "valid input")?;
3931 if !inv
3932 .parts()
3933 .iter()
3934 .any(|p| p.disposition() == Disposition::ImageData)
3935 {
3936 return Err(fail(
3937 CHECK,
3938 format!("valid input: no part is attributed to image data\n{inv}"),
3939 ));
3940 }
3941
3942 let mut junked = valid.to_vec();
3943 junked.extend((0..37u8).map(|i| i.wrapping_mul(97) ^ 0x5A));
3944 let inv = checked(&junked, "input with 37 appended bytes")?;
3945 let tail = valid.len() as u64..junked.len() as u64;
3946 let mut has_child = vec![false; inv.parts().len()];
3947 for p in inv.parts() {
3948 if let Some(parent) = p.parent() {
3949 has_child[parent.index()] = true;
3950 }
3951 }
3952 for (i, p) in inv.parts().iter().enumerate() {
3953 let overlaps = p.range().start < tail.end && p.range().end > tail.start;
3954 if overlaps && !has_child[i] && p.disposition().is_consumed() {
3955 return Err(fail(
3956 CHECK,
3957 format!(
3958 "input with 37 appended bytes: part {}..{} covers appended junk but is \
3959 reported as {}\n{inv}",
3960 p.range().start,
3961 p.range().end,
3962 p.disposition()
3963 ),
3964 ));
3965 }
3966 }
3967
3968 for n in truncation_lengths(valid.len()) {
3969 checked(
3970 &valid[..n],
3971 &format!("truncated to {n} of {} bytes", valid.len()),
3972 )?;
3973 }
3974 Ok(())
3975}
3976
3977pub fn check_inventory_dispatch<D: DecoderConfig + 'static>(dec: D, valid: &[u8]) -> Conformance {
3981 check_inventory(dec.clone(), valid)?;
3982 let dynamic: Box<dyn zencodec::decode::DynDecoderConfig> = Box::new(dec.clone());
3983 let check = |data: &[u8]| -> Conformance {
3984 let generic = dec.clone().job().inventory(data);
3985 let erased = dynamic.dyn_job().inventory(data);
3986 match (generic, erased) {
3987 (Ok(a), Ok(b)) if a == b => Ok(()),
3988 (Err(a), Err(b))
3989 if !D::capabilities().inventory()
3990 && a.unsupported_operation() == Some(&UnsupportedOperation::Inventory)
3991 && b.unsupported_operation() == Some(&UnsupportedOperation::Inventory) =>
3992 {
3993 Ok(())
3994 }
3995 (a, b) => Err(fail(
3996 "inventory dispatch",
3997 format!("generic/dynamic disagreement: {a:?} vs {b:?}"),
3998 )),
3999 }
4000 };
4001 check(valid)?;
4002 let mut trailing = valid.to_vec();
4003 trailing.extend_from_slice(&[0xA5; 37]);
4004 check(&trailing)?;
4005 for n in truncation_lengths(valid.len()) {
4006 check(&valid[..n])?;
4007 }
4008 Ok(())
4009}
4010
4011pub fn check_all<E, D>(enc: E, dec: D) -> Conformance
4015where
4016 E: EncoderConfig,
4017 D: DecoderConfig,
4018 <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
4019 <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
4020{
4021 let img = TestImage::rgba8_gradient(40, 24);
4022 check_pixel_roundtrip(enc.clone(), dec.clone(), &img)?;
4023 check_cross_path_pixel_equivalence(enc.clone(), dec.clone(), &img)?;
4024 check_orientation_roundtrip(enc.clone(), dec.clone(), &img)?;
4025 check_metadata_no_leak(enc.clone(), dec.clone(), &img)?;
4026 check_capability_honesty(enc.clone(), dec.clone(), &img)?;
4027 check_streaming_output_honesty(enc.clone(), dec.clone(), &streaming_check_image(&enc))?;
4028 check_fidelity_honesty(enc.clone(), dec.clone(), &img)?;
4029 check_color_context_consistency(enc.clone(), dec.clone(), &img)?;
4030 check_color_authority_spec(enc.clone(), dec.clone(), &img)?;
4031 check_native_hdr_roundtrip(enc.clone(), dec.clone())?;
4032 check_gain_map_roundtrip(enc.clone(), dec.clone())?;
4033 {
4034 let bytes = enc_oneshot(&enc, &img, Metadata::none(), MetadataPolicy::PreserveExact)
4035 .map_err(|e| fail("inventory", format!("encode: {e}")))?;
4036 check_inventory(dec.clone(), &bytes)?;
4037 check_incremental_capability_honesty(dec.clone(), &bytes)?;
4038 check_seekable_capability_honesty(dec.clone(), &bytes)?;
4039 }
4040 let frames = [
4041 TestImage::rgba8_gradient_seeded(24, 16, 0),
4042 TestImage::rgba8_gradient_seeded(24, 16, 60),
4043 TestImage::rgba8_gradient_seeded(24, 16, 120),
4044 ];
4045 check_animation_cross_path_equivalence(enc.clone(), dec.clone(), &frames)?;
4046 check_animation_streaming_output_honesty(enc.clone(), dec, &animation_streaming_frames(&enc))?;
4047 Ok(())
4048}
4049
4050#[cfg(test)]
4051#[allow(deprecated)] mod tests {
4053 use super::*;
4054
4055 fn ref_codecs() -> (ReferenceEncoderConfig, ReferenceDecoderConfig) {
4056 (ReferenceEncoderConfig::new(), ReferenceDecoderConfig)
4057 }
4058
4059 #[test]
4060 fn fixture_exif_is_realistic() {
4061 let blob = fixtures::rich_exif_le();
4064 let x = Exif::parse(&blob).expect("fixture EXIF parses");
4065 assert!(x.has_gps(), "fixture must contain GPS");
4066 assert!(x.has_thumbnail(), "fixture must contain a thumbnail");
4067 assert!(x.has_device_ids() && x.has_camera_owner() && x.has_image_unique_id());
4068 assert!(x.has_datetimes() && x.has_time_offsets());
4069 assert_eq!(x.copyright().as_deref(), Some("(C) 2026 Test"));
4070 }
4071
4072 #[test]
4073 fn metadata_check_catches_identifiers_alongside_retained_camera_tags() {
4074 use zencodec::{ExifPolicy, Retention};
4075 let rich = Metadata::none().with_exif(fixtures::rich_exif_le());
4076 let base = ExifPolicy::ATTRIBUTED_ORIENTATION
4077 .with_camera(Retention::Keep)
4078 .with_datetimes(Retention::Keep);
4079 let policy = |exif| MetadataPolicy::Custom(MetadataPolicy::Web.fields().with_exif(exif));
4080 let expected = rich.filtered(&policy(base));
4081 for leaky in [
4082 base.with_device_ids(Retention::Keep),
4083 base.with_camera_owner(Retention::Keep),
4084 base.with_image_unique_id(Retention::Keep),
4085 base.with_time_offsets(Retention::Keep),
4086 ] {
4087 let actual = rich.filtered(&policy(leaky));
4088 assert!(
4089 assert_no_leak(
4090 "mutation",
4091 "camera without IDs",
4092 &actual,
4093 &expected,
4094 &Metadata::none()
4095 )
4096 .is_err()
4097 );
4098 }
4099 }
4100
4101 #[test]
4102 fn metadata_check_accepts_only_exact_intrinsic_color() {
4103 let expected = Metadata::none();
4104 let intrinsic = Metadata::none().with_cicp(Cicp::SRGB).with_icc([1, 2, 3]);
4105 assert_no_leak("intrinsic", "discard", &intrinsic, &expected, &intrinsic).unwrap();
4106 for unexpected in [
4107 Metadata::none().with_cicp(Cicp::new(9, 16, 0, true)),
4108 Metadata::none().with_icc([1, 2, 4]),
4109 ] {
4110 assert!(
4111 assert_no_leak("intrinsic", "discard", &unexpected, &expected, &intrinsic).is_err()
4112 );
4113 assert!(
4114 assert_no_leak(
4115 "intrinsic",
4116 "discard",
4117 &unexpected,
4118 &expected,
4119 &Metadata::none()
4120 )
4121 .is_err()
4122 );
4123 }
4124 for unexpected in [
4126 intrinsic.clone().with_exif(fixtures::rich_exif_le()),
4127 intrinsic.clone().with_xmp(fixtures::sample_xmp()),
4128 ] {
4129 assert!(
4130 assert_no_leak("intrinsic", "discard", &unexpected, &expected, &intrinsic).is_err()
4131 );
4132 }
4133 }
4134
4135 #[test]
4136 fn reference_pixel_roundtrip_rgba8() {
4137 let (e, d) = ref_codecs();
4138 check_pixel_roundtrip(e, d, &TestImage::rgba8_gradient(37, 19)).unwrap();
4139 }
4140
4141 #[test]
4142 fn reference_pixel_roundtrip_rgb8() {
4143 let (e, d) = ref_codecs();
4144 check_pixel_roundtrip(e, d, &TestImage::rgb8_gradient(16, 16)).unwrap();
4145 }
4146
4147 #[test]
4148 fn reference_cross_path_equivalence() {
4149 let (e, d) = ref_codecs();
4150 check_cross_path_pixel_equivalence(e, d, &TestImage::rgba8_gradient(40, 23)).unwrap();
4151 }
4152
4153 #[test]
4154 fn reference_metadata_no_leak() {
4155 let (e, d) = ref_codecs();
4156 check_metadata_no_leak(e, d, &TestImage::rgba8_gradient(8, 8)).unwrap();
4157 }
4158
4159 #[test]
4162 fn reference_capability_honesty() {
4163 let (e, d) = ref_codecs();
4164 check_capability_honesty(e, d, &TestImage::rgba8_gradient(12, 9)).unwrap();
4165 }
4166
4167 #[test]
4168 fn metadata_capabilities_allow_exclusive_color_carriers() {
4169 let mut violations = Vec::new();
4170 let mut seen = Vec::new();
4171 check_metadata_channel_honesty(
4172 ReferenceEncoderConfig::capabilities(),
4173 <ReferenceDecoderConfig as DecoderConfig>::capabilities(),
4174 |mut metadata| {
4175 if metadata.icc_profile.is_some() {
4177 metadata.cicp = None;
4178 }
4179 seen.push((
4180 metadata.icc_profile.is_some(),
4181 metadata.exif.is_some(),
4182 metadata.xmp.is_some(),
4183 metadata.cicp.is_some(),
4184 ));
4185 Ok(metadata)
4186 },
4187 &mut violations,
4188 );
4189 assert!(violations.is_empty(), "{violations:?}");
4190 assert_eq!(
4191 seen,
4192 [
4193 (true, false, false, false),
4194 (false, true, false, false),
4195 (false, false, true, false),
4196 (false, false, false, true),
4197 ]
4198 );
4199 }
4200
4201 #[test]
4202 fn metadata_capabilities_still_reject_dropped_and_hidden_channels() {
4203 let decoder = <ReferenceDecoderConfig as DecoderConfig>::capabilities();
4204 let mut dropped = Vec::new();
4205 check_metadata_channel_honesty(
4206 ReferenceEncoderConfig::capabilities(),
4207 decoder,
4208 |_| Ok(Metadata::none()),
4209 &mut dropped,
4210 );
4211 let mut hidden = Vec::new();
4212 check_metadata_channel_honesty(
4213 MinimalEncoderConfig::capabilities(),
4214 decoder,
4215 Ok,
4216 &mut hidden,
4217 );
4218 for name in ["icc", "exif", "xmp", "cicp"] {
4219 assert!(
4220 dropped
4221 .iter()
4222 .any(|v| v.starts_with(&format!("{name}:")) && v.contains("did not survive"))
4223 );
4224 assert!(
4225 hidden
4226 .iter()
4227 .any(|v| v.starts_with(&format!("{name}:")) && v.contains("hidden capability"))
4228 );
4229 }
4230 }
4231
4232 #[test]
4236 fn reference_gain_map_roundtrip() {
4237 let (e, d) = ref_codecs();
4238 check_gain_map_roundtrip(e, d).unwrap();
4239 }
4240
4241 #[test]
4245 fn minimal_gain_map_roundtrip() {
4246 check_gain_map_roundtrip(MinimalEncoderConfig::new(), MinimalDecoderConfig::new()).unwrap();
4247 }
4248
4249 #[test]
4253 fn gain_map_fixture_params_are_wire_exact() {
4254 use zencodec::gainmap::{Iso21496Format, parse_iso21496_fmt, serialize_iso21496_fmt};
4255 for case in GAIN_MAP_CASES {
4256 let p = gain_map_params(case);
4257 for fmt in [Iso21496Format::JxlJhgm, Iso21496Format::AvifTmap] {
4258 let back = parse_iso21496_fmt(&serialize_iso21496_fmt(&p, fmt), fmt).unwrap();
4259 assert_eq!(back, p, "{} via {fmt:?}", case.name);
4260 }
4261 }
4262 }
4263
4264 #[test]
4269 fn reference_gain_map_encoded_own_format() {
4270 let case = GAIN_MAP_CASES[1]; let fixture = gain_map_fixture(case);
4272 let gm_img = ReferenceEncoderConfig::new()
4274 .job()
4275 .encoder()
4276 .unwrap()
4277 .encode(fixture.pixels.as_slice())
4278 .unwrap()
4279 .into_vec();
4280 let src = GainMapSource::new(gm_img, ImageFormat::Pnm, fixture.metadata.clone());
4281 let base = TestImage::rgb8_gradient(20, 14);
4282 let bytes = ReferenceEncoderConfig::new()
4283 .job()
4284 .with_gain_map_encoded(src)
4285 .unwrap()
4286 .encoder()
4287 .unwrap()
4288 .encode(base.as_slice())
4289 .unwrap()
4290 .into_vec();
4291 let out = dec_render(&ReferenceDecoderConfig, &bytes, GainMapRender::Components).unwrap();
4292 let gm = out.extras::<DecodedGainMap>().expect("surfaced");
4293 compare_gain_map(
4294 &GainMapObserved::of(gm),
4295 &gain_map_pixels(case),
4296 &fixture.metadata,
4297 true,
4298 "encoded own-format",
4299 )
4300 .unwrap();
4301
4302 let bad = GainMapSource::new(
4304 vec![0; 8],
4305 ImageFormat::Jpeg,
4306 gain_map_fixture(case).metadata,
4307 );
4308 let err = ReferenceEncoderConfig::new()
4309 .job()
4310 .with_gain_map_encoded(bad)
4311 .err()
4312 .expect("foreign format rejected");
4313 assert!(matches!(err, RefError::Invalid(_)), "{err}");
4314 }
4315
4316 #[test]
4322 fn reference_cancellation_is_classifiable() {
4323 use enough::{Stop, StopReason};
4324 use zencodec::{CategorizedError, CodecErrorExt, ErrorCategory};
4325
4326 struct Cancelled;
4328 impl Stop for Cancelled {
4329 fn check(&self) -> Result<(), StopReason> {
4330 Err(StopReason::Cancelled)
4331 }
4332 }
4333
4334 let img = TestImage::rgba8_gradient(8, 8);
4335 let mut a = ReferenceEncoderConfig::new()
4336 .job()
4337 .animation_frame_encoder()
4338 .expect("animation encoder");
4339 let err = a
4340 .push_frame(
4341 img.as_slice(),
4342 40,
4343 Some(&zencodec::how_far::StopOnly::borrowed(&Cancelled)),
4344 )
4345 .expect_err("a fired stop token must cancel the push");
4346
4347 assert_eq!(
4349 err.category(),
4350 ErrorCategory::Stopped(zencodec::enough::StopReason::Cancelled)
4351 );
4352 assert!(err.limit_exceeded().is_none());
4354 assert!(err.unsupported_operation().is_none());
4355 }
4356
4357 #[test]
4363 fn minimal_envelope_category_survives_dyn_erasure() {
4364 use zencodec::decode::DynDecoderConfig;
4365 use zencodec::{CodecError, CodecErrorExt, ErrorCategory};
4366
4367 let cfg = MinimalDecoderConfig::new();
4370 let dyn_min: &dyn DynDecoderConfig = &cfg;
4371 let erased = dyn_min
4372 .dyn_job()
4373 .probe(b"not a ZCR1 header")
4374 .expect_err("malformed header must fail");
4375 assert_eq!(
4378 erased.error_category(),
4379 Some(ErrorCategory::Image(zencodec::ImageError::Malformed))
4380 );
4381 assert_eq!(
4382 erased.codec_error().and_then(CodecError::codec),
4383 Some(crate::reference::MINIMAL_CODEC_NAME)
4384 );
4385
4386 let ref_cfg = ReferenceDecoderConfig;
4390 let dyn_ref: &dyn DynDecoderConfig = &ref_cfg;
4391 let erased_ref = dyn_ref
4392 .dyn_job()
4393 .probe(b"not a ZCR1 header")
4394 .expect_err("malformed header must fail");
4395 assert_eq!(erased_ref.error_category(), None);
4396 assert!(erased_ref.codec_error().is_none());
4397 }
4398
4399 #[test]
4403 fn minimal_envelope_typed_path_and_trace() {
4404 use std::borrow::Cow;
4405 use zencodec::decode::{DecodeJob, DecoderConfig};
4406 use zencodec::{CodecErrorExt, ErrorCategory};
4407
4408 let err = MinimalDecoderConfig::new()
4409 .job()
4410 .decoder(Cow::Borrowed(b"not a ZCR1 header"), &[])
4411 .expect_err("malformed header must fail");
4412 assert_eq!(
4414 err.error().category(),
4415 ErrorCategory::Image(zencodec::ImageError::Malformed)
4416 );
4417 assert_eq!(
4418 err.error().codec(),
4419 Some(crate::reference::MINIMAL_CODEC_NAME)
4420 );
4421 assert_eq!(
4423 err.error_category(),
4424 Some(ErrorCategory::Image(zencodec::ImageError::Malformed))
4425 );
4426 let dbg = format!("{err:?}");
4428 assert!(dbg.contains("at "), "expected a trace frame: {dbg}");
4429 }
4430
4431 #[test]
4434 fn minimal_capability_honesty() {
4435 check_capability_honesty(
4436 MinimalEncoderConfig::new(),
4437 MinimalDecoderConfig::new(),
4438 &TestImage::rgba8_gradient(12, 9),
4439 )
4440 .unwrap();
4441 }
4442
4443 #[test]
4446 fn reference_fidelity_honesty() {
4447 let (e, d) = ref_codecs();
4448 check_fidelity_honesty(e, d, &TestImage::rgba8_gradient(12, 9)).unwrap();
4449 let cfg = ReferenceEncoderConfig::new().with_fidelity(Fidelity::ssim2(70.0));
4450 assert_eq!(cfg.resolved_target_fidelity(), Some(Fidelity::Lossless));
4451 assert_eq!(cfg.is_lossless(), Some(true));
4452 }
4453
4454 #[test]
4457 fn minimal_fidelity_honesty() {
4458 check_fidelity_honesty(
4459 MinimalEncoderConfig::new(),
4460 MinimalDecoderConfig::new(),
4461 &TestImage::rgba8_gradient(12, 9),
4462 )
4463 .unwrap();
4464 assert_eq!(
4465 MinimalEncoderConfig::new()
4466 .with_fidelity(Fidelity::Lossless)
4467 .resolved_target_fidelity(),
4468 None
4469 );
4470 }
4471
4472 #[test]
4473 fn minimal_color_authority_spec() {
4474 check_color_authority_spec(
4475 MinimalEncoderConfig::new(),
4476 MinimalDecoderConfig::new(),
4477 &TestImage::rgba8_gradient(12, 9),
4478 )
4479 .unwrap();
4480 }
4481
4482 #[test]
4485 fn reference_color_authority_spec() {
4486 let (e, d) = ref_codecs();
4487 check_color_authority_spec(e, d, &TestImage::rgba8_gradient(12, 9)).unwrap();
4488 }
4489
4490 #[test]
4492 fn expected_color_authority_table() {
4493 use ColorAuthority::{Cicp as C, Icc as I};
4494 use ImageFormat::*;
4495 let e = expected_color_authority;
4496 for f in [Png, Jxl, Heic, Hdr] {
4498 assert_eq!(e(f, true, true), Some(C), "{f:?}");
4499 assert_eq!(e(f, true, false), Some(C), "{f:?}");
4500 assert_eq!(e(f, false, true), Some(I), "{f:?}");
4501 assert_eq!(e(f, false, false), Some(I), "{f:?}");
4502 }
4503 assert_eq!(e(Avif, true, true), Some(I));
4505 assert_eq!(e(Avif, false, true), Some(I));
4506 assert_eq!(e(Avif, true, false), Some(C));
4507 assert_eq!(e(Avif, false, false), Some(I));
4508 for f in [
4510 Jpeg, WebP, Gif, Tiff, Bmp, Ico, Pnm, Farbfeld, Qoi, Tga, Dng, Raw, Pdf,
4511 ] {
4512 for (c, i) in [(true, true), (true, false), (false, true), (false, false)] {
4513 assert_eq!(e(f, c, i), Some(I), "{f:?} cicp={c} icc={i}");
4514 }
4515 }
4516 for f in [Exr, Jp2, Svg, Unknown] {
4518 assert_eq!(e(f, true, true), None, "{f:?}");
4519 }
4520 }
4521
4522 #[test]
4527 fn source_color_authority_catches_nclx_only_default_authority() {
4528 let sc = SourceColor::default().with_cicp(Cicp::BT2100_PQ);
4529 let f = check_source_color_authority(ImageFormat::Heic, &sc).unwrap_err();
4530 assert_eq!(f.check, "source_color_authority");
4531 assert!(f.detail.contains("Cicp"), "{f}");
4532 assert!(
4533 check_source_color_authority(
4534 ImageFormat::Heic,
4535 &sc.with_color_authority(ColorAuthority::Cicp)
4536 )
4537 .is_ok()
4538 );
4539 }
4540
4541 #[test]
4544 fn source_color_authority_icc_first_formats() {
4545 let both = SourceColor::default()
4546 .with_cicp(Cicp::SRGB)
4547 .with_icc_profile(fixtures::sample_icc());
4548 assert!(check_source_color_authority(ImageFormat::Avif, &both).is_ok());
4549 let wrong = both.clone().with_color_authority(ColorAuthority::Cicp);
4550 assert!(check_source_color_authority(ImageFormat::Avif, &wrong).is_err());
4551 assert!(check_source_color_authority(ImageFormat::Jpeg, &wrong).is_err());
4552 assert!(check_source_color_authority(ImageFormat::Exr, &wrong).is_ok());
4554 }
4555
4556 #[test]
4559 fn minimal_one_shot_roundtrip_and_pixels() {
4560 let (e, d) = (MinimalEncoderConfig::new(), MinimalDecoderConfig::new());
4561 check_pixel_roundtrip(e, d, &TestImage::rgb8_gradient(10, 7)).unwrap();
4562 }
4563
4564 #[test]
4569 fn minimal_decode_error_envelope_survives_erasure() {
4570 check_decode_error_envelope(MinimalDecoderConfig::new(), &[0xABu8; 16]).unwrap();
4573 }
4574
4575 #[test]
4581 fn reference_pattern_a_fails_the_envelope_check() {
4582 let err = check_decode_error_envelope(ReferenceDecoderConfig, &[0xABu8; 16])
4583 .expect_err("Pattern A must fail the envelope-survival check");
4584 assert_eq!(err.check, "decode_error_envelope");
4585 assert!(
4586 err.detail.contains("Pattern A"),
4587 "the failure should name the Pattern-A cause: {}",
4588 err.detail
4589 );
4590 }
4591
4592 #[test]
4596 fn minimal_satisfies_the_static_envelope_assertion() {
4597 assert_uses_codec_error_envelope::<MinimalEncoderConfig, MinimalDecoderConfig>();
4598 }
4599
4600 #[test]
4608 fn incomplete_input_category_policy() {
4609 use zencodec::enough::StopReason;
4610 use zencodec::{
4611 CodecIoKind, ErrorCategory as E, ImageError, InternalKind, InvalidKind, LimitKind,
4612 PolicyKind, RequestError, ResourceError, UnsupportedImageKind, UnsupportedOperation,
4613 };
4614
4615 for c in [
4618 E::Image(ImageError::UnexpectedEof),
4619 E::Image(ImageError::Malformed),
4620 E::Image(ImageError::Unsupported(UnsupportedImageKind::Type)),
4621 E::Image(ImageError::Unsupported(UnsupportedImageKind::Feature)),
4622 ] {
4623 assert!(
4624 is_incomplete_input_category(c),
4625 "{c:?} must be an allowed truncation category"
4626 );
4627 }
4628
4629 for c in [
4631 E::Request(RequestError::Unsupported(UnsupportedOperation::PixelFormat)),
4632 E::Request(RequestError::Unsupported(
4633 UnsupportedOperation::AnimationEncode,
4634 )),
4635 E::Request(RequestError::CmsRequired),
4636 E::Request(RequestError::Invalid(InvalidKind::Parameters)),
4637 E::Request(RequestError::Invalid(InvalidKind::Buffer)),
4638 E::Request(RequestError::Invalid(InvalidKind::State)),
4639 E::Policy(PolicyKind::Decode),
4640 E::Policy(PolicyKind::Encode),
4641 E::Stopped(StopReason::Cancelled),
4642 E::Stopped(StopReason::TimedOut),
4643 E::Resource(ResourceError::Limits(LimitKind::Pixels)),
4644 E::Resource(ResourceError::OutOfMemory),
4645 E::Io(CodecIoKind::opaque()),
4646 E::Internal(InternalKind::Bug),
4647 E::Internal(InternalKind::Dependency),
4648 ] {
4649 assert!(
4650 !is_incomplete_input_category(c),
4651 "{c:?} misattributes a client-side truncation and must be denied"
4652 );
4653 }
4654 }
4655
4656 #[test]
4659 fn truncation_series_generator() {
4660 let len = 279usize; let s = truncation_lengths(len);
4662
4663 assert!(
4664 s.windows(2).all(|w| w[0] < w[1]),
4665 "strictly increasing (sorted + deduped): {s:?}"
4666 );
4667 assert!(s.iter().all(|&n| n < len), "every value < len: {s:?}");
4668 for n in [0usize, 1, 2, 3, 4, 8, 16] {
4669 assert!(s.contains(&n), "small header size {n} present: {s:?}");
4670 }
4671 for (num, den) in [(1, 8), (1, 4), (3, 8), (1, 2), (5, 8), (3, 4), (7, 8)] {
4672 assert!(
4673 s.contains(&(len * num / den)),
4674 "fraction {num}/{den} present: {s:?}"
4675 );
4676 }
4677 assert!(s.contains(&(len - 1)), "len-1 present: {s:?}");
4678
4679 assert_eq!(truncation_lengths(5), vec![0, 1, 2, 3, 4]);
4681 assert_eq!(truncation_lengths(2), vec![0, 1]);
4682 }
4683
4684 #[test]
4691 fn reference_pattern_a_fails_truncation_series() {
4692 let (e, d) = ref_codecs();
4693 let img = TestImage::rgba8_gradient(9, 6);
4694 let valid = enc_oneshot(&e, &img, Metadata::none(), MetadataPolicy::PreserveExact).unwrap();
4695 assert!(valid.len() > 16, "reference image should be non-trivial");
4696
4697 let err = check_decode_truncation_series(d, &valid)
4698 .expect_err("Pattern A must fail the truncation-series check on the erased path");
4699 assert_eq!(err.check, "decode_truncation_series");
4700 assert!(
4701 err.detail.contains("Pattern A"),
4702 "the failure should name the Pattern-A cause: {}",
4703 err.detail
4704 );
4705 assert_eq!(
4706 err.detail.matches("Pattern A").count(),
4707 1,
4708 "the Pattern-A limitation is reported once, not per truncated offset: {}",
4709 err.detail
4710 );
4711 }
4712
4713 #[test]
4718 fn minimal_passes_truncation_series() {
4719 let img = TestImage::rgba8_gradient(9, 6);
4720 let valid = enc_oneshot(
4721 &MinimalEncoderConfig::new(),
4722 &img,
4723 Metadata::none(),
4724 MetadataPolicy::PreserveExact,
4725 )
4726 .unwrap();
4727 check_decode_truncation_series(MinimalDecoderConfig::new(), &valid).unwrap();
4728 }
4729
4730 #[test]
4733 fn truncation_series_rejects_tiny_valid() {
4734 let err = check_decode_truncation_series(MinimalDecoderConfig::new(), &[0x00])
4735 .expect_err("a 1-byte `valid` cannot be a real encoded image");
4736 assert_eq!(err.check, "decode_truncation_series");
4737 }
4738
4739 #[test]
4743 fn detector_catches_a_lie() {
4744 let mut v = Vec::new();
4746 classify(
4747 "x",
4748 true,
4749 Err::<(), _>(RefError::Invalid("boom".into())),
4750 &mut v,
4751 );
4752 assert_eq!(v.len(), 1, "declared + failed must be flagged");
4753
4754 let mut v = Vec::new();
4756 classify("y", false, Ok::<(), RefError>(()), &mut v);
4757 assert_eq!(v.len(), 1, "undeclared + worked must be flagged");
4758
4759 let mut v = Vec::new();
4761 let declined = Err::<(), _>(RefError::Unsupported(
4762 zencodec::UnsupportedOperation::RowLevelEncode,
4763 ));
4764 classify("z", false, declined, &mut v);
4765 assert!(v.is_empty(), "undeclared + UnsupportedOperation is honest");
4766 }
4767
4768 #[test]
4769 fn reference_orientation_roundtrip() {
4770 let (e, d) = ref_codecs();
4771 check_orientation_roundtrip(e, d, &TestImage::rgba8_gradient(9, 6)).unwrap();
4774 }
4775
4776 #[test]
4779 fn render_matches_orientation_semantics() {
4780 let p = TestImage::rgba8_gradient(3, 2).pixels();
4781 assert_eq!(render(&p, Orientation::Identity), p, "identity is a no-op");
4782
4783 let r90 = render(&p, Orientation::Rotate90);
4784 assert_eq!((r90.width, r90.rows), (2, 3), "Rotate90 swaps axes");
4785
4786 let r180 = render(&p, Orientation::Rotate180);
4788 assert_eq!(
4789 render(&r180, Orientation::Rotate180),
4790 p,
4791 "Rotate180∘Rotate180 == id"
4792 );
4793 let fh = render(&p, Orientation::FlipH);
4794 assert_eq!(render(&fh, Orientation::FlipH), p, "FlipH∘FlipH == id");
4795 let fv = render(&p, Orientation::FlipV);
4796 assert_eq!(render(&fv, Orientation::FlipV), p, "FlipV∘FlipV == id");
4797 }
4798
4799 #[test]
4800 fn reference_animation_cross_path() {
4801 let (e, d) = ref_codecs();
4802 let frames = [
4804 TestImage::rgba8_gradient_seeded(10, 8, 0),
4805 TestImage::rgba8_gradient_seeded(10, 8, 50),
4806 TestImage::rgba8_gradient_seeded(10, 8, 130),
4807 ];
4808 check_animation_cross_path_equivalence(e, d, &frames).unwrap();
4809 }
4810
4811 #[test]
4812 fn minimal_animation_cross_path_skipped() {
4813 let frames = [TestImage::rgba8_gradient_seeded(8, 8, 0)];
4815 check_animation_cross_path_equivalence(
4816 MinimalEncoderConfig::new(),
4817 MinimalDecoderConfig::new(),
4818 &frames,
4819 )
4820 .unwrap();
4821 }
4822
4823 #[test]
4829 fn reference_native_hdr_roundtrip() {
4830 let (e, d) = ref_codecs();
4831 check_native_hdr_roundtrip(e, d).unwrap();
4832 }
4833
4834 #[test]
4836 fn minimal_native_hdr_roundtrip_skipped() {
4837 check_native_hdr_roundtrip(MinimalEncoderConfig::new(), MinimalDecoderConfig::new())
4838 .unwrap();
4839 }
4840
4841 #[test]
4845 fn reference_hdr_decode_is_labelled_pq() {
4846 let (e, d) = ref_codecs();
4847 let img = TestImage::rgb16_gradient(6, 5, PixelDescriptor::RGB16_BT2100_PQ);
4848 let meta = Metadata::none().with_cicp(Cicp::BT2100_PQ);
4849 let bytes = enc_oneshot(&e, &img, meta, MetadataPolicy::PreserveExact).unwrap();
4850 let out = dec_output(&d, &bytes).unwrap();
4851 assert_eq!(out.pixels().descriptor(), PixelDescriptor::RGB16_BT2100_PQ);
4852 assert_eq!(grab(out.pixels()), img.pixels());
4853 }
4854
4855 #[test]
4858 fn mastering_display_tolerance_is_fixed_point_tight() {
4859 let want = MasteringDisplay::HDR10_REFERENCE;
4860 let mut quantized = want;
4861 quantized.primaries_xy[0][0] += 1e-5; quantized.max_luminance = 10000.05; assert!(mastering_display_matches(&quantized, &want));
4864 let mut drifted = want;
4865 drifted.primaries_xy[0][0] += 1e-3;
4866 assert!(!mastering_display_matches(&drifted, &want));
4867 let mut dark = want;
4868 dark.min_luminance = 0.0;
4869 assert!(
4870 !mastering_display_matches(&dark, &want),
4871 "a zeroed min luminance (0.0001 → 0) is a drop, not precision"
4872 );
4873 }
4874
4875 #[test]
4880 fn reference_color_context_consistency() {
4881 let (e, d) = ref_codecs();
4882 check_color_context_consistency(e, d, &TestImage::rgba8_gradient(11, 9)).unwrap();
4883 }
4884
4885 #[test]
4886 fn reference_color_context_attached() {
4887 let (e, d) = ref_codecs();
4888 check_color_context_attached(e, d, &TestImage::rgb8_gradient(11, 9)).unwrap();
4889 }
4890
4891 #[test]
4895 fn minimal_color_context_checks_pass_without_color() {
4896 let img = TestImage::rgba8_gradient(8, 6);
4897 check_color_context_consistency(
4898 MinimalEncoderConfig::new(),
4899 MinimalDecoderConfig::new(),
4900 &img,
4901 )
4902 .unwrap();
4903 check_color_context_attached(
4904 MinimalEncoderConfig::new(),
4905 MinimalDecoderConfig::new(),
4906 &img,
4907 )
4908 .unwrap();
4909 }
4910
4911 #[test]
4915 fn buffer_context_class_gate() {
4916 use zenpixels::PixelDescriptor as P;
4917 let rgb_icc = ColorContext::from_icc(fixtures::sample_icc_with_class(b"RGB "));
4918 let gray_icc = ColorContext::from_icc(fixtures::sample_icc_with_class(b"GRAY"));
4919 let junk_icc = ColorContext::from_icc(fixtures::sample_icc_with_class(b"????"));
4920 let cicp_only = ColorContext::from_cicp(Cicp::BT2100_PQ);
4921
4922 assert!(validate_buffer_context(None, P::RGB8_SRGB).is_ok());
4923 assert!(validate_buffer_context(Some(&rgb_icc), P::RGB8_SRGB).is_ok());
4924 assert!(validate_buffer_context(Some(&rgb_icc), P::RGBA8_SRGB).is_ok());
4925 assert!(validate_buffer_context(Some(&gray_icc), P::GRAY8_SRGB).is_ok());
4926 assert!(validate_buffer_context(Some(&cicp_only), P::GRAY8_SRGB).is_ok());
4927
4928 let e = validate_buffer_context(Some(&gray_icc), P::RGB8_SRGB).unwrap_err();
4929 assert!(e.contains("device class Gray"), "{e}");
4930 let e = validate_buffer_context(Some(&rgb_icc), P::GRAY8_SRGB).unwrap_err();
4931 assert!(e.contains("device class Rgb"), "{e}");
4932 let e = validate_buffer_context(Some(&junk_icc), P::RGB8_SRGB).unwrap_err();
4933 assert!(e.contains("unreadable"), "{e}");
4934 let e = validate_buffer_context(Some(&ColorContext::default()), P::RGB8_SRGB).unwrap_err();
4935 assert!(e.contains("neither ICC nor CICP"), "{e}");
4936 }
4937
4938 #[test]
4943 fn reference_class_gate_ranks_the_fallbacks() {
4944 use zencodec::decode::SourceColor;
4945 use zenpixels::ColorModel;
4946
4947 let rgb = Arc::<[u8]>::from(fixtures::sample_icc_with_class(b"RGB "));
4948 let gray = Arc::<[u8]>::from(fixtures::sample_icc_with_class(b"GRAY"));
4949
4950 let both = SourceColor::default()
4951 .with_icc_profile(rgb.clone())
4952 .with_cicp(Cicp::SRGB);
4953 let ctx = crate::reference::class_gated_context(&both, ColorModel::Rgb).expect("attached");
4954 assert_eq!(
4955 ctx.icc.as_deref(),
4956 Some(&*rgb),
4957 "class-matching ICC rides as-is"
4958 );
4959 assert_eq!(ctx.cicp, None, "drop-dupe: ICC is the authority");
4960
4961 let mismatched = SourceColor::default()
4962 .with_icc_profile(gray.clone())
4963 .with_cicp(Cicp::SRGB);
4964 let ctx = crate::reference::class_gated_context(&mismatched, ColorModel::Rgb)
4965 .expect("falls back to a CICP-only description");
4966 assert_eq!(
4967 ctx.icc, None,
4968 "a GRAY-class profile never rides an RGB buffer"
4969 );
4970 assert_eq!(ctx.cicp, Some(Cicp::SRGB));
4971
4972 let underivable = SourceColor::default().with_icc_profile(gray);
4973 assert!(
4974 crate::reference::class_gated_context(&underivable, ColorModel::Rgb).is_none(),
4975 "nothing describable → no context (not an empty one)"
4976 );
4977 assert!(
4978 crate::reference::class_gated_context(&SourceColor::default(), ColorModel::Rgb)
4979 .is_none()
4980 );
4981 }
4982
4983 #[test]
4984 fn reference_check_all() {
4985 let (e, d) = ref_codecs();
4986 check_all(e, d).unwrap();
4987 }
4988
4989 #[test]
4990 fn minimal_check_all() {
4991 check_all(MinimalEncoderConfig::new(), MinimalDecoderConfig::new()).unwrap();
4992 }
4993
4994 #[test]
4998 fn reference_inventory_maps_every_section() {
4999 use zencodec::inventory::{Disposition, MetadataKind};
5000 let (e, d) = ref_codecs();
5001 let frames = [
5002 TestImage::rgba8_gradient_seeded(6, 4, 0),
5003 TestImage::rgba8_gradient_seeded(6, 4, 60),
5004 TestImage::rgba8_gradient_seeded(6, 4, 120),
5005 ];
5006 let meta = Metadata::none()
5007 .with_exif(fixtures::rich_exif_le())
5008 .with_xmp(fixtures::sample_xmp())
5009 .with_icc(fixtures::sample_icc())
5010 .with_cicp(Cicp::SRGB);
5011 let bytes = encode_animation(&e, &frames, meta).unwrap();
5012 check_inventory_dispatch(d.clone(), &bytes).unwrap();
5013
5014 let inv = d.clone().job().inventory(&bytes).unwrap();
5015 let count = |disp: Disposition| {
5016 inv.parts()
5017 .iter()
5018 .filter(|p| p.disposition() == disp)
5019 .count()
5020 };
5021 assert_eq!(count(Disposition::Metadata(MetadataKind::Exif)), 1, "{inv}");
5022 assert_eq!(count(Disposition::Metadata(MetadataKind::Xmp)), 1, "{inv}");
5023 assert_eq!(count(Disposition::Metadata(MetadataKind::Icc)), 1, "{inv}");
5024 assert_eq!(count(Disposition::Metadata(MetadataKind::Cicp)), 1, "{inv}");
5025 assert_eq!(
5026 count(Disposition::Metadata(MetadataKind::Animation)),
5027 3,
5028 "{inv}"
5029 );
5030 assert_eq!(count(Disposition::ImageData), 3, "{inv}");
5031 assert_eq!(
5032 inv.unconsumed().count(),
5033 0,
5034 "a clean file has no unconsumed bytes\n{inv}"
5035 );
5036
5037 let mut tail = bytes.clone();
5038 tail.extend_from_slice(b"camera serial 12345");
5039 let inv = d.job().inventory(&tail).unwrap();
5040 let unconsumed: Vec<_> = inv.unconsumed().collect();
5041 assert_eq!(unconsumed.len(), 1, "{inv}");
5042 assert_eq!(unconsumed[0].1.disposition(), Disposition::Trailing);
5043 assert_eq!(
5044 unconsumed[0].1.range(),
5045 bytes.len() as u64..tail.len() as u64
5046 );
5047 }
5048
5049 #[test]
5051 fn undeclared_inventory_is_unsupported() {
5052 let bytes = enc_oneshot(
5053 &MinimalEncoderConfig::new(),
5054 &TestImage::rgba8_gradient(5, 3),
5055 Metadata::none(),
5056 MetadataPolicy::PreserveExact,
5057 )
5058 .unwrap();
5059 assert!(!<MinimalDecoderConfig as DecoderConfig>::capabilities().inventory());
5060 check_inventory_dispatch(MinimalDecoderConfig::new(), &bytes).unwrap();
5061 }
5062
5063 #[test]
5067 fn reference_preserve_exact_keeps_everything() {
5068 let (e, d) = ref_codecs();
5069 let img = TestImage::rgba8_gradient(8, 8);
5070 let rich = Metadata::none()
5071 .with_exif(fixtures::rich_exif_le())
5072 .with_xmp(fixtures::sample_xmp())
5073 .with_icc(fixtures::sample_icc());
5074 let bytes = enc_oneshot(&e, &img, rich, MetadataPolicy::PreserveExact).unwrap();
5075 let (_, meta) = dec_oneshot(&d, &bytes).unwrap();
5076 let x = Exif::parse(meta.exif.as_deref().expect("exif kept")).expect("parses");
5077 assert!(x.has_gps() && x.has_thumbnail());
5078 assert_eq!(x.copyright().as_deref(), Some("(C) 2026 Test"));
5079 assert!(meta.xmp.is_some(), "xmp kept");
5080 assert!(meta.icc_profile.is_some(), "icc kept");
5081 }
5082
5083 #[test]
5086 fn reference_web_strips_privacy_keeps_rights() {
5087 let (e, d) = ref_codecs();
5088 let img = TestImage::rgba8_gradient(8, 8);
5089 let rich = Metadata::none()
5090 .with_exif(fixtures::rich_exif_le())
5091 .with_xmp(fixtures::sample_xmp());
5092 let bytes = enc_oneshot(&e, &img, rich, MetadataPolicy::Web).unwrap();
5093 let (_, meta) = dec_oneshot(&d, &bytes).unwrap();
5094 let x = Exif::parse(meta.exif.as_deref().expect("exif kept")).expect("parses");
5095 assert!(!x.has_gps(), "Web must strip GPS");
5096 assert!(!x.has_thumbnail(), "Web must strip the thumbnail");
5097 assert_eq!(
5098 x.copyright().as_deref(),
5099 Some("(C) 2026 Test"),
5100 "Web keeps rights"
5101 );
5102 assert!(meta.xmp.is_none(), "Web strips XMP");
5103 }
5104
5105 #[test]
5114 fn error_trace_preserves_lines_all_the_way_up() {
5115 use std::borrow::Cow;
5116 use zencodec::decode::{DecodeJob, DecoderConfig};
5117
5118 let origin: At<CodecError> = MinimalDecoderConfig::new()
5121 .job()
5122 .decoder(Cow::Borrowed(b"not a ZCR1 header"), &[])
5123 .expect_err("malformed header must fail");
5124 assert_eq!(
5125 origin.frame_count(),
5126 1,
5127 "the codec locates its error exactly once"
5128 );
5129 let f0 = origin
5130 .frames()
5131 .next()
5132 .and_then(|f| f.location())
5133 .expect("origin frame has a location");
5134 let origin_line = f0.line();
5135 assert!(
5136 f0.file().contains("minimal.rs"),
5137 "origin frame is attributed to the codec module, not the caller: {}",
5138 f0.file()
5139 );
5140
5141 let l1 = line!() + 1;
5144 let hop1 = origin.at();
5145 let l2 = line!() + 1;
5146 let climbed = hop1.at();
5147
5148 let locs: Vec<(String, u32)> = climbed
5150 .frames()
5151 .filter_map(|f| f.location().map(|l| (l.file().to_string(), l.line())))
5152 .collect();
5153 assert_eq!(locs.len(), 3, "origin + 2 hops = 3 frames, none lost");
5154 assert_eq!(
5155 locs[0].1, origin_line,
5156 "origin line preserved at the bottom of the trace"
5157 );
5158 assert!(locs[0].0.contains("minimal.rs"));
5159 assert_eq!(
5160 (locs[1].0.contains("lib.rs"), locs[1].1),
5161 (true, l1),
5162 "hop 1 file+line preserved"
5163 );
5164 assert_eq!(
5165 (locs[2].0.contains("lib.rs"), locs[2].1),
5166 (true, l2),
5167 "hop 2 file+line preserved"
5168 );
5169 assert_ne!(
5170 locs[0].0, locs[1].0,
5171 "codec-origin and caller frames are distinguishable by file (the crate-boundary signal)"
5172 );
5173
5174 let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(climbed);
5177 let recovered = boxed
5178 .downcast_ref::<At<CodecError>>()
5179 .expect("downcast the erased envelope");
5180 let after: Vec<(String, u32)> = recovered
5181 .frames()
5182 .filter_map(|f| f.location().map(|l| (l.file().to_string(), l.line())))
5183 .collect();
5184 assert_eq!(
5185 after, locs,
5186 "every frame + line survives the BoxedError round-trip"
5187 );
5188 }
5189
5190 #[test]
5196 fn error_trace_marks_crate_boundaries() {
5197 use whereat::AtCrateInfo;
5198
5199 static CODEC_CRATE: AtCrateInfo = AtCrateInfo::builder().name("demo-codec").build();
5201 static APP_CRATE: AtCrateInfo = AtCrateInfo::builder().name("demo-app").build();
5202
5203 let err = At::wrap(CodecError::new(
5204 Some("demo-codec"),
5205 zencodec::ErrorCategory::Image(zencodec::ImageError::Malformed),
5206 ))
5207 .at_crate(&CODEC_CRATE)
5208 .at()
5209 .at_crate(&APP_CRATE)
5210 .at();
5211
5212 let full = err.full_trace().to_string();
5215 assert!(
5216 full.contains("demo-codec") && full.contains("demo-app"),
5217 "both crate boundaries should be recorded + visible in the full trace:\n{full}"
5218 );
5219 }
5220}