Skip to main content

rawshift_image/formats/
dng_export.rs

1//! DNG export for demosaiced linear RGB images.
2//!
3//! This module provides functionality to export processed images as
4//! DNG 1.7 compliant files with complete metadata preservation.
5
6use std::fs::File;
7use std::io::{BufWriter, Seek, Write};
8use std::path::Path;
9
10use crate::core::image::RgbImage;
11use crate::core::metadata::ImageMetadata;
12use crate::error::RawResult;
13use crate::tiff::writer::{IfdEntry, TiffWriter};
14use crate::tiff::{ByteOrder, TiffTag};
15
16/// DNG export configuration.
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct DngExportConfig {
20    /// Software name to embed (defaults to "rawshift")
21    pub software: Option<String>,
22    /// Whether to embed EXIF metadata
23    pub embed_exif: bool,
24    /// Whether to embed GPS metadata (if available)
25    pub embed_gps: bool,
26}
27
28impl DngExportConfig {
29    /// Create a new config with defaults for archival export.
30    pub fn archival() -> Self {
31        Self {
32            software: Some(format!("rawshift {}", env!("CARGO_PKG_VERSION"))),
33            embed_exif: true,
34            embed_gps: true,
35        }
36    }
37}
38
39/// Export an RGB image as a demosaiced linear DNG into any seekable writer.
40///
41/// The DNG container is written via [`TiffWriter`], which needs `Seek` to
42/// backfill the IFD0 offset; an in-memory [`std::io::Cursor`] satisfies this.
43///
44/// The output is DNG 1.7 compliant with:
45/// - PhotometricInterpretation = 2 (RGB)
46/// - 16-bit per channel
47/// - Embedded color matrices and white balance
48/// - Full EXIF metadata (if available)
49pub fn export_dng_to_writer<W: Write + Seek>(
50    writer: W,
51    image: &RgbImage,
52    metadata: &ImageMetadata,
53    config: &DngExportConfig,
54) -> RawResult<()> {
55    let mut writer = TiffWriter::new(writer, ByteOrder::LittleEndian);
56
57    // Write TIFF header
58    writer.write_header()?;
59
60    // Write image data first to get offset
61    let (strip_offset, strip_bytes) = writer.write_image_strip_rgb16(&image.data)?;
62
63    // Build IFD entries
64    let mut entries = build_dng_ifd(image, metadata, config, strip_offset, strip_bytes);
65
66    // Write IFD
67    let ifd_offset = writer.write_ifd(&mut entries, 0)?;
68
69    // Update IFD0 offset in header
70    writer.update_ifd0_offset(ifd_offset as u32)?;
71
72    Ok(())
73}
74
75/// Export an RGB image as a demosaiced linear DNG file.
76///
77/// Thin wrapper over [`export_dng_to_writer`] that creates the file at `path`.
78pub fn export_dng(
79    path: &Path,
80    image: &RgbImage,
81    metadata: &ImageMetadata,
82    config: &DngExportConfig,
83) -> RawResult<()> {
84    let file = File::create(path)?;
85    export_dng_to_writer(BufWriter::new(file), image, metadata, config)
86}
87
88/// Build the IFD entries for a DNG file.
89fn build_dng_ifd(
90    image: &RgbImage,
91    metadata: &ImageMetadata,
92    config: &DngExportConfig,
93    strip_offset: u64,
94    strip_bytes: u64,
95) -> Vec<IfdEntry> {
96    let mut entries = vec![
97        // === Required TIFF baseline tags ===
98        IfdEntry::long(TiffTag::ImageWidth, image.width()),
99        IfdEntry::long(TiffTag::ImageLength, image.height()),
100        IfdEntry::shorts(TiffTag::BitsPerSample, &[16, 16, 16]),
101        IfdEntry::short(TiffTag::Compression, 1), // Uncompressed
102        IfdEntry::short(TiffTag::PhotometricInterpretation, 2), // RGB
103        IfdEntry::short(TiffTag::SamplesPerPixel, 3),
104        IfdEntry::long(TiffTag::RowsPerStrip, image.height()),
105        IfdEntry::long(TiffTag::StripOffsets, strip_offset as u32),
106        IfdEntry::long(TiffTag::StripByteCounts, strip_bytes as u32),
107        IfdEntry::short(TiffTag::PlanarConfiguration, 1), // Chunky
108        // Resolution (72 dpi default)
109        IfdEntry::rational(TiffTag::XResolution, 72, 1),
110        IfdEntry::rational(TiffTag::YResolution, 72, 1),
111        IfdEntry::short(TiffTag::ResolutionUnit, 2), // Inch
112    ];
113
114    // Orientation
115    if let Some(orient) = metadata.image.orientation {
116        entries.push(IfdEntry::short(TiffTag::Orientation, orient));
117    } else {
118        entries.push(IfdEntry::short(TiffTag::Orientation, 1)); // Normal
119    }
120
121    // === DNG version tags ===
122    entries.push(IfdEntry::bytes(TiffTag::DNGVersion, &[1, 7, 0, 0]));
123    entries.push(IfdEntry::bytes(TiffTag::DNGBackwardVersion, &[1, 7, 0, 0]));
124
125    // === Camera identification ===
126    if !metadata.camera.make.is_empty() {
127        entries.push(IfdEntry::ascii(TiffTag::Make, &metadata.camera.make));
128    }
129    if !metadata.camera.model.is_empty() {
130        entries.push(IfdEntry::ascii(TiffTag::Model, &metadata.camera.model));
131    }
132
133    // UniqueCameraModel (required for DNG)
134    let unique_model = metadata
135        .camera
136        .unique_camera_model
137        .as_deref()
138        .unwrap_or_else(|| {
139            if !metadata.camera.model.is_empty() {
140                &metadata.camera.model
141            } else {
142                "Unknown Camera"
143            }
144        });
145    entries.push(IfdEntry::ascii(TiffTag::UniqueCameraModel, unique_model));
146
147    // === Software ===
148    if let Some(ref sw) = config.software {
149        entries.push(IfdEntry::ascii(TiffTag::Software, sw));
150    }
151
152    // === Color calibration ===
153    // ColorMatrix1 is required for DNG
154    if let Some(cm1) = &metadata.dng_color.color_matrix_1 {
155        // Convert f64 to SRATIONAL (multiply by 10000 for precision)
156        let rationals: Vec<(i32, i32)> = cm1
157            .iter()
158            .map(|&v| ((v * 10000.0).round() as i32, 10000))
159            .collect();
160        entries.push(IfdEntry::srationals(TiffTag::ColorMatrix1, &rationals));
161    } else {
162        // Default sRGB-ish color matrix if none provided
163        let default_cm: Vec<(i32, i32)> = [1.0f64, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
164            .iter()
165            .map(|&v| ((v * 10000.0).round() as i32, 10000))
166            .collect();
167        entries.push(IfdEntry::srationals(TiffTag::ColorMatrix1, &default_cm));
168    }
169
170    // CalibrationIlluminant1
171    if let Some(ill1) = metadata.dng_color.calibration_illuminant_1 {
172        entries.push(IfdEntry::short(TiffTag::CalibrationIlluminant1, ill1));
173    } else {
174        entries.push(IfdEntry::short(TiffTag::CalibrationIlluminant1, 21)); // D65
175    }
176
177    // ColorMatrix2 (if available)
178    if let Some(cm2) = &metadata.dng_color.color_matrix_2 {
179        let rationals: Vec<(i32, i32)> = cm2
180            .iter()
181            .map(|&v| ((v * 10000.0).round() as i32, 10000))
182            .collect();
183        entries.push(IfdEntry::srationals(TiffTag::ColorMatrix2, &rationals));
184
185        if let Some(ill2) = metadata.dng_color.calibration_illuminant_2 {
186            entries.push(IfdEntry::short(TiffTag::CalibrationIlluminant2, ill2));
187        } else {
188            entries.push(IfdEntry::short(TiffTag::CalibrationIlluminant2, 17)); // Standard Light A
189        }
190    }
191
192    // AsShotNeutral
193    if let Some(neutral) = &metadata.dng_color.as_shot_neutral {
194        let rationals: Vec<(u32, u32)> = neutral
195            .iter()
196            .map(|&v| ((v * 1000000.0).round() as u32, 1000000))
197            .collect();
198        entries.push(IfdEntry::rationals(TiffTag::AsShotNeutral, &rationals));
199    }
200
201    // AnalogBalance
202    if let Some(balance) = &metadata.dng_color.analog_balance {
203        let rationals: Vec<(u32, u32)> = balance
204            .iter()
205            .map(|&v| ((v * 1000000.0).round() as u32, 1000000))
206            .collect();
207        entries.push(IfdEntry::rationals(TiffTag::AnalogBalance, &rationals));
208    }
209
210    // === Calibration ===
211    if let Some(be) = metadata.dng_calibration.baseline_exposure {
212        // Convert to SRATIONAL
213        let int_part = (be * 100.0).round() as i32;
214        entries.push(IfdEntry::srational(
215            TiffTag::BaselineExposure,
216            int_part,
217            100,
218        ));
219    }
220
221    // Black/White levels
222    if !metadata.image.black_levels.is_empty() {
223        // For RGB, we typically have 3 black levels
224        let black: Vec<(u32, u32)> = metadata
225            .image
226            .black_levels
227            .iter()
228            .take(3)
229            .map(|&v| (v, 1))
230            .collect();
231        if black.len() == 3 {
232            entries.push(IfdEntry::rationals(TiffTag::BlackLevel, &black));
233        }
234    }
235
236    if let Some(wl) = metadata.image.white_level {
237        entries.push(IfdEntry::longs(TiffTag::WhiteLevel, &[wl, wl, wl]));
238    } else {
239        // Default to 16-bit max
240        entries.push(IfdEntry::longs(TiffTag::WhiteLevel, &[65535, 65535, 65535]));
241    }
242
243    entries
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::core::image::RgbImage;
250
251    #[test]
252    fn test_build_dng_ifd() {
253        let image = RgbImage::new(100, 50, vec![0u16; 100 * 50 * 3]);
254        let metadata = ImageMetadata::default();
255        let config = DngExportConfig::archival();
256
257        let entries = build_dng_ifd(&image, &metadata, &config, 1024, 30000);
258
259        // Check required tags are present
260        assert!(
261            entries
262                .iter()
263                .any(|e| e.tag == TiffTag::ImageWidth.as_u16())
264        );
265        assert!(
266            entries
267                .iter()
268                .any(|e| e.tag == TiffTag::ImageLength.as_u16())
269        );
270        assert!(
271            entries
272                .iter()
273                .any(|e| e.tag == TiffTag::DNGVersion.as_u16())
274        );
275        assert!(
276            entries
277                .iter()
278                .any(|e| e.tag == TiffTag::UniqueCameraModel.as_u16())
279        );
280        assert!(
281            entries
282                .iter()
283                .any(|e| e.tag == TiffTag::ColorMatrix1.as_u16())
284        );
285    }
286}