diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 2f04f016b..500a66347 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -108,15 +108,24 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { ifd.set(t::PHOTOMETRIC, Value::Short(vec![if raw.cfa.is_some() { photometric::CFA } else { photometric::LINEAR_RAW }])); ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); ifd.set(t::DNG_BACKWARD_VERSION, Value::Byte(vec![1, 3, 0, 0])); - let make = raw.metadata.make.clone().unwrap_or_default(); - let model = raw.metadata.model.clone().unwrap_or_default(); + let make = raw.metadata.make.as_deref().unwrap_or_default(); + let model = raw.metadata.model.as_deref().unwrap_or_default(); if !make.is_empty() { - ifd.set(t::MAKE, Value::Ascii(make.clone())); + ifd.set(t::MAKE, Value::Ascii(make.to_owned())); } if !model.is_empty() { - ifd.set(t::MODEL, Value::Ascii(model.clone())); + ifd.set(t::MODEL, Value::Ascii(model.to_owned())); } - let unique = format!("{make} {model}").trim().to_string(); + let model_includes_make = !make.is_empty() + && model.get(..make.len()).is_some_and(|prefix| prefix.eq_ignore_ascii_case(make)) + && model.get(make.len()..).is_some_and(|suffix| suffix.chars().next().is_none_or(|c| !c.is_alphanumeric() && c != '_')); + let unique = if model_includes_make || make.is_empty() { + model.to_owned() + } else if model.is_empty() { + make.to_owned() + } else { + format!("{make} {model}") + }; ifd.set(t::UNIQUE_CAMERA_MODEL, Value::Ascii(if unique.is_empty() { "LightCraft".into() } else { unique })); ifd.set(t::ORIENTATION, Value::Short(vec![raw.orientation.to_exif()])); ifd.set(t::SOFTWARE, Value::Ascii("LightCraft".into())); diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 44527cf9c..14daaaf99 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -90,6 +90,45 @@ fn assert_same(a: &RawImage, b: &RawImage) { assert_eq!(b.metadata.rating, Some(3)); } +#[test] +fn writer_camera_identity_tags_roundtrip() { + let cases = [ + (Some("Canon"), Some("Canon EOS R6"), "Canon EOS R6"), + (Some("CANON"), Some("cAnOn EOS R6"), "cAnOn EOS R6"), + (Some("Canon"), Some("Canon"), "Canon"), + (Some("Canon"), Some("Canon-EOS R6"), "Canon-EOS R6"), + (Some("Canon"), Some("Canon_EOS R6"), "Canon Canon_EOS R6"), + (Some("Canon"), Some("Canonic R6"), "Canon Canonic R6"), + (Some("Canon"), Some("Canon2 R6"), "Canon Canon2 R6"), + (Some("Canon"), Some("Canoné R6"), "Canon Canoné R6"), + (Some("Canon"), Some("EOS R6"), "Canon EOS R6"), + (Some("Eastman Kodak"), Some("eAsTmAn kOdAk DCS Pro"), "eAsTmAn kOdAk DCS Pro"), + (Some("Eastman Kodak"), Some("Eastman Kodakish DCS Pro"), "Eastman Kodak Eastman Kodakish DCS Pro"), + (Some("Eastman Kodak"), Some("Eastman DCS Pro"), "Eastman Kodak Eastman DCS Pro"), + (Some(""), Some("EOS R6"), "EOS R6"), + (Some("Canon"), Some(""), "Canon"), + (Some(""), Some(""), "LightCraft"), + (None, Some("EOS R6"), "EOS R6"), + (Some("Canon"), None, "Canon"), + (None, None, "LightCraft"), + ]; + for (make, model, unique) in cases { + let mut raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1); + raw.metadata.make = make.map(str::to_owned); + raw.metadata.model = model.map(str::to_owned); + for order in [ByteOrder::Little, ByteOrder::Big] { + let bytes = write_dng(&raw, &DngWriteOptions { compression: DngCompression::Uncompressed, order, xmp: None }).unwrap(); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let ifd = &tiff.ifds[0]; + assert_eq!(ifd.value(t::UNIQUE_CAMERA_MODEL), Some(&Value::Ascii(unique.into())), "{make:?} / {model:?}, {order:?}"); + for (tag, original) in [(t::MAKE, make), (t::MODEL, model)] { + let expected = original.filter(|s| !s.is_empty()).map(|s| Value::Ascii(s.into())); + assert_eq!(ifd.value(tag), expected.as_ref(), "tag {tag}, {make:?} / {model:?}, {order:?}"); + } + } + } +} + #[test] fn writer_roundtrip_all_layouts() { for (w, h) in [(64, 48), (70, 45), (33, 17)] { diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..57c61e7ba 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-13 + +### DNG camera identity +- DNG export and Convert to DNG no longer duplicate the camera make when the model already + contains it: `Canon EOS R6` stays `Canon EOS R6`, rather than `Canon Canon EOS R6` (the camera + identity subissue in #840). The separate Make and Model tags keep their original values. + This does not change colour matrices, camera calibration or RAW noise rendering. + ## 2026-10-11 ### JPEG XL export