diff --git a/Cargo.lock b/Cargo.lock index cf4937b93..5607ee5ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,13 +59,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -479,9 +494,20 @@ version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", + "brotli-decompressor 5.0.3", +] + +[[package]] +name = "brotli" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.3.1", ] [[package]] @@ -490,8 +516,18 @@ version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", +] + +[[package]] +name = "brotli-decompressor" +version = "6.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -1322,6 +1358,8 @@ dependencies = [ name = "craft-metadata" version = "0.1.0" dependencies = [ + "brotli 9.1.1", + "brotli-decompressor 6.3.1", "chrono", "craft-tiff", "proptest", @@ -3137,7 +3175,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" name = "hayro-cmap" version = "0.1.0" dependencies = [ - "brotli", + "brotli 8.0.4", "hayro-postscript", ] @@ -3920,7 +3958,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" dependencies = [ - "brotli-decompressor", + "brotli-decompressor 5.0.3", "jxl-bitstream", "jxl-frame", "jxl-grid", @@ -3952,7 +3990,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" dependencies = [ - "brotli-decompressor", + "brotli-decompressor 5.0.3", "jxl-bitstream", "jxl-color", "jxl-frame", diff --git a/crates/metadata/Cargo.toml b/crates/metadata/Cargo.toml index 207492619..28764c041 100644 --- a/crates/metadata/Cargo.toml +++ b/crates/metadata/Cargo.toml @@ -15,7 +15,11 @@ craft-tiff = { workspace = true } serde = { workspace = true } quick-xml = "0.41" chrono = { workspace = true } +# JPEG XL `brob` metadata boxes +brotli-decompressor = { version = "6", default-features = false, features = ["std"] } [dev-dependencies] proptest = { workspace = true } serde_json = { workspace = true } +# writes the `brob` boxes of the JPEG XL tests +brotli = { version = "9", default-features = false, features = ["std"] } diff --git a/crates/metadata/README.md b/crates/metadata/README.md index 1345ead5f..5bdb7f1c0 100644 --- a/crates/metadata/README.md +++ b/crates/metadata/README.md @@ -19,8 +19,9 @@ are one crate. | `write_xmp(&Metadata) -> String` | A complete `` packet. `write_xmp_with` sets the toolkit name and adds simple properties in the caller's namespace. | | `merge_xmp(existing, fresh, MergeRules)` | Updates a packet another program wrote: owned properties are replaced, everything else stays byte for byte. | | `parse_gpx(&str) -> Result` | GPX track points; `Tracklog::locate` gives the position at a capture time. | -| `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF and Canon CR3 and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. | +| `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF, Canon CR3 and JPEG XL containers and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. | | `cr3::parse_cr3` | The Canon CR3 container: `CMT1`…`CMT4`, the XMP `uuid` box, the thumbnail, the tracks. | +| `jxl::metadata` | The `Exif` and `xml ` boxes of a JPEG XL container, plain or Brotli-wrapped in `brob`. | | `extract(&[u8]) -> Metadata` | All of it for a whole file. Precedence: EXIF, then IPTC fills gaps, then XMP overrides the user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. `from_embedded` does the same for blocks already located. | HEIF, PSD and MRW files are read by `craft-heif`, `craft-psd` and `craft-raw`. Put the blocks they @@ -47,7 +48,7 @@ A foreign namespace that uses a recognised prefix cannot supply recognised prope ## Limits XMP: 64 levels, 200 000 nodes, 1 MiB of flattened keys and 8 MiB of opaque names per packet -(`XmpError::TooComplex`). GPX: 64 levels, 5 000 000 points. CR3: 4096 boxes. JPEG ICC is returned only when its APP2 chunks form a +(`XmpError::TooComplex`). GPX: 64 levels, 5 000 000 points. CR3 and JPEG XL: 4096 boxes; a `brob` box inflates to at most 32 MiB. JPEG ICC is returned only when its APP2 chunks form a complete, consistent, uniquely numbered sequence. TIFF limits are `craft-tiff`'s. No input panics: proptests feed random bytes to every container walker and random text to the XMP parser, and a sample JPEG is cut at every byte. diff --git a/crates/metadata/src/container.rs b/crates/metadata/src/container.rs index 660677581..439771b98 100644 --- a/crates/metadata/src/container.rs +++ b/crates/metadata/src/container.rs @@ -9,6 +9,8 @@ //! IPTC (33723) from IFD0. //! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the //! XMP `uuid` box. +//! - JPEG XL (ISO base media file, see [`crate::jxl`]): the `Exif` and `xml ` boxes, plain or Brotli-wrapped +//! in `brob`; a bare codestream has no metadata. //! //! PSD, MRW and HEIF files are read by `craft-psd`, `craft-raw` and `craft-heif`. @@ -41,6 +43,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded { if let Some(c) = crate::cr3::parse_cr3(bytes) { return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; } + if crate::jxl::is_jxl_container(bytes) { + return crate::jxl::metadata(bytes); + } if bytes.starts_with(&[0xff, 0xd8]) { jpeg_segments(bytes) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { diff --git a/crates/metadata/src/datetime.rs b/crates/metadata/src/datetime.rs index 0ecedae36..13ab90bca 100644 --- a/crates/metadata/src/datetime.rs +++ b/crates/metadata/src/datetime.rs @@ -14,6 +14,30 @@ pub struct DateTime { /// Sub-second part in milliseconds. pub millis: u16, pub offset_minutes: Option, + /// How much of the date was stated. XMP dates may stop at the year (`1983`) or the month + /// (`1983-04`), as for a scanned print whose day isn't known; the missing parts read as 1 and + /// the time as midnight, and the date writes back as stated. + #[serde(default, skip_serializing_if = "DatePrecision::is_full")] + pub precision: DatePrecision, +} + +/// How much of a [`DateTime`] was stated. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DatePrecision { + /// The day at least (a time of day may be midnight because none was given). + #[default] + Full, + /// The year and month only (`1983-04`). + Month, + /// The year only (`1983`). + Year, +} + +impl DatePrecision { + pub fn is_full(&self) -> bool { + *self == DatePrecision::Full + } } fn num(s: &str) -> Option { @@ -66,6 +90,7 @@ impl DateTime { second: second.try_into().ok()?, millis: 0, offset_minutes: None, + precision: DatePrecision::Full, } .valid() } @@ -102,7 +127,8 @@ impl DateTime { self } - /// ISO 8601 (XMP date): `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDThh:mm[:ss[.s+]][TZD]`. + /// ISO 8601 (XMP date): `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDThh:mm[:ss[.s+]][TZD]`. A + /// year or a month alone keeps its [`DatePrecision`]. pub fn parse_iso(s: &str) -> Option { let s = s.trim(); let (d, t) = match s.split_once('T') { @@ -117,8 +143,17 @@ impl DateTime { if dp.len() > 3 { return None; } - let mut dt = DateTime { year, month, day, ..Default::default() }; + let precision = match dp.len() { + 1 => DatePrecision::Year, + 2 => DatePrecision::Month, + _ => DatePrecision::Full, + }; + let mut dt = DateTime { year, month, day, precision, ..Default::default() }; if !t.is_empty() { + // a time of day needs the day it is on + if !dt.precision.is_full() { + return None; + } let (clock, off) = match t.find(['Z', '+', '-']) { Some(i) => (&t[..i], Some(&t[i..])), None => (t, None), @@ -151,13 +186,25 @@ impl DateTime { dt.valid() } + /// Whether the day was stated: Exif's date form can't say a year or a month alone, so only a + /// full date is written there. + pub fn is_full(&self) -> bool { + self.precision.is_full() + } + /// Exif form `YYYY:MM:DD HH:MM:SS`. pub fn to_exif(&self) -> String { format!("{:04}:{:02}:{:02} {:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second) } - /// ISO 8601 with milliseconds when non-zero and the offset when known. + /// ISO 8601 with milliseconds when non-zero and the offset when known; a year or a month + /// alone as stated (`1983`, `1983-04`). pub fn to_iso(&self) -> String { + match self.precision { + DatePrecision::Year => return format!("{:04}", self.year), + DatePrecision::Month => return format!("{:04}-{:02}", self.year, self.month), + DatePrecision::Full => {} + } let mut s = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second); if self.millis != 0 { s += &format!(".{:03}", self.millis); @@ -233,6 +280,29 @@ mod tests { } } + /// XMP dates may stop at the year or the month (a scanned print's `photoshop:DateCreated`): + /// read with that precision, written back as stated, and never as an Exif date. + #[test] + fn a_year_or_month_alone_reads_and_writes_as_stated() { + for (iso, precision) in [("1983", DatePrecision::Year), ("1983-04", DatePrecision::Month)] { + let d = DateTime::parse_iso(iso).unwrap(); + assert_eq!((d.year, d.month, d.day, d.precision), (1983, if iso.len() == 7 { 4 } else { 1 }, 1, precision), "{iso}"); + assert!(!d.is_full()); + assert_eq!(d.to_iso(), iso); + let meta = crate::Metadata { capture_time: Some(d), ..Default::default() }; + let xmp = crate::write_xmp(&meta); + assert!(!xmp.contains("DateTimeOriginal"), "{xmp}"); + assert_eq!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time, Some(d), "{xmp}"); + assert_eq!(crate::read_exif(&crate::write_exif(&meta)).capture_time, None, "Exif can't say {iso}"); + } + for not in ["0000", "1983-00", "1983-13", "1983-04T10:00:00", "1983T10:00"] { + assert_eq!(DateTime::parse_iso(not), None, "{not}"); + } + let full = DateTime::parse_iso("1983-04-12").unwrap(); + assert!(full.is_full()); + assert_eq!(full.to_iso(), "1983-04-12T00:00:00"); + } + #[test] fn offsets() { assert_eq!(DateTime::parse_offset("+02:00"), Some(120)); @@ -299,8 +369,8 @@ mod tests { #[test] fn iso_validates_clock_while_preserving_supported_forms() { - assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019-01-01T00:00:00"); - assert_eq!(DateTime::parse_iso("2019-07").unwrap().to_iso(), "2019-07-01T00:00:00"); + assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019"); + assert_eq!(DateTime::parse_iso("2019-07").unwrap().to_iso(), "2019-07"); assert_eq!(DateTime::parse_iso("2019-07-04").unwrap().to_iso(), "2019-07-04T00:00:00"); assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().to_iso(), "2019-07-04T10:11:00"); assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12").unwrap().to_iso(), "2019-07-04T10:11:12"); diff --git a/crates/metadata/src/exif.rs b/crates/metadata/src/exif.rs index 47c9cd196..c691b7fb0 100644 --- a/crates/metadata/src/exif.rs +++ b/crates/metadata/src/exif.rs @@ -1,7 +1,7 @@ //! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics). use crate::{DateTime, Flash, Gps, Metadata, Orientation}; -use craft_tiff::{Ifd, ParseOptions, Tiff, TiffError, tags as t}; +use craft_tiff::{FieldType, Ifd, ParseOptions, Tiff, TiffError, tags as t}; /// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks. pub fn strip_exif_header(b: &[u8]) -> &[u8] { @@ -29,8 +29,9 @@ pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result Option { tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif) } +/// The ISO an Exif IFD states beyond the 16-bit ISOSpeedRatings: RecommendedExposureIndex, else +/// ISOSpeedRatingsExt. A zero states nothing. +fn extended_iso(exif: Option<&Ifd>) -> Option { + let e = exif?; + let stated = |tag| e.u64(tag).filter(|&v| v > 0); + stated(t::RECOMMENDED_EXPOSURE_INDEX).or_else(|| stated(t::ISO_SPEED_RATINGS_EXT)) +} + +/// `iso`, unless it is only the overflow marker of this stream's ISOSpeedRatings, which states no ISO. +fn without_iso_overflow(iso: Option, tiff: &Tiff) -> Option { + // 65535 is an overflow marker only in the short field; a LONG or an extended measurement may equal it. + let entry = tiff.exif().and_then(|e| e.get(t::ISO_SPEED)).or_else(|| tiff.ifds.first().and_then(|i| i.get(t::ISO_SPEED))); + let short = entry.is_some_and(|e| e.field_type() == FieldType::Short); + iso.filter(|&v| v != u32::from(u16::MAX) || !short || extended_iso(tiff.exif()).is_some()) +} + /// Build [`Metadata`] from a parsed TIFF stream (IFD0 + Exif + GPS; DNG tags as fallbacks). pub fn from_tiff(tiff: &Tiff) -> Metadata { let ifd0 = tiff.ifds.first(); @@ -83,14 +100,17 @@ pub fn from_tiff(tiff: &Tiff) -> Metadata { m.lens_spec = nikon_lens_spec(exif, m.make.as_deref(), tiff.order); } m.orientation = ifd0.and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif); - m.rating = ifd0.and_then(|i| i.i64(t::RATING)).map(|r| r.clamp(-1, 5) as i8); + m.rating = ifd0.and_then(|i| i.i64(t::RATING)).map(|r| { + let r = if r == 65535 || r == -1 { -1 } else { r }; + r.clamp(-1, 5) as i8 + }); m.exposure_time = pos(either(t::EXPOSURE_TIME).and_then(|v| v.get_f64(0))) .or_else(|| exif.and_then(|e| e.f64(t::SHUTTER_SPEED_VALUE)).filter(|v| v.abs() < 64.0).map(|tv| 2f64.powf(-tv))); m.f_number = pos(either(t::F_NUMBER).and_then(|v| v.get_f64(0))) .or_else(|| exif.and_then(|e| e.f64(t::APERTURE_VALUE)).filter(|v| v.abs() < 64.0).map(|av| 2f64.powf(av / 2.0))); let iso = either(t::ISO_SPEED).and_then(|v| v.get_u64(0)).filter(|&v| v > 0); - let iso_ext = exif.and_then(|e| e.u64(t::RECOMMENDED_EXPOSURE_INDEX).or_else(|| e.u64(t::ISO_SPEED_RATINGS_EXT))).filter(|&v| v > 0); + let iso_ext = extended_iso(exif); m.iso = match (iso, iso_ext) { (Some(65535), Some(x)) | (None, Some(x)) => Some(x), (Some(v), _) => Some(v), @@ -187,9 +207,14 @@ const PANASONIC_LENS_TYPE: u16 = 0x0051; 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. +/// TIFF header): the note of its own Exif IFD, else, for a RW2, 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)) + panasonic_lens(data, tiff).or_else(|| is_panasonic_raw(data).then(|| panasonic_jpg_from_raw_lens(tiff, &ParseOptions::default())).flatten()) +} + +/// A RW2/RWL stream: its private IFD0 tags, the JpgFromRaw too, mean nothing in any other TIFF. +fn is_panasonic_raw(data: &[u8]) -> bool { + data.starts_with(b"IIU\0") } /// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header). @@ -204,12 +229,47 @@ fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option { text(Some(&mn.ifd), PANASONIC_LENS_TYPE) } +/// RW2/RWL IFD0 tags `0x0017` (ISO, int16u) and `0x0037` (the int32u ISO of newer bodies). +const PANASONIC_ISO: u16 = 0x0017; +const PANASONIC_ISO_32: u16 = 0x0037; + +/// A RW2's own Exif IFD is a short subset (older bodies such as the GF6 or GM1 leave out ISOSpeedRatings and +/// more). The full Exif is in the APP1 of its JpgFromRaw: fill what the raw stream lacks from there, then fall +/// back to the ISO in the Panasonic raw IFD0. The raw's own dimensions stay; the preview's would be smaller. +fn fill_from_panasonic_raw(m: &mut Metadata, data: &[u8], tiff: &Tiff, opts: &ParseOptions) { + if !is_panasonic_raw(data) { + return; + } + let Some(ifd0) = tiff.ifds.first() else { return }; + m.iso = without_iso_overflow(m.iso, tiff); + if let Some(jpg) = ifd0.bytes(PANASONIC_JPG_FROM_RAW) + && let Some(exif) = crate::jpeg_segments(jpg).exif + && let Ok(inner) = Tiff::parse_with(strip_exif_header(&exif), opts) + { + let mut full = from_tiff(&inner); + // Reuse this parse for the lens name too, under the same caller policy. + if full.lens_model.is_none() { + full.lens_model = panasonic_lens(strip_exif_header(&exif), &inner); + } + (full.width, full.height) = (None, None); + full.iso = without_iso_overflow(full.iso, &inner); + m.fill_missing(&full); + } + if m.iso.is_none() { + // Ifd::u32 also accepts untyped bytes; these ISO fields have specific unsigned types. + let iso = |tag, kind| ifd0.get(tag).filter(|entry| entry.field_type() == kind).and_then(|_| ifd0.u32(tag)); + m.iso = iso(PANASONIC_ISO, FieldType::Short) + .filter(|&v| v > 0 && v < u32::from(u16::MAX)) + .or_else(|| iso(PANASONIC_ISO_32, FieldType::Long).filter(|&v| v > 0 && v < u32::MAX)); + } +} + /// 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 { +fn panasonic_jpg_from_raw_lens(tiff: &Tiff, opts: &ParseOptions) -> 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()?) + panasonic_lens(data, &Tiff::parse_with(data, opts).ok()?) } /// Canon maker note tag `0x0095` (LensModel in the ExifTool Canon tag names): the lens name as ASCII. Older @@ -360,7 +420,9 @@ 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); - TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + let mut bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + bytes[2..4].copy_from_slice(&0x55u16.to_le_bytes()); + bytes } #[test] @@ -372,6 +434,285 @@ pub(crate) mod tests { assert_eq!(crate::extract(&rw2).lens_model.as_deref(), Some("LUMIX S 85/F1.8")); } + /// A RW2 whose own short Exif IFD lacks ISO, with `inner` as the Exif of its JpgFromRaw and `raw_iso` in + /// the Panasonic IFD0 tag 0x0017. + fn rw2_with(inner: Option, raw_iso: Option) -> Vec { + rw2_with_options(inner, raw_iso, None, true, None) + } + + /// [`rw2_with`], also with the 32-bit raw ISO, an optional JpgFromRaw and an ISO in the raw's own Exif IFD. + fn rw2_with_options(inner: Option, raw_iso: Option, raw_iso32: Option, with_jpg: bool, exif_iso: Option) -> Vec { + let mut jpg = vec![0xff, 0xd8]; + if let Some(e) = inner { + let mut i0 = IfdBuilder::new(); + i0.set(t::MAKE, Value::Ascii("Panasonic".into())); + i0.set_child(t::EXIF_IFD, e); + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend(TiffWriter::new(ByteOrder::Little, false).write(&[i0]).unwrap()); + jpg.extend_from_slice(&[0xff, 0xe1]); + 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::EXPOSURE_TIME, Value::Rational(vec![(8, 1)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4816])); + if let Some(v) = raw_iso { + ifd0.set(PANASONIC_ISO, Value::Short(vec![v])); + } + if let Some(v) = raw_iso32 { + ifd0.set(PANASONIC_ISO_32, Value::Long(vec![v])); + } + if let Some(v) = exif_iso { + exif.set(t::ISO_SPEED, Value::Short(vec![v])); + } + if with_jpg { + ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); + } + ifd0.set_child(t::EXIF_IFD, exif); + let mut bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + bytes[2..4].copy_from_slice(&0x55u16.to_le_bytes()); + bytes + } + + #[test] + fn rw2_raw_iso_does_not_require_a_jpeg() { + for with_jpg in [false, true] { + for (iso16, iso32, want) in [ + (Some(200), None, Some(200)), + (None, Some(102400), Some(102400)), + (None, Some(65535), Some(65535)), + (Some(0), Some(400), Some(400)), + (Some(u16::MAX), Some(102400), Some(102400)), + (Some(u16::MAX), Some(u32::MAX), None), + (None, Some(0), None), + (None, None, None), + ] { + assert_eq!(read_exif(&rw2_with_options(None, iso16, iso32, with_jpg, None)).iso, want); + } + } + } + + #[test] + fn rw2_private_iso_tags_are_not_interpreted_in_ordinary_tiff() { + for with_jpg in [false, true] { + let mut bytes = rw2_with_options(None, Some(200), Some(250), with_jpg, None); + assert_eq!(read_exif(&bytes).iso, Some(200)); + bytes[2..4].copy_from_slice(&42u16.to_le_bytes()); + assert_eq!(read_exif(&bytes).iso, None); + } + } + + #[test] + fn ordinary_tiff_does_not_import_a_private_jpeg_tag_collision() { + let mut inner = IfdBuilder::new(); + inner.set(t::ISO_SPEED, Value::Short(vec![160])); + inner.set(t::LENS_MODEL, Value::Ascii("Preview lens".into())); + inner.set(t::DATE_TIME_ORIGINAL, Value::Ascii("2020:01:02 03:04:05".into())); + let mut bytes = rw2_with(Some(inner), Some(200)); + assert_eq!(read_exif(&bytes).iso, Some(160)); + assert_eq!(read_exif(&bytes).lens_model.as_deref(), Some("Preview lens")); + assert!(read_exif(&bytes).capture_time.is_some()); + bytes[2..4].copy_from_slice(&42u16.to_le_bytes()); + let m = read_exif(&bytes); + assert_eq!(m.iso, None); + assert_eq!(m.lens_model, None); + assert_eq!(m.capture_time, None); + assert_eq!(m.width, Some(4816)); + assert_eq!(m.exposure_time, Some(8.0)); + } + + #[test] + fn rw2_raw_iso_requires_the_recorded_unsigned_field_types() { + for with_jpg in [false, true] { + for (iso16, iso32, want) in [ + (Value::Undefined(vec![98]), Value::Long(vec![250]), Some(250)), + (Value::Byte(vec![98]), Value::Long(vec![250]), Some(250)), + (Value::SLong(vec![-1]), Value::Long(vec![250]), Some(250)), + (Value::Long(vec![98]), Value::Long(vec![250]), Some(250)), + (Value::Short(vec![0]), Value::Undefined(vec![98]), None), + (Value::Short(vec![0]), Value::Short(vec![98]), None), + ] { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set(PANASONIC_ISO, iso16); + ifd0.set(PANASONIC_ISO_32, iso32); + if with_jpg { + ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(vec![0xff, 0xd8, 0xff, 0xd9])); + } + let mut bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + bytes[2..4].copy_from_slice(&0x55u16.to_le_bytes()); + assert_eq!(read_exif(&bytes).iso, want); + } + } + } + + #[test] + fn rw2_saturated_exif_iso_uses_real_measurements() { + let inner = |iso| { + let mut e = IfdBuilder::new(); + e.set(t::ISO_SPEED, Value::Short(vec![iso])); + e + }; + assert_eq!(read_exif(&rw2_with_options(Some(inner(160)), Some(200), None, true, Some(u16::MAX))).iso, Some(160)); + assert_eq!(read_exif(&rw2_with_options(Some(inner(u16::MAX)), Some(u16::MAX), Some(102400), true, Some(u16::MAX))).iso, Some(102400)); + assert_eq!(read_exif(&rw2_with_options(Some(inner(160)), Some(200), None, true, Some(100))).iso, Some(100)); + assert_eq!(read_exif(&rw2_with_options(None, None, None, false, Some(u16::MAX))).iso, None); + } + + #[test] + fn rw2_extended_exif_iso_65535_is_a_measurement_not_an_overflow_marker() { + for tag in [t::RECOMMENDED_EXPOSURE_INDEX, t::ISO_SPEED_RATINGS_EXT] { + let mut inner = IfdBuilder::new(); + inner.set(t::ISO_SPEED, Value::Short(vec![u16::MAX])); + inner.set(tag, Value::Long(vec![65535])); + assert_eq!(read_exif(&rw2_with_options(Some(inner), Some(200), None, true, None)).iso, Some(65535)); + let bytes = rw2_with_options(None, Some(200), None, false, Some(u16::MAX)); + let mut tiff = Tiff::parse(&bytes).unwrap(); + tiff.ifds[0].exif.as_mut().unwrap().entries.push(craft_tiff::Entry { tag, value: Value::Long(vec![65535]), offset: 0 }); + tiff.ifds[0].exif.as_mut().unwrap().entries.sort_by_key(|e| e.tag); + let mut m = from_tiff(&tiff); + fill_from_panasonic_raw(&mut m, &bytes, &tiff, &ParseOptions::default()); + assert_eq!(m.iso, Some(65535)); + } + } + + /// An extended ISO tag holding 0 states nothing: the next one still answers, for the 65535 marker too. + #[test] + fn rw2_zero_recommended_exposure_index_does_not_hide_the_extended_iso() { + let inner = |ext: Option| { + let mut e = IfdBuilder::new(); + e.set(t::ISO_SPEED, Value::Short(vec![u16::MAX])); + e.set(t::RECOMMENDED_EXPOSURE_INDEX, Value::Long(vec![0])); + if let Some(v) = ext { + e.set(t::ISO_SPEED_RATINGS_EXT, Value::Long(vec![v])); + } + e + }; + assert_eq!(read_exif(&rw2_with_options(Some(inner(Some(102400))), Some(200), None, true, None)).iso, Some(102400)); + assert_eq!(read_exif(&rw2_with_options(Some(inner(Some(65535))), Some(200), None, true, None)).iso, Some(65535)); + // nothing else states one: 65535 is still the short field's overflow marker + assert_eq!(read_exif(&rw2_with_options(Some(inner(None)), Some(200), None, true, None)).iso, Some(200)); + } + + /// 65535 marks an overflow only in the SHORT form of ISOSpeedRatings: a LONG can hold the value itself. + #[test] + fn rw2_long_iso_65535_is_a_measurement_not_an_overflow_marker() { + let mut inner = IfdBuilder::new(); + inner.set(t::ISO_SPEED, Value::Long(vec![65535])); + assert_eq!(read_exif(&rw2_with_options(Some(inner), Some(200), None, true, None)).iso, Some(65535)); + // the raw's own Exif IFD + let bytes = rw2_with_options(None, Some(200), None, false, Some(u16::MAX)); + let mut tiff = Tiff::parse(&bytes).unwrap(); + let exif = tiff.ifds[0].exif.as_mut().unwrap(); + exif.entries.iter_mut().find(|e| e.tag == t::ISO_SPEED).unwrap().value = Value::Long(vec![65535]); + let mut m = from_tiff(&tiff); + fill_from_panasonic_raw(&mut m, &bytes, &tiff, &ParseOptions::default()); + assert_eq!(m.iso, Some(65535)); + // the same short value is still the marker + assert_eq!(read_exif(&rw2_with_options(None, Some(200), None, false, Some(u16::MAX))).iso, Some(200)); + } + + #[test] + fn rw2_embedded_exif_respects_caller_parse_options() { + let mut inner = IfdBuilder::new(); + inner.set(t::ISO_SPEED, Value::Short(vec![160])); + let bytes = rw2_with(Some(inner), None); + assert_eq!(try_read_exif(&bytes).unwrap().iso, Some(160)); + let opts = ParseOptions { follow_children: false, ..Default::default() }; + assert_eq!(try_read_exif_with(&bytes, &opts).unwrap().iso, None); + assert_eq!(try_read_exif_with(&panasonic_rw2("LUMIX S 85/F1.8"), &opts).unwrap().lens_model, None); + } + + #[test] + fn rw2_damaged_jpg_keeps_raw_metadata_and_iso_fallback() { + let mut inner = IfdBuilder::new(); + inner.set(t::ISO_SPEED, Value::Short(vec![160])); + let complete = rw2_with(Some(inner), Some(200)); + let parsed = Tiff::parse(&complete).unwrap(); + let jpg = parsed.ifds[0].bytes(PANASONIC_JPG_FROM_RAW).unwrap(); + // Truncation can retain readable Exif; otherwise the private raw ISO is used. + let mut damaged: Vec<&[u8]> = (0..jpg.len()).map(|end| &jpg[..end]).collect(); + damaged.push(b"not a JPEG"); + for payload in damaged { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("Panasonic".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4816])); + raw.set(PANASONIC_ISO, Value::Short(vec![200])); + raw.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(payload.to_vec())); + let mut bytes = TiffWriter::new(ByteOrder::Little, false).write(&[raw]).unwrap(); + bytes[2..4].copy_from_slice(&0x55u16.to_le_bytes()); + let m = try_read_exif(&bytes).unwrap(); + assert!(matches!(m.iso, Some(160 | 200)), "payload length {}: {:?}", payload.len(), m.iso); + if payload.len() < 12 || !payload.starts_with(&[0xff, 0xd8]) { + assert_eq!(m.iso, Some(200)); + } + assert_eq!(m.width, Some(4816)); + } + } + + #[test] + fn rw2_fills_missing_exif_from_its_jpg_from_raw() { + let mut inner = IfdBuilder::new(); + inner.set(t::ISO_SPEED, Value::Short(vec![160])); + inner.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 60)])); + inner.set(t::PIXEL_X_DIMENSION, Value::Long(vec![1920])); + inner.set(t::LENS_MODEL, Value::Ascii("Preview lens".into())); + inner.set(t::DATE_TIME_ORIGINAL, Value::Ascii("2020:01:02 03:04:05".into())); + let m = read_exif(&rw2_with(Some(inner), Some(200))); + assert_eq!(m.iso, Some(160), "the JpgFromRaw's ISOSpeedRatings"); + assert_eq!(m.exposure_time, Some(8.0), "the raw's own Exif wins"); + assert_eq!(m.width, Some(4816), "the raw's size, not the preview's"); + assert_eq!(m.lens_model.as_deref(), Some("Preview lens")); + assert!(m.capture_time.is_some()); + } + + #[test] + fn rw2_embedded_standard_lens_wins_over_its_maker_note() { + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend(panasonic_exif(ByteOrder::Little, Some("Maker lens"), Some("Standard lens"))); + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpg.extend(app1); + jpg.extend_from_slice(&[0xff, 0xd9]); + let mut raw = IfdBuilder::new(); + raw.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); + let mut bytes = TiffWriter::new(ByteOrder::Little, false).write(&[raw]).unwrap(); + bytes[2..4].copy_from_slice(&0x55u16.to_le_bytes()); + assert_eq!(read_exif(&bytes).lens_model.as_deref(), Some("Standard lens")); + } + + #[test] + fn rw2_raw_lens_and_date_win_over_embedded_exif() { + let mut inner = IfdBuilder::new(); + inner.set(t::LENS_MODEL, Value::Ascii("Preview lens".into())); + inner.set(t::DATE_TIME_ORIGINAL, Value::Ascii("2020:01:02 03:04:05".into())); + let with_preview = rw2_with(Some(inner), None); + let preview = Tiff::parse(&with_preview).unwrap(); + let mut raw_exif = IfdBuilder::new(); + raw_exif.set(t::LENS_MODEL, Value::Ascii("Raw lens".into())); + raw_exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii("2021:02:03 04:05:06".into())); + let mut raw = IfdBuilder::new(); + raw.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(preview.ifds[0].bytes(PANASONIC_JPG_FROM_RAW).unwrap().to_vec())); + raw.set_child(t::EXIF_IFD, raw_exif); + let mut bytes = TiffWriter::new(ByteOrder::Little, false).write(&[raw]).unwrap(); + bytes[2..4].copy_from_slice(&0x55u16.to_le_bytes()); + let m = read_exif(&bytes); + assert_eq!(m.lens_model.as_deref(), Some("Raw lens")); + assert!(m.capture_time.is_some()); + assert_eq!(m.capture_time, from_tiff(&Tiff::parse(&bytes).unwrap()).capture_time); + assert_ne!(m.capture_time, read_exif(&with_preview).capture_time); + } + + #[test] + fn rw2_falls_back_to_the_panasonic_raw_iso() { + assert_eq!(read_exif(&rw2_with(None, Some(200))).iso, Some(200)); + assert_eq!(read_exif(&rw2_with(Some(IfdBuilder::new()), Some(200))).iso, Some(200)); + assert_eq!(read_exif(&rw2_with(None, None)).iso, None); + } + /// Canon maker note tags: `0x0095` holds the lens name as ASCII; `0x0001` is CameraSettings, a table of /// int16s whose index 22 is the numeric LensType. const CANON_LENS_MODEL_TAG: u16 = 0x0095; @@ -624,18 +965,22 @@ pub fn write_exif(meta: &Metadata) -> Vec { (t::ARTIST, ascii(&meta.artist)), (t::COPYRIGHT, ascii(&meta.copyright)), (t::IMAGE_DESCRIPTION, ascii(&meta.caption)), - (t::DATE_TIME, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), + // Exif's date can't say a year or a month alone (an XMP date can) + (t::DATE_TIME, meta.capture_time.filter(DateTime::is_full).map(|d| Value::Ascii(d.to_exif()))), (t::ORIENTATION, meta.orientation.map(|o| Value::Short(vec![o.to_exif()]))), ] { if let Some(v) = v { ifd0.set(tag, v); } } + if let Some(r) = meta.rating { + ifd0.set(t::RATING, if r < 0 { Value::Short(vec![0xffff]) } else { Value::Short(vec![r as u16]) }); + } let mut ex = IfdBuilder::new(); ex.set(t::EXIF_VERSION, Value::Undefined(b"0232".to_vec())); let r = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)])); for (tag, v) in [ - (t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), + (t::DATE_TIME_ORIGINAL, meta.capture_time.filter(DateTime::is_full).map(|d| Value::Ascii(d.to_exif()))), (t::EXPOSURE_TIME, reciprocal(meta.exposure_time).or_else(|| r(meta.exposure_time))), (t::F_NUMBER, r(meta.f_number)), (t::FOCAL_LENGTH, r(meta.focal_length)), @@ -724,6 +1069,7 @@ mod write_tests { focal_length: Some(35.0), lens_model: Some("35mm F2".into()), gps: Some(Gps { latitude: 43.7904, longitude: -110.6818, altitude: Some(2000.0) }), + rating: Some(4), ..Default::default() }; let back = read_exif(&write_exif(&m)); @@ -736,8 +1082,12 @@ mod write_tests { assert!((back.f_number.unwrap() - 2.8).abs() < 1e-6); assert!((back.exposure_time.unwrap() - 0.004).abs() < 1e-9); assert_eq!(back.lens_model, m.lens_model); + assert_eq!(back.rating, Some(4)); let g = back.gps.unwrap(); assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}"); + + let rejected = read_exif(&write_exif(&Metadata { rating: Some(-1), ..Default::default() })); + assert_eq!(rejected.rating, Some(-1)); } #[test] @@ -756,4 +1106,14 @@ mod write_tests { assert!((1.0 / got - den).abs() < 0.5, "1/{den} s came back as 1/{}", 1.0 / got); } } + + #[test] + fn written_shutter_speeds_show_as_the_same_reciprocal() { + // the exact time is what export writes, and it reads back as the text shown + for (t, want) in [(1.0 / 3.0, "1/3"), (1.0 / 250.0, "1/250"), (1.0 / 12_800.0, "1/12800"), (0.3, "0.3"), (1.0 / 2.8, "0.3571")] { + let back = read_exif(&write_exif(&Metadata { exposure_time: Some(t), ..Default::default() })); + let got = back.exposure_time.unwrap(); + assert_eq!(crate::format_exposure_time(got).as_deref(), Some(want), "{t} came back as {got}"); + } + } } diff --git a/crates/metadata/src/jxl.rs b/crates/metadata/src/jxl.rs new file mode 100644 index 000000000..f7a860099 --- /dev/null +++ b/crates/metadata/src/jxl.rs @@ -0,0 +1,198 @@ +//! JPEG XL container metadata (ISO/IEC 18181-2): the `Exif` and `xml ` boxes. +//! +//! A JPEG XL file is either a bare codestream (`FF 0A`, no metadata) or an ISO base media file +//! that starts with the 12-byte `JXL ` signature box. Its metadata boxes may be stored as they +//! are, or wrapped in a `brob` box: the real box type followed by a Brotli stream of the payload. +//! An `Exif` payload starts with a four-byte big-endian `tiff_header_offset`: the TIFF header +//! begins that many bytes after the prefix. + +use crate::container::Embedded; +use crate::cr3::{MAX_BOXES, be32, boxes}; + +/// The signature box every JPEG XL container starts with. +const SIGNATURE: &[u8; 12] = b"\0\0\0\x0cJXL \r\n\x87\n"; + +/// A decompressed `brob` payload larger than this is left out, as too large for metadata. +const MAX_BROB: usize = 32 << 20; + +/// Whether `bytes` are a JPEG XL container (a bare codestream is not: it carries no metadata). +pub fn is_jxl_container(bytes: &[u8]) -> bool { + bytes.starts_with(SIGNATURE) +} + +/// The Exif and XMP blocks of a JPEG XL container: the first `Exif` and the first `xml ` box, +/// plain or inside `brob`. Anything malformed is left out rather than reported. +pub fn metadata(bytes: &[u8]) -> Embedded { + let mut out = Embedded::default(); + if !is_jxl_container(bytes) { + return out; + } + let mut budget = MAX_BOXES; + for b in boxes(bytes, SIGNATURE.len(), bytes.len(), &mut budget) { + if out.exif.is_some() && out.xmp.is_some() { + break; + } + let Some(payload) = bytes.get(b.body..b.end) else { break }; + let (kind, payload) = match &b.kind { + b"brob" => { + let (Some(kind), Some(stream)) = (payload.get(..4), payload.get(4..)) else { continue }; + let mut k = [0u8; 4]; + k.copy_from_slice(kind); + if (&k == b"Exif" && out.exif.is_none()) || (&k == b"xml " && out.xmp.is_none()) { + let Some(data) = unbrotli(stream) else { continue }; + (k, std::borrow::Cow::Owned(data)) + } else { + continue; + } + } + k => (*k, std::borrow::Cow::Borrowed(payload)), + }; + match &kind { + b"Exif" if out.exif.is_none() => out.exif = exif_tiff(&payload).map(<[u8]>::to_vec), + b"xml " if out.xmp.is_none() => out.xmp = Some(String::from_utf8_lossy(&payload).trim_end_matches('\0').to_string()), + _ => {} + } + } + out +} + +/// The TIFF stream of an `Exif` box payload: after the four-byte prefix and the offset it holds. +/// `None` when the offset points past the payload, or at nothing. +pub(crate) fn exif_tiff(payload: &[u8]) -> Option<&[u8]> { + let skip = be32(payload, 0).and_then(|v| usize::try_from(v).ok())?.checked_add(4)?; + payload.get(skip..).filter(|b| !b.is_empty()) +} + +/// Decompress a `brob` stream, giving up on a malformed stream or one past [`MAX_BROB`]. +fn unbrotli(stream: &[u8]) -> Option> { + use std::io::Read; + let mut out = Vec::new(); + let limit = u64::try_from(MAX_BROB).ok()?.checked_add(1)?; + brotli_decompressor::Decompressor::new(stream, 4096).take(limit).read_to_end(&mut out).ok()?; + (out.len() <= MAX_BROB).then_some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::embedded; + + fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&u32::try_from(payload.len() + 8).unwrap().to_be_bytes()); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v + } + + fn brob(kind: &[u8; 4], payload: &[u8]) -> Vec { + use std::io::Write; + let mut body = kind.to_vec(); + let mut w = brotli::CompressorWriter::new(&mut body, 4096, 5, 22); + w.write_all(payload).unwrap(); + drop(w); + bx(b"brob", &body) + } + + /// A container whose codestream is a stand-in: the metadata reader never parses it. + fn container(boxes: &[Vec]) -> Vec { + let mut v = SIGNATURE.to_vec(); + v.extend(bx(b"ftyp", b"jxl \0\0\0\0jxl ")); + v.extend(bx(b"jxlc", &[0xff, 0x0a, 1, 2, 3])); + for b in boxes { + v.extend_from_slice(b); + } + v + } + + /// A little-endian TIFF with one IFD0 entry: `Make` = `m`. + fn tiff(make: &str) -> Vec { + let m = format!("{make}\0"); + let mut v = b"II*\0\x08\0\0\0\x01\0\x0f\x01\x02\0".to_vec(); + v.extend_from_slice(&(m.len() as u32).to_le_bytes()); + v.extend_from_slice(&26u32.to_le_bytes()); + v.extend_from_slice(&[0; 4]); + v.extend_from_slice(m.as_bytes()); + v + } + + fn exif_box(tiff: &[u8], offset: u32) -> Vec { + let mut p = offset.to_be_bytes().to_vec(); + p.extend(std::iter::repeat_n(0u8, offset as usize)); + p.extend_from_slice(tiff); + p + } + + const XMP: &str = "Golden horizon"; + + #[test] + fn plain_exif_and_xml_boxes_are_read() { + let t = tiff("NIKON CORPORATION"); + let f = container(&[bx(b"Exif", &exif_box(&t, 0)), bx(b"xml ", XMP.as_bytes())]); + let e = embedded(&f); + assert_eq!(e.exif.as_deref(), Some(t.as_slice())); + assert_eq!(e.xmp.as_deref(), Some(XMP)); + let m = crate::extract(&f); + assert_eq!(m.make.as_deref(), Some("NIKON CORPORATION")); + assert_eq!(m.title.as_deref(), Some("Golden horizon")); + } + + #[test] + fn brotli_wrapped_boxes_are_read() { + let t = tiff("SONY"); + let f = container(&[brob(b"Exif", &exif_box(&t, 0)), brob(b"xml ", XMP.as_bytes())]); + let e = embedded(&f); + assert_eq!(e.exif.as_deref(), Some(t.as_slice())); + assert_eq!(e.xmp.as_deref(), Some(XMP)); + } + + /// The `Exif` prefix is an offset to the TIFF header, not always 0. + #[test] + fn the_tiff_header_offset_is_honoured() { + let t = tiff("Canon"); + let f = container(&[bx(b"Exif", &exif_box(&t, 6))]); + assert_eq!(embedded(&f).exif.as_deref(), Some(t.as_slice())); + assert_eq!(crate::extract(&f).make.as_deref(), Some("Canon")); + // an offset past the payload yields nothing + let f = container(&[bx(b"Exif", &[0, 0, 0, 100, 1, 2, 3, 4])]); + assert_eq!(embedded(&f).exif, None); + assert_eq!(exif_tiff(&[0, 0, 1, 0, 1, 2, 3]), None); + assert_eq!(exif_tiff(&[0, 0]), None); + } + + #[test] + fn the_first_of_each_box_wins_and_the_bare_codestream_has_none() { + let (a, b) = (tiff("A"), tiff("B")); + let f = container(&[bx(b"Exif", &exif_box(&a, 0)), bx(b"Exif", &exif_box(&b, 0)), bx(b"xml ", b""), bx(b"xml ", b"")]); + let e = embedded(&f); + assert_eq!(e.exif.as_deref(), Some(a.as_slice())); + assert_eq!(e.xmp.as_deref(), Some("")); + assert_eq!(embedded(&[0xff, 0x0a, 1, 2, 3, 4]), Embedded::default()); + assert!(!is_jxl_container(b"\0\0\0\x0cJXL \r\n\x87")); + } + + #[test] + fn broken_files_yield_nothing_without_panicking() { + let t = tiff("X"); + let good = container(&[brob(b"Exif", &exif_box(&t, 0)), bx(b"xml ", XMP.as_bytes())]); + for n in 0..good.len() { + let _ = embedded(&good[..n]); + } + // a box that claims more than the file holds, a zero size, a 64-bit size and junk Brotli + let mut huge = bx(b"Exif", &exif_box(&t, 0)); + huge[..4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(embedded(&container(&[huge])), Embedded::default()); + let mut zero = bx(b"xml ", b""); + zero[..4].copy_from_slice(&[0; 4]); + assert_eq!(embedded(&container(&[zero])).xmp.as_deref(), Some("")); + let mut large = vec![0, 0, 0, 1]; + large.extend_from_slice(b"xml "); + large.extend_from_slice(&(8u64 + 8 + 4).to_be_bytes()); + large.extend_from_slice(b""); + assert_eq!(embedded(&container(&[large])).xmp.as_deref(), Some("")); + assert_eq!(embedded(&container(&[bx(b"brob", b"Exif\x01\x02\x03\x04\x05"), bx(b"brob", b"xm")])), Embedded::default()); + // a brob that inflates past the cap is left out + let big = vec![0u8; MAX_BROB + 1]; + assert_eq!(embedded(&container(&[brob(b"xml ", &big)])), Embedded::default()); + } +} diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index e485e4a85..16b2dbec8 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -10,7 +10,8 @@ //! another program wrote without losing what it does not understand. //! - GPX: [`parse_gpx`] reads track logs for geotagging by capture time. //! - Containers: [`embedded`], [`jpeg_segments`], [`png_chunks`] and [`webp_chunks`] locate the Exif, -//! XMP, ICC and IPTC blocks of JPEG, PNG, WebP, TIFF-based, Fujifilm RAF and Canon CR3 ([`cr3`]) files. +//! XMP, ICC and IPTC blocks of JPEG, PNG, WebP, TIFF-based, Fujifilm RAF, Canon CR3 ([`cr3`]) and +//! JPEG XL ([`jxl`]) files. //! [`extract`] does all of the above for a whole file. //! //! HEIF, PSD and MRW files are read by their own crates. Put the blocks they return into an @@ -25,12 +26,13 @@ mod focus_read; mod geom; mod gpx; mod iptc; +pub mod jxl; pub mod tags; mod xmp; mod xmp_merge; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; -pub use datetime::DateTime; +pub use datetime::{DatePrecision, DateTime}; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif}; pub use focus::FocusPoint; pub use geom::{Orientation, Point, Rect}; @@ -162,6 +164,29 @@ macro_rules! overlay { ($self:ident, $o:ident, $($f:ident),*) => { $( if $o.$f.is_some() { $self.$f = $o.$f.clone(); } )* }; } +/// How far (relative) a duration may be from an exact `1/n` second and still be shown as `1/n`: +/// absorbs decimal EXIF rationals such as `3333/10000` for 1/3 s without turning 0.3 s or +/// 1/2.8 s into a reciprocal they aren't. +pub const RECIPROCAL_TOLERANCE: f64 = 1e-3; + +/// The one shutter-speed formatter for display (catalog `shutter`, Info panel, All Metadata): +/// a near-exact `1/n` second as `1/n` (`1/3`, `1/250`), anything else rounded to four +/// significant digits with trailing zeros dropped (`0.3`, `0.3571`, `2.5`, `30`). Display only: +/// export writes the exact duration. `None` for a time that isn't positive and finite. +pub fn format_exposure_time(t: f64) -> Option { + if !t.is_finite() || t <= 0.0 { + return None; + } + let n = (1.0 / t).round(); + if n >= 2.0 && n.is_finite() && (1.0 / (n * t) - 1.0).abs() <= RECIPROCAL_TOLERANCE { + return Some(format!("1/{n}")); + } + // four significant digits; whole seconds never lose digits + let decimals = (3 - t.log10().floor() as i32).max(0) as usize; + let text = format!("{t:.decimals$}"); + Some(if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.').to_string() } else { text }) +} + impl Metadata { /// Fill every empty field of `self` from `other`. pub fn fill_missing(&mut self, other: &Metadata) { @@ -245,19 +270,10 @@ impl Metadata { } } - /// Exposure time formatted like cameras show it: `1/250`, `0.5`, `2″`. + /// Exposure time formatted like cameras show it: `1/250`, `0.3571`, `2.5″` (see [`format_exposure_time`]). pub fn exposure_display(&self) -> Option { let t = self.exposure_time?; - if t.is_nan() || t <= 0.0 { - return None; - } - Some(if t < 0.3 { - format!("1/{}", (1.0 / t).round()) - } else if t < 1.0 { - format!("{}", (t * 10.0).round() / 10.0) - } else { - format!("{}″", (t * 10.0).round() / 10.0) - }) + format_exposure_time(t).map(|text| if t >= 1.0 { format!("{text}″") } else { text }) } /// Human name of the metering mode. @@ -291,7 +307,7 @@ impl Metadata { } } -/// Read everything understood from a whole file (JPEG, PNG, WebP, TIFF-based incl. DNG and most raws, RAF, CR3). +/// Read everything understood from a whole file (JPEG, PNG, WebP, TIFF-based incl. DNG and most raws, RAF, CR3, JPEG XL). /// Precedence: EXIF, then IPTC fills gaps, then XMP overrides user fields and fills the rest. pub fn extract(bytes: &[u8]) -> Metadata { from_embedded(&embedded(bytes)) @@ -386,12 +402,58 @@ mod tests { assert_eq!(parse_number("inf"), None); } + #[test] + fn exposure_time_formats_reciprocals_and_significant_digits() { + for (t, want) in [ + (1.0 / 3.0, "1/3"), + (1.0 / 250.0, "1/250"), + (1.0 / 8000.0, "1/8000"), + (0.5, "1/2"), + (0.3, "0.3"), + (0.4, "0.4"), + (1.0 / 2.8, "0.3571"), + (0.625, "0.625"), + (0.003, "0.003"), + (1.0, "1"), + (1.3, "1.3"), + (2.5, "2.5"), + (30.0, "30"), + (9.99996, "10"), + (1800.0, "1800"), + ] { + assert_eq!(format_exposure_time(t).as_deref(), Some(want), "{t}"); + } + for t in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(format_exposure_time(t), None, "{t}"); + } + } + + #[test] + fn exposure_time_reciprocal_tolerance() { + // a camera's decimal rational for 1/3 s, and times just inside the tolerance either side + assert_eq!(format_exposure_time(3333.0 / 10000.0).as_deref(), Some("1/3")); + // a microsecond-level rational error still reads as 1/8000; above ~1/1000 s every time is + // within the tolerance of some 1/n, which is how cameras show those speeds anyway + assert_eq!(format_exposure_time(1.0 / 8000.0 * (1.0 + 1e-5)).as_deref(), Some("1/8000")); + for n in [3.0, 10.0, 250.0] { + let inside = 0.9 * RECIPROCAL_TOLERANCE; + let outside = 1.1 * RECIPROCAL_TOLERANCE; + let want = format!("1/{n}"); + assert_eq!(format_exposure_time(1.0 / n * (1.0 + inside)).as_deref(), Some(want.as_str()), "{n} +inside"); + assert_eq!(format_exposure_time(1.0 / n * (1.0 - inside)).as_deref(), Some(want.as_str()), "{n} -inside"); + assert_ne!(format_exposure_time(1.0 / n * (1.0 + outside)).as_deref(), Some(want.as_str()), "{n} +outside"); + assert_ne!(format_exposure_time(1.0 / n * (1.0 - outside)).as_deref(), Some(want.as_str()), "{n} -outside"); + } + } + #[test] fn exposure_display() { let mut m = Metadata { exposure_time: Some(1.0 / 250.0), ..Default::default() }; assert_eq!(m.exposure_display().as_deref(), Some("1/250")); m.exposure_time = Some(0.5); - assert_eq!(m.exposure_display().as_deref(), Some("0.5")); + assert_eq!(m.exposure_display().as_deref(), Some("1/2")); + m.exposure_time = Some(0.3); + assert_eq!(m.exposure_display().as_deref(), Some("0.3")); m.exposure_time = Some(2.0); assert_eq!(m.exposure_display().as_deref(), Some("2″")); m.exposure_time = Some(0.0); diff --git a/crates/metadata/src/tags.rs b/crates/metadata/src/tags.rs index 6e5930922..f666e549c 100644 --- a/crates/metadata/src/tags.rs +++ b/crates/metadata/src/tags.rs @@ -250,7 +250,10 @@ fn value_text(group: &str, tag: u16, v: &Value) -> String { // exposure time as a fraction of a second let (n, d) = a[0]; let s = n as f64 / d as f64; - if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) } + match crate::format_exposure_time(s) { + Some(text) => format!("{text} s"), + None => format!("{} s", rational(n as f64, d as f64)), + } } Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)), Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)), @@ -380,6 +383,28 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec { #[cfg(test)] mod tests { + #[test] + fn exposure_rows_do_not_round_third_stop_times() { + for (n, d, expected) in [ + (3, 10, "0.3 s"), + (4, 10, "0.4 s"), + (6, 10, "0.6 s"), + (8, 10, "0.8 s"), + (13, 10, "1.3 s"), + (16, 10, "1.6 s"), + (25, 10, "2.5 s"), + (32, 10, "3.2 s"), + (5, 8, "0.625 s"), + (1, 3, "1/3 s"), + (10, 28, "0.3571 s"), + (1, 250, "1/250 s"), + (1, 8000, "1/8000 s"), + (30, 1, "30 s"), + ] { + assert_eq!(value_text("EXIF", 0x829a, &Value::Rational(vec![(n, d)])), expected); + } + } + use super::*; #[test] @@ -526,4 +551,17 @@ mod tests { // no Panasonic note, no row assert!(lens(&file_tag_rows(&synthetic_raw(Some(3)))).is_empty()); } + + /// Tag 0x002E is a JpgFromRaw only in a RW2: in an ordinary TIFF it is someone else's private tag, whose bytes + /// name no lens of this photo. Its own maker note still does. + #[test] + fn panasonic_lens_row_reads_a_jpg_from_raw_only_in_a_rw2() { + let lens = |rows: &[TagRow]| rows.iter().filter(|r| r.group == "Panasonic" && r.tag == 0x0051).count(); + let mut bytes = crate::exif::tests::panasonic_rw2("LUMIX S 85/F1.8"); + assert_eq!(lens(&file_tag_rows(&bytes)), 1); + bytes[2..4].copy_from_slice(&42u16.to_le_bytes()); + assert_eq!(lens(&file_tag_rows(&bytes)), 0); + let own = crate::exif::tests::panasonic_exif(craft_tiff::ByteOrder::Little, Some("LUMIX G 25/F1.7"), None); + assert_eq!(lens(&file_tag_rows(&own)), 1); + } } diff --git a/crates/metadata/src/xmp.rs b/crates/metadata/src/xmp.rs index e2af65fb5..9f982b45a 100644 --- a/crates/metadata/src/xmp.rs +++ b/crates/metadata/src/xmp.rs @@ -442,9 +442,8 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap String { push("xmp:CreatorTool", meta.software.clone()); push("xmp:Rating", meta.rating.map(|v| v.to_string())); push("xmp:Label", meta.label.clone()); - push("exif:DateTimeOriginal", meta.capture_time.map(|d| d.to_iso())); + // a year or a month alone goes in `photoshop:DateCreated` only, as Lightroom writes it: Exif's + // date can't say it + push("exif:DateTimeOriginal", meta.capture_time.filter(DateTime::is_full).map(|d| d.to_iso())); push("photoshop:DateCreated", meta.capture_time.map(|d| d.to_iso())); push("exif:ExposureTime", meta.exposure_time.map(frac)); push("exif:FNumber", meta.f_number.map(frac)); diff --git a/crates/metadata/src/xmp/tests.rs b/crates/metadata/src/xmp/tests.rs index 7d7656fba..7eb15dcc3 100644 --- a/crates/metadata/src/xmp/tests.rs +++ b/crates/metadata/src/xmp/tests.rs @@ -629,3 +629,28 @@ fn the_callers_namespace_is_written_and_read_only_when_passed() { let orphan = write_xmp_with(&Metadata::default(), &XmpWrite { properties: &[("flag", "pick")], ..Default::default() }); assert_eq!(orphan, plain); } + +#[test] +fn resource_struct_attributes_and_elements_flatten_like_description_fields() { + let forms = [ + r#"1"#, + r#"1"#, + r#"True1"#, + r#""#, + ]; + let mut previous = None; + for form in forms { + let xml = format!( + r#"{form}"# + ); + let packet = parse_xmp(&xml).unwrap(); + assert_eq!(packet.properties["crs:Look/crs:Parameters/crs:ConvertToGrayscale"], vec!["True"]); + assert_eq!(packet.properties["crs:Look/crs:Parameters/crs:Version"], vec!["1"]); + assert!(packet.properties.keys().all(|key| !key.contains("rdf:"))); + if let Some((properties, values)) = previous { + assert_eq!(packet.properties, properties); + assert_eq!(packet.values, values); + } + previous = Some((packet.properties, packet.values)); + } +} diff --git a/crates/metadata/src/xmp_merge.rs b/crates/metadata/src/xmp_merge.rs index b52e81c26..3056225b0 100644 --- a/crates/metadata/src/xmp_merge.rs +++ b/crates/metadata/src/xmp_merge.rs @@ -465,21 +465,36 @@ mod tests { #[test] fn many_attributes_scan_in_linear_time() { // quick-xml's duplicate-name check compares each attribute with all earlier ones: 200 000 attributes on - // one description took over 20 s to scan, about 30 ms with the set - let n = 200_000; - let mut s = String::from( - "\ - \ + "); + s + }; + let run = |s: &str, n: usize| { + let t = std::time::Instant::now(); + let found = scan(s, &[]).unwrap(); + let took = t.elapsed(); + assert_eq!(found.descs[0].attrs.len(), n + 2, "every attribute is read"); + took + }; + let (n, small_packet, large_packet) = (50_000, packet(50_000), packet(200_000)); + // interleaved, so a burst of load slows both sizes rather than one; the fastest run of each counts + let (mut small, mut large) = (std::time::Duration::MAX, std::time::Duration::MAX); + for _ in 0..5 { + small = small.min(run(&small_packet, n)); + large = large.min(run(&large_packet, 4 * n)); } - s.push_str("/>"); - let t = std::time::Instant::now(); - let found = scan(&s, &[LC]).unwrap(); - let took = t.elapsed(); - assert_eq!(found.descs[0].attrs.len(), n + 2, "every attribute is read"); - assert!(took < std::time::Duration::from_secs(5), "{n} attributes took {took:?}"); + let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); + assert!(ratio < 10.0, "4x the attributes took {ratio:.1}x as long ({small:?}, then {large:?})"); } #[test]