From f9c97ab57bebe082f33aeeb76e2f27a239e41f10 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 11:09:25 +0200 Subject: [PATCH 1/2] Metadata: read the Panasonic lens name from maker note tag 0x0051 (#1671) Panasonic writes no Exif LensModel. The lens name is in its maker note (LensType, 0x0051), and a RW2 or RWL keeps that maker note only in the Exif of its embedded JpgFromRaw (IFD0 0x002E), not in the raw's own Exif IFD. When LensModel is absent, read it from the maker note, and for a raw from the JpgFromRaw's Exif. Checked on the CC0 samples DC-GX9 gx9_1_1.RW2 (LUMIX G 25/F1.7) and DC-S5 P1047510.RW2 (LUMIX S 85/F1.8), also through the sparse header plan an import uses. --- crates/meta/src/exif.rs | 103 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index ca8d7b7aa..51da692e8 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -16,8 +16,14 @@ pub fn read_exif(bytes: &[u8]) -> Metadata { /// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. pub fn try_read_exif(bytes: &[u8]) -> Result { - let tiff = Tiff::parse(strip_exif_header(bytes))?; - Ok(from_tiff(&tiff)) + let data = strip_exif_header(bytes); + let tiff = Tiff::parse(data)?; + let mut m = from_tiff(&tiff); + // Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif) + if m.lens_model.is_none() { + m.lens_model = panasonic_lens(data, &tiff).or_else(|| panasonic_jpg_from_raw_lens(&tiff)); + } + Ok(m) } fn text(ifd: Option<&Ifd>, tag: u16) -> Option { @@ -138,11 +144,104 @@ fn read_gps(g: &Ifd) -> Option { Some(Gps { latitude: lat, longitude: lon, altitude }) } +/// Panasonic maker note tag `0x0051` (LensType in the ExifTool Panasonic tag names): the lens name, ASCII. +const PANASONIC_LENS_TYPE: u16 = 0x0051; +/// RW2/RWL IFD0 tag `0x002E` (JpgFromRaw): a JPEG whose APP1 holds the full Exif, maker note included. +const PANASONIC_JPG_FROM_RAW: u16 = 0x002E; + +/// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header). +fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option { + let exif = tiff.exif()?; + if !exif.bytes(t::MAKER_NOTE)?.starts_with(b"Panasonic\0") { + return None; + } + let e = exif.get(t::MAKER_NOTE)?; + let make = tiff.ifds.first().and_then(|i| i.string(t::MAKE)).unwrap_or_default(); + let mn = lightcraft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, &make)?; + text(Some(&mn.ifd), PANASONIC_LENS_TYPE) +} + +/// The lens name from the Exif of a RW2's (or Leica RWL's) embedded JpgFromRaw. +fn panasonic_jpg_from_raw_lens(tiff: &Tiff) -> Option { + let jpg = tiff.ifds.first()?.bytes(PANASONIC_JPG_FROM_RAW)?; + let exif = crate::jpeg_segments(jpg).exif?; + let data = strip_exif_header(&exif); + panasonic_lens(data, &Tiff::parse(data).ok()?) +} + #[cfg(test)] pub(crate) mod tests { use super::*; use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + /// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value + /// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it). + fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec { + let note = |at: u32| { + let mut v = b"Panasonic\0\0\0".to_vec(); + order.put_u16(&mut v, 1); + order.put_u16(&mut v, if lens.is_some() { PANASONIC_LENS_TYPE } else { 0x0001 }); + order.put_u16(&mut v, 2); + let name = format!("{:<34}\0", lens.unwrap_or("0210")); + order.put_u32(&mut v, name.len() as u32); + order.put_u32(&mut v, at + 30); + order.put_u32(&mut v, 0); + v.extend_from_slice(name.as_bytes()); + v + }; + let build = |at: u32| { + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note(at))); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(25, 1)])); + if let Some(l) = lens_model { + exif.set(t::LENS_MODEL, Value::Ascii(l.into())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + }; + // the note's own offset is only known once it is laid out; its length doesn't change + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as u32; + build(at) + } + + #[test] + fn panasonic_maker_note_names_the_lens() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let m = read_exif(&panasonic_exif(order, Some("LUMIX G 25/F1.7"), None)); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX G 25/F1.7")); + assert_eq!(m.focal_length, Some(25.0)); + } + // Exif's own LensModel wins, and a note without 0x0051 leaves the lens empty + let m = read_exif(&panasonic_exif(ByteOrder::Little, Some("LUMIX G 25/F1.7"), Some("Other 25mm"))); + assert_eq!(m.lens_model.as_deref(), Some("Other 25mm")); + assert_eq!(read_exif(&panasonic_exif(ByteOrder::Little, None, None)).lens_model, None); + } + + #[test] + fn rw2_reads_the_lens_from_its_jpg_from_raw() { + // a RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend(panasonic_exif(ByteOrder::Little, Some("LUMIX S 85/F1.8"), None)); + jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpg.extend(app1); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + let mut exif = IfdBuilder::new(); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(85, 1)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); + ifd0.set_child(t::EXIF_IFD, exif); + let rw2 = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let m = read_exif(&rw2); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + assert_eq!(m.focal_length, Some(85.0)); + assert_eq!(crate::extract(&rw2).lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + } + pub(crate) fn sample_exif(order: ByteOrder) -> Vec { let mut exif = IfdBuilder::new(); exif.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 250)])); From fbe3810aa82aace7ccb4190bb01d3a43194b87f7 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 17:42:09 +0200 Subject: [PATCH 2/2] Metadata: list the Panasonic lens name in All Metadata (#1671) photo.allMetadata listed no lens for a RW2: Panasonic writes no Exif LensModel, and the maker note that names the lens is in the Exif of the embedded JpgFromRaw. List that maker note tag (LensType, 0x0051) as a row of a Panasonic group, the same name photo.inspect shows, for a RW2/RWL and for a JPEG with the note in its own Exif. Checked on the CC0 sample DMC-G80 P1000403.RW2: Panasonic / Lens Type = SIGMA 60mm F2.8 DN. --- crates/meta/src/exif.rs | 24 +++++++++++++++++------- crates/meta/src/tags.rs | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index 51da692e8..2ce5ff1a2 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -21,7 +21,7 @@ pub fn try_read_exif(bytes: &[u8]) -> Result { let mut m = from_tiff(&tiff); // Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif) if m.lens_model.is_none() { - m.lens_model = panasonic_lens(data, &tiff).or_else(|| panasonic_jpg_from_raw_lens(&tiff)); + m.lens_model = panasonic_maker_note_lens(data, &tiff); } Ok(m) } @@ -149,6 +149,12 @@ const PANASONIC_LENS_TYPE: u16 = 0x0051; /// RW2/RWL IFD0 tag `0x002E` (JpgFromRaw): a JPEG whose APP1 holds the full Exif, maker note included. const PANASONIC_JPG_FROM_RAW: u16 = 0x002E; +/// The lens name in the Panasonic maker note (tag `0x0051`) of this Exif or RW2 stream (`data` starts at its +/// TIFF header): the note of its own Exif IFD, else, for a raw, the note in its JpgFromRaw's Exif. +pub(crate) fn panasonic_maker_note_lens(data: &[u8], tiff: &Tiff) -> Option { + panasonic_lens(data, tiff).or_else(|| panasonic_jpg_from_raw_lens(tiff)) +} + /// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header). fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option { let exif = tiff.exif()?; @@ -176,7 +182,7 @@ pub(crate) mod tests { /// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value /// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it). - fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec { + pub(crate) fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec { let note = |at: u32| { let mut v = b"Panasonic\0\0\0".to_vec(); order.put_u16(&mut v, 1); @@ -220,12 +226,11 @@ pub(crate) mod tests { assert_eq!(read_exif(&panasonic_exif(ByteOrder::Little, None, None)).lens_model, None); } - #[test] - fn rw2_reads_the_lens_from_its_jpg_from_raw() { - // a RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD + /// A RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD. + pub(crate) fn panasonic_rw2(lens: &str) -> Vec { let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; let mut app1 = b"Exif\0\0".to_vec(); - app1.extend(panasonic_exif(ByteOrder::Little, Some("LUMIX S 85/F1.8"), None)); + app1.extend(panasonic_exif(ByteOrder::Little, Some(lens), None)); jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); jpg.extend(app1); jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); @@ -235,7 +240,12 @@ pub(crate) mod tests { ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); ifd0.set_child(t::EXIF_IFD, exif); - let rw2 = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + #[test] + fn rw2_reads_the_lens_from_its_jpg_from_raw() { + let rw2 = panasonic_rw2("LUMIX S 85/F1.8"); let m = read_exif(&rw2); assert_eq!(m.lens_model.as_deref(), Some("LUMIX S 85/F1.8")); assert_eq!(m.focal_length, Some(85.0)); diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index 39e26097d..3cbab5cc2 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -1,14 +1,15 @@ //! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info //! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; //! common enumerations are spelled out, everything else is shown as stored. Maker notes and -//! binary blobs are summarised by size. +//! binary blobs are summarised by size; of a maker note only the lens name is listed (the +//! Panasonic one, as Panasonic writes no Exif LensModel). use lightcraft_tiff::{Ifd, Tiff, Value}; /// One metadata row. #[derive(Clone, Debug, PartialEq)] pub struct TagRow { - /// `TIFF`, `EXIF`, `GPS`, `Interop`. + /// `TIFF`, `EXIF`, `GPS`, `Interop`, or a maker note's (`Panasonic`). pub group: &'static str, pub tag: u16, pub name: String, @@ -309,6 +310,13 @@ fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> { nef_compression(mn.ifd.u32(0x0093)?) } +/// The Panasonic maker note's lens name (LensType, `0x0051`) as a row, the name `photo.inspect` shows as the lens. +fn panasonic_lens_row(data: &[u8], tiff: &Tiff, out: &mut Vec) { + if let Some(lens) = crate::exif::panasonic_maker_note_lens(data, tiff) { + out.push(TagRow { group: "Panasonic", tag: 0x0051, name: "Lens Type".into(), value: lens.trim().to_string() }); + } +} + /// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, /// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor /// image's, not IFD0's thumbnail's. @@ -347,10 +355,15 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec { { row.value = words.to_string(); } + panasonic_lens_row(bytes, &t, &mut rows); return rows; } let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; - Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default() + let data = crate::strip_exif_header(&exif); + let Ok(t) = Tiff::parse(data) else { return Vec::new() }; + let mut rows = tag_rows(&t); + panasonic_lens_row(data, &t, &mut rows); + rows } #[cfg(test)] @@ -461,4 +474,22 @@ mod tests { let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]); } + + #[test] + fn panasonic_lens_is_listed_from_the_maker_note() { + let lens = |rows: &[TagRow]| rows.iter().filter(|r| r.group == "Panasonic" && r.tag == 0x0051).map(|r| r.value.clone()).collect::>(); + // a RW2: the note is only in the JpgFromRaw's Exif + let rw2 = crate::exif::tests::panasonic_rw2("LUMIX S 85/F1.8"); + assert_eq!(lens(&file_tag_rows(&rw2)), ["LUMIX S 85/F1.8"]); + // a JPEG: the note is in its own Exif + let exif = crate::exif::tests::panasonic_exif(lightcraft_tiff::ByteOrder::Big, Some("LUMIX G 25/F1.7"), None); + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpg.extend(b"Exif\0\0"); + jpg.extend(exif); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + assert_eq!(lens(&file_tag_rows(&jpg)), ["LUMIX G 25/F1.7"]); + // no Panasonic note, no row + assert!(lens(&file_tag_rows(&synthetic_raw(Some(3)))).is_empty()); + } }