Skip to main content

rawshift_image/formats/
mod.rs

1//! RAW format decoders.
2//!
3//! This module provides format-specific decoders for various RAW image formats.
4//! Use `RawFile::open()` as the common entry point for automatic format detection.
5
6#[cfg(feature = "arw-decode")]
7pub(crate) mod arw;
8#[cfg(feature = "cr2-decode")]
9pub(crate) mod cr2;
10#[cfg(feature = "cr3-decode")]
11pub(crate) mod cr3;
12#[cfg(feature = "crw-decode")]
13pub(crate) mod crw;
14#[cfg(feature = "dng-decode")]
15pub(crate) mod dng;
16#[cfg(feature = "dng-encode")]
17pub(crate) mod dng_export;
18mod encode;
19pub mod export;
20#[cfg(feature = "heic-decode")]
21pub(crate) mod heic;
22#[cfg(feature = "nef-decode")]
23pub(crate) mod nef;
24#[cfg(feature = "raf-decode")]
25pub(crate) mod raf;
26pub mod registry;
27pub(crate) mod standard;
28
29#[cfg(feature = "dng-encode")]
30pub use dng_export::{DngExportConfig, export_dng};
31pub use encode::{encode_rgb_image, encode_rgb_image_to_vec, encode_rgb_image_to_writer};
32#[cfg(feature = "heic-decode")]
33pub use heic::{HeicAuxImage, HeicAuxKind, HeicFile};
34pub use registry::{available_decoders, available_encoders};
35#[cfg(feature = "jxl-decode")]
36pub use standard::decode_jxl_partial;
37pub use standard::{
38    DecodeOptions, GifDecodeConfig, ImageAvifDecodeConfig, ImageProbe, JxlOxideDecodeConfig,
39    LibheifDecodeConfig, LibwebpDecodeConfig, ResvgDecodeConfig, StandardFormat, TiffDecodeConfig,
40    ZuneJpegDecodeConfig, ZunePngDecodeConfig, ZunePpmDecodeConfig, decode_standard_image,
41    decode_standard_image_with, detect_standard_format, probe_standard_image,
42    read_standard_image_metadata,
43};
44
45#[cfg(feature = "tiff-parser")]
46use crate::tiff::{TiffParser, TiffTag};
47
48#[cfg(any_raw)]
49use {
50    crate::core::image::{RawImage, RgbImage},
51    crate::error::{RawError, RawResult},
52    crate::processing::ProcessingOptions,
53    crate::transforms::{
54        apply_bad_pixel_correction, apply_bilateral_filter, apply_black_level, apply_ca_correction,
55        apply_color_matrix, apply_tone_reproduction, apply_white_balance, apply_white_balance_raw,
56        compute_camera_to_srgb,
57    },
58    export::EncodeOptions,
59    std::io::{Read, Seek, SeekFrom},
60    std::path::Path,
61    tracing::instrument,
62};
63
64#[cfg(any_raw)]
65/// Supported RAW file formats.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub enum RawFormat {
69    /// Sony ARW format
70    #[cfg(feature = "arw-decode")]
71    Arw,
72    /// Canon CR2 format
73    #[cfg(feature = "cr2-decode")]
74    Cr2,
75    /// Canon CR3 format
76    #[cfg(feature = "cr3-decode")]
77    Cr3,
78    /// Canon CRW (CIFF) format
79    #[cfg(feature = "crw-decode")]
80    Crw,
81    /// Adobe DNG format
82    #[cfg(feature = "dng-decode")]
83    Dng,
84    /// Nikon NEF format
85    #[cfg(feature = "nef-decode")]
86    Nef,
87    /// Fujifilm RAF format
88    #[cfg(feature = "raf-decode")]
89    Raf,
90}
91
92#[cfg(any_raw)]
93impl std::fmt::Display for RawFormat {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            #[cfg(feature = "arw-decode")]
97            RawFormat::Arw => write!(f, "ARW"),
98            #[cfg(feature = "cr2-decode")]
99            RawFormat::Cr2 => write!(f, "CR2"),
100            #[cfg(feature = "cr3-decode")]
101            RawFormat::Cr3 => write!(f, "CR3"),
102            #[cfg(feature = "crw-decode")]
103            RawFormat::Crw => write!(f, "CRW"),
104            #[cfg(feature = "dng-decode")]
105            RawFormat::Dng => write!(f, "DNG"),
106            #[cfg(feature = "nef-decode")]
107            RawFormat::Nef => write!(f, "NEF"),
108            #[cfg(feature = "raf-decode")]
109            RawFormat::Raf => write!(f, "RAF"),
110        }
111    }
112}
113
114#[cfg(any_raw)]
115/// Common entry point for parsing RAW files.
116///
117/// Wraps the specific format implementation for the detected file type.
118pub enum RawFile<R> {
119    /// Sony ARW format
120    #[cfg(feature = "arw-decode")]
121    Arw(Box<arw::ArwFile<R>>),
122    /// Canon CR2 format
123    #[cfg(feature = "cr2-decode")]
124    Cr2(Box<cr2::Cr2File<R>>),
125    /// Canon CR3 format
126    #[cfg(feature = "cr3-decode")]
127    Cr3(Box<cr3::Cr3File<R>>),
128    /// Canon CRW (CIFF) format
129    #[cfg(feature = "crw-decode")]
130    Crw(Box<crw::CrwFile<R>>),
131    /// Adobe DNG format
132    #[cfg(feature = "dng-decode")]
133    Dng(Box<dng::DngFile<R>>),
134    /// Nikon NEF format
135    #[cfg(feature = "nef-decode")]
136    Nef(Box<nef::NefFile<R>>),
137    /// Fujifilm RAF format
138    #[cfg(feature = "raf-decode")]
139    Raf(Box<raf::RafFile<R>>),
140}
141
142/// Macro that generates feature-gated match arms for uniform `RawFile` dispatch.
143///
144/// Use when every format variant calls the same method on its inner value:
145/// ```ignore
146/// raw_format_dispatch!(self, inner => inner.some_method())
147/// ```
148#[cfg(any_raw)]
149macro_rules! raw_format_dispatch {
150    ($self:expr, $inner:ident => $body:expr) => {
151        match $self {
152            #[cfg(feature = "arw-decode")]
153            Self::Arw($inner) => $body,
154            #[cfg(feature = "cr2-decode")]
155            Self::Cr2($inner) => $body,
156            #[cfg(feature = "cr3-decode")]
157            Self::Cr3($inner) => $body,
158            #[cfg(feature = "crw-decode")]
159            Self::Crw($inner) => $body,
160            #[cfg(feature = "dng-decode")]
161            Self::Dng($inner) => $body,
162            #[cfg(feature = "nef-decode")]
163            Self::Nef($inner) => $body,
164            #[cfg(feature = "raf-decode")]
165            Self::Raf($inner) => $body,
166        }
167    };
168}
169
170#[cfg(any_raw)]
171impl<R: Read + Seek> RawFile<R> {
172    /// Open and parse a RAW file, automatically detecting the format.
173    ///
174    /// This is the primary entry point for using valid file formats.
175    pub fn open(mut reader: R) -> RawResult<Self> {
176        let format = Self::detect_format(&mut reader)?;
177
178        match format {
179            #[cfg(feature = "arw-decode")]
180            RawFormat::Arw => {
181                let file = arw::ArwFile::parse(reader)?;
182                Ok(RawFile::Arw(Box::new(file)))
183            }
184            #[cfg(feature = "cr2-decode")]
185            RawFormat::Cr2 => {
186                let file = cr2::Cr2File::parse(reader)?;
187                Ok(RawFile::Cr2(Box::new(file)))
188            }
189            #[cfg(feature = "cr3-decode")]
190            RawFormat::Cr3 => {
191                let file = cr3::Cr3File::parse(reader)?;
192                Ok(RawFile::Cr3(Box::new(file)))
193            }
194            #[cfg(feature = "crw-decode")]
195            RawFormat::Crw => {
196                let file = crw::CrwFile::parse(reader)?;
197                Ok(RawFile::Crw(Box::new(file)))
198            }
199            #[cfg(feature = "dng-decode")]
200            RawFormat::Dng => {
201                let file = dng::DngFile::parse(reader)?;
202                Ok(RawFile::Dng(Box::new(file)))
203            }
204            #[cfg(feature = "nef-decode")]
205            RawFormat::Nef => {
206                let file = nef::NefFile::parse(reader)?;
207                Ok(RawFile::Nef(Box::new(file)))
208            }
209            #[cfg(feature = "raf-decode")]
210            RawFormat::Raf => {
211                let file = raf::RafFile::parse(reader)?;
212                Ok(RawFile::Raf(Box::new(file)))
213            }
214        }
215    }
216
217    /// Get unified metadata from this RAW file.
218    ///
219    /// This provides format-agnostic access to all available metadata.
220    pub fn metadata(&self) -> crate::core::ImageMetadata {
221        use crate::core::MetadataExtractor;
222        raw_format_dispatch!(self, inner => inner.extract_metadata())
223    }
224
225    /// Extract the embedded JPEG thumbnail from the RAW file, if available.
226    ///
227    /// Returns `Ok(Some(jpeg_bytes))` when a thumbnail is found, `Ok(None)` when
228    /// the format does not contain one (or extraction is not yet implemented).
229    pub fn thumbnail(&mut self) -> RawResult<Option<Vec<u8>>> {
230        raw_format_dispatch!(self, inner => inner.thumbnail())
231    }
232
233    /// Decode raw sensor data without processing.
234    ///
235    /// Returns the raw Bayer/X-Trans CFA data with original bit depth.
236    /// For LinearRaw DNG files, returns the already-demosaiced data as a RawImage.
237    pub fn decode_raw(&mut self) -> RawResult<RawImage> {
238        raw_format_dispatch!(self, inner => inner.decode_raw())
239    }
240
241    /// Decode and process to an in-memory RGB image.
242    ///
243    /// Runs the full processing pipeline (black level, WB, demosaic, color matrix,
244    /// denoise, CA correction, tone mapping, orientation) and returns the result
245    /// without writing to disk.
246    #[instrument(skip(self), fields(process = ?processing_options))]
247    pub fn process(&mut self, processing_options: &ProcessingOptions) -> RawResult<RgbImage> {
248        tracing::trace!("Processing raw file to RGB");
249
250        let mut wb_applied_to_cfa = false;
251
252        // 1. Obtain the initial RGB image
253        let mut rgb_image = if self.is_linear_raw_dng() {
254            #[cfg(feature = "dng-decode")]
255            {
256                tracing::trace!("Using LinearRaw path (already demosaiced)");
257                let RawFile::Dng(dng) = self else {
258                    unreachable!()
259                };
260
261                let metadata = dng.metadata();
262                let bit_depth = metadata.map(|m| m.bit_depth).unwrap_or(16);
263                let linearization_table = metadata.and_then(|m| m.linearization_table.as_ref());
264
265                let is_scaled_by_table = if let Some(table) = linearization_table {
266                    if !table.is_empty() {
267                        let max_val = table.iter().max().copied().unwrap_or(0);
268                        tracing::trace!("LinearizationTable present. Max value: {}", max_val);
269                        max_val > 4095
270                    } else {
271                        false
272                    }
273                } else {
274                    false
275                };
276
277                let mut image = dng.decode_linear_raw()?;
278
279                let shift = if is_scaled_by_table {
280                    0
281                } else {
282                    16u8.saturating_sub(bit_depth)
283                };
284
285                if shift > 0 {
286                    tracing::debug!(
287                        "Scaling {}-bit linear data to 16-bit (shift: {})",
288                        bit_depth,
289                        shift
290                    );
291                    for pixel in &mut image.data {
292                        let val = (*pixel as u32) << shift;
293                        *pixel = val.min(65535) as u16;
294                    }
295                }
296                image
297            }
298            #[cfg(not(feature = "dng-decode"))]
299            unreachable!()
300        } else {
301            tracing::trace!("Using standard CFA path (demosaicing needed)");
302
303            let cfa_wb = processing_options.white_balance.or_else(|| {
304                let meta = self.metadata();
305                if let Some(neutral) = meta.dng_color.as_shot_neutral
306                    && neutral[0] > 0.0
307                    && neutral[1] > 0.0
308                    && neutral[2] > 0.0
309                {
310                    tracing::trace!("Using AsShotNeutral from metadata: {:?}", neutral);
311                    return Some((
312                        1.0 / neutral[0] as f32,
313                        1.0 / neutral[1] as f32,
314                        1.0 / neutral[2] as f32,
315                    ));
316                }
317                tracing::warn!(
318                    "No white balance metadata found. Image may appear green (unbalanced)."
319                );
320                None
321            });
322
323            let mut raw_image = self.decode_raw()?;
324
325            apply_black_level(&mut raw_image);
326
327            if let Some(mode) = processing_options.bad_pixel_correction {
328                tracing::trace!("Applying bad pixel correction: {:?}", mode);
329                apply_bad_pixel_correction(&mut raw_image, mode, 0.5);
330            }
331
332            let effective_white = raw_image
333                .white_level()
334                .saturating_sub(raw_image.black_levels()[0]);
335            if let Some(coeffs) = cfa_wb {
336                apply_white_balance_raw(&mut raw_image, coeffs);
337                wb_applied_to_cfa = true;
338            }
339
340            if effective_white > 0 && effective_white < 65535 {
341                let scale = 65535.0 / effective_white as f32;
342                for pixel in &mut raw_image.data {
343                    *pixel = (*pixel as f32 * scale).min(65535.0) as u16;
344                }
345            }
346
347            let demosaic_impl = processing_options.demosaic.to_demosaic();
348            let mut rgb = demosaic_impl.demosaic(&raw_image);
349
350            rgb.set_baseline_exposure(raw_image.baseline_exposure());
351            rgb.set_default_crop(raw_image.default_crop());
352
353            rgb
354        };
355
356        // 2. Post-Processing Pipeline
357        tracing::trace!("Applying post-processing");
358
359        if let Some(exposure) = rgb_image.baseline_exposure() {
360            tracing::debug!(
361                "Applying BaselineExposure={:.2} EV with filmic tone mapping",
362                exposure
363            );
364        } else {
365            tracing::trace!("Applying filmic tone mapping (no BaselineExposure)");
366        }
367
368        if let Some(crop) = rgb_image.default_crop() {
369            tracing::trace!(
370                "Cropping to default crop: {}x{} at {},{}",
371                crop.size.width,
372                crop.size.height,
373                crop.origin.x,
374                crop.origin.y
375            );
376            crate::transforms::orientation::apply_crop(&mut rgb_image, crop);
377        }
378
379        let wb_coeffs = if wb_applied_to_cfa {
380            None
381        } else {
382            processing_options.white_balance.or_else(|| {
383                let meta = self.metadata();
384                if let Some(neutral) = meta.dng_color.as_shot_neutral
385                    && neutral[0] > 0.0
386                    && neutral[1] > 0.0
387                    && neutral[2] > 0.0
388                {
389                    tracing::trace!("Using AsShotNeutral from metadata: {:?}", neutral);
390                    return Some((
391                        1.0 / neutral[0] as f32,
392                        1.0 / neutral[1] as f32,
393                        1.0 / neutral[2] as f32,
394                    ));
395                }
396
397                if !self.is_linear_raw_dng() {
398                    tracing::warn!(
399                        "No white balance metadata found. Image may appear green (unbalanced)."
400                    );
401                }
402
403                None
404            })
405        };
406
407        if let Some(coeffs) = wb_coeffs {
408            tracing::trace!("Applying white balance: {:?}", coeffs);
409            apply_white_balance(&mut rgb_image, coeffs);
410        }
411
412        let color_matrix = processing_options.color_matrix.or_else(|| {
413            let meta = self.metadata();
414            let xyz_to_cam = meta
415                .dng_color
416                .color_matrix_2
417                .or(meta.dng_color.color_matrix_1)
418                .or_else(|| {
419                    let model = &meta.camera.model;
420                    if model.is_empty() {
421                        return None;
422                    }
423                    let cal = crate::data::cameras::find_camera_calibration(model)?;
424                    tracing::trace!("Using camera database color matrix for {}", model);
425                    cal.color_matrix_2.or(cal.color_matrix_1)
426                });
427
428            if let Some(ref cm) = xyz_to_cam {
429                match compute_camera_to_srgb(cm) {
430                    Some(m) => {
431                        tracing::trace!("Auto-resolved camera-to-sRGB color matrix");
432                        Some(m)
433                    }
434                    None => {
435                        tracing::warn!("Color matrix is singular, skipping color correction");
436                        None
437                    }
438                }
439            } else {
440                tracing::debug!("No color matrix available in metadata or camera database");
441                None
442            }
443        });
444        if let Some(matrix) = color_matrix {
445            tracing::trace!("Applying color matrix");
446            apply_color_matrix(&mut rgb_image, &matrix);
447        }
448
449        if let Some(sigma) = processing_options.denoise_sigma {
450            tracing::trace!("Applying bilateral denoise: sigma={}", sigma);
451            let radius = (sigma * 2.0).ceil() as u32;
452            apply_bilateral_filter(&mut rgb_image, sigma, sigma * 10000.0, radius);
453        }
454
455        if let Some((red_scale, blue_scale)) = processing_options.ca_correction {
456            tracing::trace!(
457                "Applying CA correction: red_scale={}, blue_scale={}",
458                red_scale,
459                blue_scale
460            );
461            apply_ca_correction(&mut rgb_image, red_scale, blue_scale);
462        }
463
464        if let Some(g) = processing_options.gamma {
465            tracing::trace!("Applying custom gamma override: {}", g);
466        }
467        apply_tone_reproduction(&mut rgb_image, processing_options.gamma);
468
469        let raw_orientation = self.metadata().image.orientation.unwrap_or(1);
470        if raw_orientation != 1 {
471            tracing::trace!("Applying orientation transform: {}", raw_orientation);
472            crate::transforms::orientation::apply_orientation(&mut rgb_image, raw_orientation);
473        }
474
475        // The pipeline emits display-referred sRGB after tone reproduction.
476        rgb_image.set_color_space(crate::core::ColorSpace::Srgb);
477
478        Ok(rgb_image)
479    }
480
481    /// Export the raw file to an image format based on the encoded options.
482    ///
483    /// This runs the full processing pipeline:
484    /// 1. Decode raw data
485    /// 2. Apply black level subtraction and normalization
486    /// 3. Demosaic
487    /// 4. Apply White Balance (if specified)
488    /// 5. Apply Color Matrix (if specified)
489    /// 6. Apply Gamma Correction (if specified)
490    /// 7. Save to disk using format-specific encoder
491    #[instrument(
492        skip(self),
493        fields(
494            path = %path.as_ref().display(),
495            process = ?processing_options,
496            encode = ?encode_options
497        )
498    )]
499    pub fn export<P: AsRef<Path>>(
500        &mut self,
501        path: P,
502        processing_options: &ProcessingOptions,
503        encode_options: &EncodeOptions,
504    ) -> RawResult<()> {
505        tracing::trace!("Exporting raw file");
506
507        let rgb_image = self.process(processing_options)?;
508
509        // Build metadata for EXIF embedding.
510        // If orientation was applied to pixel data, mark the output as orientation=1 (Normal)
511        // so viewers don't apply it a second time.
512        let raw_orientation = self.metadata().image.orientation.unwrap_or(1);
513        let exif_metadata = {
514            let mut m = self.metadata();
515            if raw_orientation != 1 {
516                m.image.orientation = Some(1);
517            }
518            m
519        };
520
521        tracing::info!("Encoding image to disk: {:?}", path.as_ref());
522        encode_rgb_image(&rgb_image, &exif_metadata, path.as_ref(), encode_options)
523    }
524
525    /// Helper to check if the current file is a LinearRaw DNG
526    pub fn is_linear_raw_dng(&self) -> bool {
527        match self {
528            #[cfg(feature = "dng-decode")]
529            RawFile::Dng(dng) => dng.metadata().map(|m| m.is_linear_raw).unwrap_or(false),
530            #[cfg(feature = "arw-decode")]
531            RawFile::Arw(_) => false,
532            #[cfg(feature = "cr2-decode")]
533            RawFile::Cr2(_) => false,
534            #[cfg(feature = "cr3-decode")]
535            RawFile::Cr3(_) => false,
536            #[cfg(feature = "crw-decode")]
537            RawFile::Crw(_) => false,
538            #[cfg(feature = "nef-decode")]
539            RawFile::Nef(_) => false,
540            #[cfg(feature = "raf-decode")]
541            RawFile::Raf(_) => false,
542        }
543    }
544
545    /// Detect the format of the provided reader.
546    fn detect_format(reader: &mut R) -> RawResult<RawFormat> {
547        // Read magic bytes (16 bytes covers TIFF header + CR2 magic at offset 8,
548        // and the full RAF magic string). We also read up to 14 bytes for CRW.
549        let start = reader.stream_position()?;
550        let mut header = [0u8; 16];
551        reader.read_exact(&mut header)?;
552        reader.seek(SeekFrom::Start(start))?;
553
554        // Check for Fujifilm RAF magic first (not TIFF-based)
555        #[cfg(feature = "raf-decode")]
556        if raf::is_raf(&header) {
557            return Ok(RawFormat::Raf);
558        }
559
560        // CR3 detection must come BEFORE the TIFF check because CR3 uses ISOBMFF
561        // (not TIFF) and would otherwise be rejected as an unsupported format.
562        #[cfg(feature = "cr3-decode")]
563        if cr3::is_cr3(&header) {
564            return Ok(RawFormat::Cr3);
565        }
566
567        // CRW detection must come BEFORE the TIFF check. CRW uses II/MM + 0x0001
568        // (not 0x002A) and has "HEAPCCDR" at bytes 6..14. A standard TIFF parser
569        // would reject it because the magic number is wrong.
570        #[cfg(feature = "crw-decode")]
571        if crw::is_crw(&header) {
572            return Ok(RawFormat::Crw);
573        }
574
575        // Check for TIFF magic (II or MM at offset 0)
576        let is_tiff = (header[0] == b'I' && header[1] == b'I' && header[2] == 42 && header[3] == 0)
577            || (header[0] == b'M' && header[1] == b'M' && header[2] == 0 && header[3] == 42);
578
579        if !is_tiff {
580            return Err(RawError::Unsupported(
581                "Not a TIFF-based RAW file".to_string(),
582            ));
583        }
584
585        // CR2 detection via magic bytes at offset 8: "CR" + 0x02
586        // This is faster than parsing IFDs and more reliable.
587        #[cfg(feature = "cr2-decode")]
588        if cr2::is_cr2(&header) {
589            return Ok(RawFormat::Cr2);
590        }
591
592        // Parse as TIFF to inspect Make tag for format detection
593        #[cfg(feature = "tiff-parser")]
594        {
595            let mut parser = TiffParser::new(reader)?;
596            let ifd0 = parser.parse_ifd0()?;
597
598            // Check for DNG version first - if present, it's a DNG regardless of Make
599            #[cfg(feature = "dng-decode")]
600            if ifd0.get(TiffTag::DNGVersion).is_some() {
601                return Ok(RawFormat::Dng);
602            }
603
604            // Check Make tag to determine specific format
605            if let Some(make_entry) = ifd0.get(TiffTag::Make)
606                && let Ok(value) = parser.read_value(make_entry)
607                && let Some(make) = value.as_str()
608            {
609                let make_lower = make.to_lowercase();
610                #[cfg(feature = "arw-decode")]
611                if make_lower.contains("sony") {
612                    return Ok(RawFormat::Arw);
613                }
614                // Add more manufacturers here as we add support
615                #[cfg(feature = "nef-decode")]
616                if make_lower.contains("nikon") {
617                    return Ok(RawFormat::Nef);
618                }
619            }
620        }
621
622        // Default to DNG for unrecognized TIFF-based formats (or return unsupported)
623        Err(RawError::Unsupported(
624            "Unrecognized camera manufacturer".to_string(),
625        ))
626    }
627}
628#[cfg(test)]
629mod tests {
630    #[cfg(any_raw)]
631    use super::{RawFile, RawFormat};
632    #[cfg(any_raw)]
633    use crate::error::RawError;
634    #[cfg(any_raw)]
635    use std::io::Cursor;
636
637    /// Verify that applying WB before normalization prevents highlight clipping
638    /// for neutral-gray pixels that would otherwise be pushed above 65535.
639    #[test]
640    fn test_wb_before_normalization_no_clipping_for_midtones() {
641        // Simulate 14-bit sensor: white_level=16383, black_level=512
642        // WB: R=2.35, G=1.0, B=1.65 (typical daylight for Sony APS-C)
643        let white_level: u16 = 16383;
644        let black_level: u16 = 512;
645        let effective_white = white_level - black_level; // 15871
646        let (r_gain, g_gain, b_gain) = (2.35f32, 1.0f32, 1.65f32);
647
648        // A neutral gray at 50% brightness:
649        // R_raw_neutral = effective_white / r_gain / 2 ≈ 3377 (+ black = 3889)
650        // G_raw_neutral = effective_white / 2 ≈ 7936 (+ black = 8448)
651        // B_raw_neutral = effective_white / b_gain / 2 ≈ 4810 (+ black = 5322)
652        let r_raw: u16 = 3377; // after black subtraction
653        let g_raw: u16 = 7936;
654        let b_raw: u16 = 4810;
655
656        // Apply WB and clamp to effective_white
657        let white_f = effective_white as f32;
658        let r_wb = (r_raw as f32 * r_gain).min(white_f) as u16;
659        let g_wb = (g_raw as f32 * g_gain).min(white_f) as u16;
660        let b_wb = (b_raw as f32 * b_gain).min(white_f) as u16;
661
662        // None should exceed effective_white
663        assert!(
664            r_wb <= effective_white,
665            "R clipped: {r_wb} > {effective_white}"
666        );
667        assert!(
668            g_wb <= effective_white,
669            "G clipped: {g_wb} > {effective_white}"
670        );
671        assert!(
672            b_wb <= effective_white,
673            "B clipped: {b_wb} > {effective_white}"
674        );
675
676        // After 16-bit normalization, all channels should be approximately equal
677        // (neutral gray should be neutral after WB)
678        let scale = 65535.0 / effective_white as f32;
679        let r_16 = (r_wb as f32 * scale) as u16;
680        let g_16 = (g_wb as f32 * scale) as u16;
681        let b_16 = (b_wb as f32 * scale) as u16;
682
683        // All should be close to ~32767 (50% of 65535), with ≤5% tolerance
684        let expected = 32767u16;
685        let tolerance = 2000u16;
686        assert!(
687            r_16.abs_diff(expected) < tolerance,
688            "R {r_16} not near {expected}"
689        );
690        assert!(
691            g_16.abs_diff(expected) < tolerance,
692            "G {g_16} not near {expected}"
693        );
694        assert!(
695            b_16.abs_diff(expected) < tolerance,
696            "B {b_16} not near {expected}"
697        );
698    }
699
700    /// Verify that the old approach (normalize-then-WB) clips midtones incorrectly.
701    /// This demonstrates the bug that the fix addresses.
702    #[test]
703    fn test_old_approach_clips_midtones() {
704        let white_level: u16 = 16383;
705        let black_level: u16 = 512;
706        let effective_white = white_level - black_level; // 15871
707        let r_gain = 2.35f32;
708
709        // A red pixel at ~44% of effective_white (just above the clipping threshold
710        // in the OLD normalize-then-WB pipeline)
711        let r_raw: u16 = (effective_white as f32 * 0.44) as u16; // ~6983
712
713        // Old pipeline: bit-shift to 16-bit FIRST, then WB
714        let shift = 16u8.saturating_sub(14); // bit_depth=14, shift=2
715        let r_shifted = ((r_raw as u32) << shift).min(65535) as u16; // *4
716        let r_old = (r_shifted as f32 * r_gain).min(65535.0) as u16;
717
718        // New pipeline: WB first (clamped to effective_white), then normalize
719        let white_f = effective_white as f32;
720        let r_wb = (r_raw as f32 * r_gain).min(white_f) as u16;
721        let scale = 65535.0 / effective_white as f32;
722        let r_new = (r_wb as f32 * scale).min(65535.0) as u16;
723
724        // Old approach clips to 65535 (wrong - this is only ~44% brightness)
725        assert_eq!(r_old, 65535, "Old approach should clip to white");
726
727        // New approach produces ~65535 too because 0.44 * 2.35 > 1.0,
728        // meaning this pixel IS genuinely overexposed for red at this WB setting.
729        // The important case is pixels BELOW the channel white point (effective_white/gain).
730        let r_channel_white = (effective_white as f32 / r_gain) as u16; // ~6753
731        let r_neutral_50pct = r_channel_white / 2; // ~3377
732
733        let r_wb_neutral = (r_neutral_50pct as f32 * r_gain).min(white_f) as u16;
734        let r_new_neutral = (r_wb_neutral as f32 * scale).min(65535.0) as u16;
735
736        let r_shifted_neutral = ((r_neutral_50pct as u32) << shift).min(65535) as u16;
737        let r_old_neutral = (r_shifted_neutral as f32 * r_gain).min(65535.0) as u16;
738
739        // Old approach: a 50%-brightness neutral red pixel does NOT clip
740        assert!(r_old_neutral < 65535, "Old neutral 50% should not clip");
741        // New approach: same, but also scales correctly to white level
742        assert!(r_new_neutral < 65535, "New neutral 50% should not clip");
743        // New approach gives a value close to ~32767 (50% of full range)
744        assert!(
745            r_new_neutral.abs_diff(32767) < 3000,
746            "New neutral 50% {r_new_neutral} should be ~50% of 65535"
747        );
748
749        // Suppress unused variable warning
750        let _ = r_new;
751    }
752
753    #[cfg(any_raw)]
754    #[test]
755    fn test_detect_format_invalid_magic() {
756        // This data is valid length but has wrong magic bytes
757        // We pad to 16+ bytes to satisfy the header read
758        let mut data = vec![0u8; 32];
759        data[..14].copy_from_slice(b"not a raw file");
760        let mut cursor = Cursor::new(data);
761        let result = RawFile::detect_format(&mut cursor);
762        assert!(
763            matches!(result, Err(RawError::Unsupported(_))),
764            "Should fail with UnsupportedFormat for invalid magic: {:?}",
765            result
766        );
767    }
768
769    #[cfg(feature = "tiff-parser")]
770    #[test]
771    fn test_detect_format_tiff_no_make() {
772        // Valid TIFF header but no Make tag - should return UnsupportedFormat
773        // Pad to ensure enough data for parser
774        let mut data = vec![0u8; 32];
775        data[0..2].copy_from_slice(b"II");
776        data[2..4].copy_from_slice(&42u16.to_le_bytes());
777        data[4..8].copy_from_slice(&8u32.to_le_bytes()); // IFD at offset 8
778        data[8..10].copy_from_slice(&0u16.to_le_bytes()); // 0 entries
779        data[10..14].copy_from_slice(&0u32.to_le_bytes()); // no next IFD
780
781        let mut cursor = Cursor::new(data);
782        let result = RawFile::detect_format(&mut cursor);
783        assert!(
784            matches!(result, Err(RawError::Unsupported(_))),
785            "Should fail with UnsupportedFormat for unrecognized camera: {:?}",
786            result
787        );
788    }
789
790    #[cfg(feature = "dng-decode")]
791    #[test]
792    fn test_detect_format_dng() {
793        // Mock TIFF with DNGVersion tag
794        let mut data = vec![0u8; 64];
795        // TIFF Header (LE)
796        data[0..2].copy_from_slice(b"II");
797        data[2..4].copy_from_slice(&42u16.to_le_bytes());
798        data[4..8].copy_from_slice(&8u32.to_le_bytes());
799
800        // IFD at offset 8
801        let entry_count = 1u16;
802        data[8..10].copy_from_slice(&entry_count.to_le_bytes());
803
804        // Entry 1: DNGVersion (0xC612)
805        // Tag (2), Type (1=Byte), Count (4), Value/Offset (1,2,3,4)
806        data[10..12].copy_from_slice(&0xC612u16.to_le_bytes());
807        data[12..14].copy_from_slice(&1u16.to_le_bytes()); // Type Byte
808        data[14..18].copy_from_slice(&4u32.to_le_bytes()); // Count 4
809        data[18..22].copy_from_slice(&[1, 1, 0, 0]); // Version 1.1.0.0
810
811        // Next IFD (0)
812        data[22..26].copy_from_slice(&0u32.to_le_bytes());
813
814        let mut cursor = Cursor::new(data);
815        let result = RawFile::detect_format(&mut cursor);
816        assert!(matches!(result, Ok(RawFormat::Dng)));
817    }
818
819    #[cfg(feature = "dng-decode")]
820    #[test]
821    fn test_detect_format_sony_dng() {
822        // Mock TIFF with BOTH DNGVersion and Make="Sony"
823        // Should be detected as DNG, not ARW
824        let mut data = vec![0u8; 128];
825        // TIFF Header (LE)
826        data[0..2].copy_from_slice(b"II");
827        data[2..4].copy_from_slice(&42u16.to_le_bytes());
828        data[4..8].copy_from_slice(&8u32.to_le_bytes());
829
830        // IFD at offset 8
831        let entry_count = 2u16;
832        data[8..10].copy_from_slice(&entry_count.to_le_bytes());
833
834        // Entry 1: Make (0x010F), Type ASCII (2), Count 5 ("Sony\0"), Offset to data
835        let make_offset = 64u32;
836        data[10..12].copy_from_slice(&0x010Fu16.to_le_bytes());
837        data[12..14].copy_from_slice(&2u16.to_le_bytes());
838        data[14..18].copy_from_slice(&5u32.to_le_bytes());
839        data[18..22].copy_from_slice(&make_offset.to_le_bytes());
840
841        // Entry 2: DNGVersion (0xC612)
842        // Tag (2), Type (1=Byte), Count (4), Value/Offset (1,2,3,4)
843        data[22..24].copy_from_slice(&0xC612u16.to_le_bytes());
844        data[24..26].copy_from_slice(&1u16.to_le_bytes());
845        data[26..30].copy_from_slice(&4u32.to_le_bytes());
846        data[30..34].copy_from_slice(&[1, 1, 0, 0]);
847
848        // Next IFD (0)
849        data[34..38].copy_from_slice(&0u32.to_le_bytes());
850
851        // String data at offset 64
852        data[64..69].copy_from_slice(b"Sony\0");
853
854        let mut cursor = Cursor::new(data);
855        let result = RawFile::detect_format(&mut cursor);
856        assert!(matches!(result, Ok(RawFormat::Dng)));
857    }
858
859    // -------------------------------------------------------------------------
860    // Tests for orientation transforms (via transforms::orientation module)
861    // -------------------------------------------------------------------------
862
863    fn make_test_rgb(width: u32, height: u32, data: Vec<u16>) -> crate::core::image::RgbImage {
864        crate::core::image::RgbImage::new(width, height, data)
865    }
866
867    #[test]
868    fn test_flip_horizontal_2x1() {
869        use crate::transforms::orientation::flip_horizontal;
870        let mut img = make_test_rgb(2, 1, vec![10, 11, 12, 20, 21, 22]);
871        flip_horizontal(&mut img);
872        assert_eq!(img.data, vec![20, 21, 22, 10, 11, 12]);
873    }
874
875    #[test]
876    fn test_rotate_180() {
877        use crate::transforms::orientation::rotate_180;
878        let mut img = make_test_rgb(2, 1, vec![1, 2, 3, 4, 5, 6]);
879        rotate_180(&mut img);
880        assert_eq!(img.data, vec![4, 5, 6, 1, 2, 3]);
881    }
882
883    #[test]
884    fn test_rotate_90_cw_1x2() {
885        use crate::transforms::orientation::rotate_90_cw;
886        let mut img = make_test_rgb(1, 2, vec![1, 2, 3, 4, 5, 6]);
887        rotate_90_cw(&mut img);
888        assert_eq!(img.width(), 2);
889        assert_eq!(img.height(), 1);
890        assert_eq!(img.data, vec![4, 5, 6, 1, 2, 3]);
891    }
892
893    #[test]
894    fn test_rotate_90_ccw_2x1() {
895        use crate::transforms::orientation::rotate_90_ccw;
896        let mut img = make_test_rgb(2, 1, vec![1, 2, 3, 4, 5, 6]);
897        rotate_90_ccw(&mut img);
898        assert_eq!(img.width(), 1);
899        assert_eq!(img.height(), 2);
900        assert_eq!(img.data, vec![4, 5, 6, 1, 2, 3]);
901    }
902
903    #[test]
904    fn test_orientation_identity() {
905        use crate::transforms::orientation::apply_orientation;
906        let mut img = make_test_rgb(2, 2, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
907        let original = img.data.clone();
908        apply_orientation(&mut img, 1);
909        assert_eq!(img.data, original);
910    }
911
912    #[test]
913    fn test_orientation_6_cw_then_ccw_is_identity() {
914        use crate::transforms::orientation::apply_orientation;
915        let mut img = make_test_rgb(
916            3,
917            2,
918            vec![
919                1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
920            ],
921        );
922        let original_data = img.data.clone();
923        let original_w = img.width();
924        let original_h = img.height();
925        apply_orientation(&mut img, 6); // 90° CW
926        apply_orientation(&mut img, 8); // 90° CCW (should undo it)
927        assert_eq!(img.width(), original_w);
928        assert_eq!(img.height(), original_h);
929        assert_eq!(img.data, original_data);
930    }
931
932    #[cfg(any_raw)]
933    #[test]
934    fn test_open_empty_reader_returns_error() {
935        // An empty reader cannot be a valid RAW file
936        let cursor = Cursor::new(vec![]);
937        let result = RawFile::open(cursor);
938        assert!(
939            result.is_err(),
940            "Opening an empty reader should return an error"
941        );
942    }
943
944    #[cfg(any_raw)]
945    #[test]
946    fn test_detect_format_empty_returns_error() {
947        // detect_format on an empty buffer should return an Io error (UnexpectedEof)
948        let mut cursor = Cursor::new(vec![]);
949        let result = RawFile::detect_format(&mut cursor);
950        assert!(
951            result.is_err(),
952            "detect_format on empty input should return an error"
953        );
954    }
955}