From 75471d19bd68f7a14ff3ee992e5c87b9888afb98 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:09:09 -0500 Subject: [PATCH] craft-metadata: fixes ported from open LightCraft pull requests (#967) * craft-metadata: EXIF, IPTC-IIM, XMP and GPX from LightCraft lightcraft-meta at 5b61ce1e, as one crate with a module per format: exif (read, write, maker-note lens, focus point, tag listing), iptc, xmp (parse, write, merge), gpx, and the JPEG, PNG, WebP, TIFF, RAF and CR3 containers. They share Metadata, DateTime, Gps and Orientation. Changed in the move: - TIFF comes from craft-tiff; Point, Rect and Orientation are the crate's own. - The app namespace is a parameter: parse_xmp_with, write_xmp_with and MergeRules::namespaces take it, and XmpData no longer has lc_settings. - The HEIF, PSD and MRW readers stay out, since craft-heif, craft-psd and craft-raw read those containers; from_embedded takes the blocks they find. * craft-metadata: a RW2 reads ISO and the rest of its Exif from the JpgFromRaw A RW2's own Exif IFD is a short subset. What it lacks is filled from the Exif of the embedded JpgFromRaw, then from the ISO tags of the Panasonic IFD0 (0x0017, 0x0037), each only in its recorded field type. 65535 in a SHORT ISOSpeedRatings is the overflow marker and states no ISO. The private tags are read only in a RW2 stream, under the caller's parse options. Ported from storytold/lightcraft#3518. * craft-metadata: the Exif rating is written, and 65535 reads as rejected write_exif writes Rating (0x4746), with 0xFFFF for a rejected photo, and from_tiff reads 65535 as -1 instead of clamping it to 5. Ported from storytold/lightcraft#1995. * craft-metadata: one shutter-speed formatter, exact to four digits format_exposure_time shows a duration within 0.1% of 1/n second as 1/n and anything else to four significant digits, so 0.3 s, 1/2.8 s and 0.625 s are no longer rounded to a reciprocal or one decimal. Metadata::exposure_display and the ExposureTime tag row both use it. Ported from storytold/lightcraft#1692. * craft-metadata: JPEG XL containers give their Exif and xml boxes The first Exif and xml box of a JPEG XL container, plain or Brotli-wrapped in brob, honouring the Exif box's TIFF header offset. A brob payload is capped at 32 MiB. A bare codestream has no metadata. Ported from storytold/lightcraft#3756. * craft-metadata: XMP Resource structs flatten their attribute fields A property with rdf:parseType="Resource" may give its fields as attributes as well as child elements. Only the children were flattened into XmpData::properties; the attributes now are too, as for a nested rdf:Description. Ported from storytold/lightcraft#1226 (the XMP part). * craft-metadata: a capture date of just a year or a month stays as stated XMP dates may stop at the year (1983) or the month (1983-04). DateTime now has a DatePrecision; such a date writes back to XMP as stated and only in photoshop:DateCreated, and write_exif leaves it out, since an Exif date cannot say it. Ported from storytold/lightcraft#3502. * craft-metadata: the linear-time scan test compares times, not a fixed bound The test asserted that 200 000 attributes scan in under 5 s, which failed on a loaded machine. It now scans 25 000 and 100 000 attributes, takes the best of three runs of each, and asserts that four times the attributes take less than ten times as long. Linear scans give about 4; a quadratic duplicate check gave 18.6 and fails. * craft-metadata: interleave the linear-time scan test's runs The best-of-three ratio still failed once under load. The test now alternates five runs of 50 000 and 200 000 attributes and compares the fastest of each, so a burst of load slows both sizes. Six runs at load average 55 gave ratios of 3.9 to 5.4 against the bound of 10. --------- Co-authored-by: radialmonster Co-authored-by: Sebastian Berschneider Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Michael Anderson <1427851+endthestart@users.noreply.github.com> Co-authored-by: Ilya Dorman Co-authored-by: Clyde C Brown --- Cargo.lock | 56 ++++- crates/metadata/Cargo.toml | 4 + crates/metadata/README.md | 5 +- crates/metadata/src/container.rs | 5 + crates/metadata/src/datetime.rs | 80 ++++++- crates/metadata/src/exif.rs | 382 ++++++++++++++++++++++++++++++- crates/metadata/src/jxl.rs | 198 ++++++++++++++++ crates/metadata/src/lib.rs | 92 ++++++-- crates/metadata/src/tags.rs | 40 +++- crates/metadata/src/xmp.rs | 9 +- crates/metadata/src/xmp/tests.rs | 25 ++ crates/metadata/src/xmp_merge.rs | 43 ++-- 12 files changed, 878 insertions(+), 61 deletions(-) create mode 100644 crates/metadata/src/jxl.rs 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]