diff --git a/Cargo.lock b/Cargo.lock index 7469cbd..ca9757e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,7 +248,7 @@ checksum = "08f9ee4a4ca9239c9a839453dce04b7ddee2f859ec4cd7acd1f5703b68db549c" dependencies = [ "bitflags", "cairo-sys-rs", - "glib", + "glib 0.16.2", "libc", "once_cell", "thiserror", @@ -260,7 +260,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5119ea655ec777b523f0b57279e70f8a4542f61b0e98a48f892b4ef043fd4c5d" dependencies = [ - "glib-sys", + "glib-sys 0.16.0", "libc", "system-deps", ] @@ -531,8 +531,8 @@ checksum = "c0fb526c8c3a075eda15f961820edf3e15fe18576ac4fbabbb324e4cc6c421e6" dependencies = [ "bitflags", "gdk-pixbuf-sys", - "gio", - "glib", + "gio 0.16.2", + "glib 0.16.2", "libc", ] @@ -542,9 +542,9 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df12d15c10c3c5a84d9fb4ba0e27659f6a2bdee4f27f8b17126da15d5ddd3f2" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.16.0", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "libc", "system-deps", ] @@ -559,8 +559,8 @@ dependencies = [ "cairo-rs", "gdk-pixbuf", "gdk4-sys", - "gio", - "glib", + "gio 0.16.2", + "glib 0.16.2", "libc", "pango", ] @@ -573,9 +573,9 @@ checksum = "ddcf9e3ab5f237bb641e7f2fccc4b26d5b86f111f0d62e27d452dc24964541c2" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.16.0", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "libc", "pango-sys", "pkg-config", @@ -590,8 +590,8 @@ checksum = "ca5c2a5b1a115ed6288679b984a1d67fbab57c930b6e75adeff5e15123c28fb7" dependencies = [ "gdk4", "gdk4-wayland-sys", - "gio", - "glib", + "gio 0.16.2", + "glib 0.16.2", "libc", ] @@ -601,7 +601,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b809ee567e47b75efdd3591f46ee9bc3b458aeba6e0eee9c3af219bb167cc557" dependencies = [ - "glib-sys", + "glib-sys 0.16.0", "libc", "system-deps", ] @@ -614,8 +614,8 @@ checksum = "7d1355a25b44d5c1b4b416ed4b9fdcf0c0791d4c276c22d68e3d844c2efba46b" dependencies = [ "gdk4", "gdk4-x11-sys", - "gio", - "glib", + "gio 0.16.2", + "glib 0.16.2", "libc", ] @@ -626,7 +626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4e558577b6971d9121b6e881c22cff9256a4eb32af2c31fd2c5bae3094dbb7" dependencies = [ "gdk4-sys", - "glib-sys", + "glib-sys 0.16.0", "libc", "system-deps", ] @@ -662,6 +662,23 @@ dependencies = [ "temp-dir", ] +[[package]] +name = "gio" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-io", + "gio-sys 0.15.10", + "glib 0.15.12", + "libc", + "once_cell", + "thiserror", +] + [[package]] name = "gio" version = "0.16.2" @@ -673,8 +690,8 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "gio-sys", - "glib", + "gio-sys 0.16.0", + "glib 0.16.2", "libc", "once_cell", "pin-project-lite", @@ -682,19 +699,52 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gio-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d" +dependencies = [ + "glib-sys 0.15.10", + "gobject-sys 0.15.10", + "libc", + "system-deps", + "winapi", +] + [[package]] name = "gio-sys" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6da1bba9d3f2ab13a6e9932c40f240dc99ebc9f0bdc35cfb130d1a3df36f374c" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "libc", "system-deps", "winapi", ] +[[package]] +name = "glib" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "glib-macros 0.15.11", + "glib-sys 0.15.10", + "gobject-sys 0.15.10", + "libc", + "once_cell", + "smallvec", + "thiserror", +] + [[package]] name = "glib" version = "0.16.2" @@ -707,16 +757,31 @@ dependencies = [ "futures-executor", "futures-task", "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", + "gio-sys 0.16.0", + "glib-macros 0.16.0", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "libc", "once_cell", "smallvec", "thiserror", ] +[[package]] +name = "glib-macros" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a68131a662b04931e71891fb14aaf65ee4b44d08e8abc10f49e77418c86c64" +dependencies = [ + "anyhow", + "heck", + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "glib-macros" version = "0.16.0" @@ -732,6 +797,16 @@ dependencies = [ "syn", ] +[[package]] +name = "glib-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glib-sys" version = "0.16.0" @@ -754,13 +829,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gobject-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a" +dependencies = [ + "glib-sys 0.15.10", + "libc", + "system-deps", +] + [[package]] name = "gobject-sys" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63ca11a57400f3d4fda594e002844be47900c9fb8b29e2155c6e37a1f24e51b3" dependencies = [ - "glib-sys", + "glib-sys 0.16.0", "libc", "system-deps", ] @@ -771,7 +857,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a8de4506a64776d90fedf9c28fdca5a7127f8cc9c78976e8184ac6f42685d8" dependencies = [ - "glib", + "glib 0.16.2", "graphene-sys", "libc", ] @@ -782,7 +868,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2c952f764f02f8546fcc5d014bc78aa704c6d453c828c8b429121f704349163" dependencies = [ - "glib-sys", + "glib-sys 0.16.0", "libc", "pkg-config", "system-deps", @@ -797,7 +883,7 @@ dependencies = [ "bitflags", "cairo-rs", "gdk4", - "glib", + "glib 0.16.2", "graphene-rs", "gsk4-sys", "libc", @@ -812,8 +898,8 @@ checksum = "2cb53e25cbbe3fa8e3e9db7c06d65085086fadbec4cd0aa567b2e2a4917db83d" dependencies = [ "cairo-sys-rs", "gdk4-sys", - "glib-sys", - "gobject-sys", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "graphene-sys", "libc", "pango-sys", @@ -838,8 +924,8 @@ dependencies = [ "futures-channel", "gdk-pixbuf", "gdk4", - "gio", - "glib", + "gio 0.16.2", + "glib 0.16.2", "graphene-rs", "gsk4", "gtk4-macros", @@ -872,9 +958,9 @@ dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.16.0", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "graphene-sys", "gsk4-sys", "libc", @@ -882,6 +968,19 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gweather-sys" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba1c54c4a21a1c6f3aea1a7691dae9d94a4cdfb434806227c596a6e912ff61" +dependencies = [ + "gio-sys 0.15.10", + "glib-sys 0.15.10", + "gobject-sys 0.15.10", + "libc", + "system-deps", +] + [[package]] name = "heck" version = "0.4.0" @@ -974,8 +1073,8 @@ dependencies = [ "futures-channel", "gdk-pixbuf", "gdk4", - "gio", - "glib", + "gio 0.16.2", + "glib 0.16.2", "gtk4", "libadwaita-sys", "libc", @@ -990,9 +1089,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ec4243e86fb53d06df2461d543529a640c9a0fba2d4cc850b70e11a85f9d952" dependencies = [ "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.16.0", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "gtk4-sys", "libc", "pango-sys", @@ -1005,6 +1104,19 @@ version = "0.2.137" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" +[[package]] +name = "libgweather" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a7d3fc0d5bccb67e9ab066f6b052922ff7a78b4694eac874453acb4a5984b2" +dependencies = [ + "bitflags", + "gio 0.15.12", + "glib 0.15.12", + "gweather-sys", + "libc", +] + [[package]] name = "locale_config" version = "0.3.0" @@ -1049,11 +1161,12 @@ dependencies = [ "futures", "futures-channel", "gettext-rs", - "gio", + "gio 0.16.2", "gtk-macros", "gtk4", "kamadak-exif", "libadwaita", + "libgweather", "log", "once_cell", "pretty_env_logger", @@ -1155,8 +1268,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7208c60f224cf6e44c551df5ee2ef38f9da0fd29d7c5a0402000b8ab0520e798" dependencies = [ "bitflags", - "gio", - "glib", + "gio 0.16.2", + "glib 0.16.2", "libc", "once_cell", "pango-sys", @@ -1168,8 +1281,8 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "922441c228366ed98d3534b87bc7c987c50564094c3abbc3513717786419252d" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.16.0", + "gobject-sys 0.16.0", "libc", "system-deps", ] diff --git a/Cargo.toml b/Cargo.toml index 7aa7494..94b4ac8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ futures-channel = "0.3.25" futures = "0.3.25" svg_metadata = "0.4.3" url = "2.3.1" +libgweather = "4.0.0" [dependencies.ashpd] version = "0.4.0-alpha.1" diff --git a/build-aux/flatpak/org.gnome.Loupe.Devel.json b/build-aux/flatpak/org.gnome.Loupe.Devel.json index 81813ea..0db1f54 100644 --- a/build-aux/flatpak/org.gnome.Loupe.Devel.json +++ b/build-aux/flatpak/org.gnome.Loupe.Devel.json @@ -46,6 +46,51 @@ "*.a" ], "modules" : [ + { + "name": "geocode-glib", + "buildsystem": "meson", + "config-opts": [ + "-Denable-installed-tests=false", + "-Denable-introspection=false", + "-Denable-gtk-doc=false", + "-Dsoup2=false" + ], + "sources": [ + { + "type": "archive", + "url": "https://download.gnome.org/sources/geocode-glib/3.26/geocode-glib-3.26.4.tar.xz", + "sha256": "2d9a6826d158470449a173871221596da0f83ebdcff98b90c7049089056a37aa", + "x-checker-data": { + "type": "gnome", + "name": "geocode-glib" + } + } + ] + }, + { + "name": "libgweather", + "buildsystem": "meson", + "config-opts": [ + "-Denable_vala=false", + "-Dgtk_doc=false", + "-Dintrospection=false", + "-Dtests=false" + ], + "sources": [ + { + "type": "archive", + "url": "https://download.gnome.org/sources/libgweather/4.2/libgweather-4.2.0.tar.xz", + "sha256": "af8a812da0d8976a000e1d62572c256086a817323fbf35b066dbfdd8d2ca6203", + "x-checker-data": { + "type": "gnome", + "name": "libgweather", + "versions": { + "<": "40.0" + } + } + } + ] + }, { "name" : "loupe", "builddir" : true, diff --git a/data/gtk/properties_view.ui b/data/gtk/properties_view.ui index 0dec98b..fd5b31d 100644 --- a/data/gtk/properties_view.ui +++ b/data/gtk/properties_view.ui @@ -78,7 +78,7 @@ - Created + File Created LpPropertiesView @@ -91,12 +91,12 @@ - Modified + File Modified LpPropertiesView - + " @@ -104,7 +104,169 @@ + + + + + LpPropertiesView + + + + + Originally Created + + + LpPropertiesView + + + + + + LpPropertiesView + + + + + + + + + Location + location_button + + + LpPropertiesView + + + + + + LpPropertiesView + + + + + + center + properties.open-location + mark-location-symbolic + Open Location + + + + + + + + + Aperture + + + LpPropertiesView + + + + + + LpPropertiesView + + + + + + + + + Exposure + + + LpPropertiesView + + + + + + LpPropertiesView + + + + + + + + + ISO + + + LpPropertiesView + + + + + + LpPropertiesView + + + + + + + + + Focal Length + + + LpPropertiesView + + + + + + LpPropertiesView + + + + + + + + + Camera + + + LpPropertiesView + + + + + + LpPropertiesView + + + + + + --> + + + diff --git a/data/gtk/window.ui b/data/gtk/window.ui index 0b14642..52ef319 100644 --- a/data/gtk/window.ui +++ b/data/gtk/window.ui @@ -188,6 +188,13 @@ + + + + image_view + + + diff --git a/src/image_metadata.rs b/src/image_metadata.rs deleted file mode 100644 index 7e3b763..0000000 --- a/src/image_metadata.rs +++ /dev/null @@ -1,104 +0,0 @@ -use gio::prelude::*; - -#[derive(Default)] -pub enum ImageMetadata { - Exif(exif::Exif), - #[default] - None, -} - -impl ImageMetadata { - pub fn load(file: &gio::File) -> Self { - if let Some(path) = file.path() { - if let Ok(file) = std::fs::File::open(path) { - let mut bufreader = std::io::BufReader::new(&file); - let exifreader = exif::Reader::new(); - - if let Ok(exif) = exifreader.read_from_container(&mut bufreader) { - return Self::Exif(exif); - } - } - } - - Self::None - } - - pub fn orientation(&self) -> Orientation { - match self { - Self::Exif(exif) => { - if let Some(orientation) = exif - .get_field(exif::Tag::Orientation, exif::In::PRIMARY) - .and_then(|x| x.value.get_uint(0)) - { - Orientation::from(orientation) - } else { - Orientation::default() - } - } - Self::None => Orientation::default(), - } - } -} - -impl std::fmt::Debug for ImageMetadata { - fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - match self { - Self::Exif(exif) => { - let list = exif.fields().into_iter().map(|f| { - let mut value = f.display_value().to_string(); - // Remove long values - if value.len() > 100 { - value = String::from("…"); - } - - (f.ifd_num.to_string(), f.tag.to_string(), value) - }); - fmt.write_str("Exif")?; - fmt.debug_list().entries(list).finish() - } - Self::None => fmt.write_str("None"), - } - } -} - -#[derive(Default, Debug, Clone, Copy)] -pub struct Orientation { - pub rotation: f64, - pub mirrored: bool, -} - -impl From for Orientation { - fn from(number: u32) -> Self { - match number { - 8 => Self { - rotation: 90., - mirrored: false, - }, - 3 => Self { - rotation: 180., - mirrored: false, - }, - 6 => Self { - rotation: 270., - mirrored: false, - }, - 2 => Self { - rotation: 0., - mirrored: true, - }, - 5 => Self { - rotation: 90., - mirrored: true, - }, - 4 => Self { - rotation: 180., - mirrored: true, - }, - 7 => Self { - rotation: 270., - mirrored: true, - }, - _ => Self::default(), - } - } -} diff --git a/src/image_metadata/gps.rs b/src/image_metadata/gps.rs new file mode 100644 index 0000000..93f3028 --- /dev/null +++ b/src/image_metadata/gps.rs @@ -0,0 +1,125 @@ +#[derive(Debug, Clone, Copy)] +pub struct GPSLocation { + pub latitude: GPSCoord, + pub longitude: GPSCoord, +} + +#[derive(Debug, Clone, Copy)] +pub struct GPSCoord { + sing: bool, + deg: f64, + min: Option, + sec: Option, +} + +impl GPSCoord { + fn to_f64(&self) -> f64 { + let sign = if self.sing { 1. } else { -1. }; + + let min = self.min.unwrap_or_default(); + let sec = self.sec.unwrap_or_default(); + + sign * (self.deg + min / 60. + sec / 60. / 60.) + } + + fn display(&self, reference: &str) -> String { + let deg = self.deg; + + if let (Some(min), Some(sec)) = (self.min, self.sec) { + format!("{deg}° {min}′ {sec}″ {reference}") + } else if let Some(min) = self.min { + format!("{deg}° {min}′ {reference}") + } else { + format!("{deg}° {reference}") + } + } + + fn latitude_sign(reference: &[Vec]) -> Option { + let reference = reference.first().and_then(|x| x.first())?; + match reference.to_ascii_uppercase() { + b'N' => Some(true), + b'S' => Some(false), + _ => None, + } + } + + fn longitude_sign(reference: &[Vec]) -> Option { + let reference = reference.first().and_then(|x| x.first())?; + match reference.to_ascii_uppercase() { + b'E' => Some(true), + b'W' => Some(false), + _ => None, + } + } + + fn position_exif(position: &[exif::Rational]) -> Option<(f64, Option, Option)> { + Some(( + position.get(0).map(exif::Rational::to_f64)?, + position.get(1).map(exif::Rational::to_f64), + position.get(2).map(exif::Rational::to_f64), + )) + } +} + +impl GPSLocation { + pub fn for_exif( + latitude: &[exif::Rational], + latitude_ref: &[Vec], + longitude: &[exif::Rational], + longitude_ref: &[Vec], + ) -> Option { + let (lat_deg, lat_min, lat_sec) = GPSCoord::position_exif(latitude)?; + let lat_sign = GPSCoord::latitude_sign(latitude_ref)?; + + let (lon_deg, lon_min, lon_sec) = GPSCoord::position_exif(longitude)?; + let lon_sign = GPSCoord::longitude_sign(longitude_ref)?; + + Some(Self { + latitude: GPSCoord { + sing: lat_sign, + deg: lat_deg, + min: lat_min, + sec: lat_sec, + }, + longitude: GPSCoord { + sing: lon_sign, + deg: lon_deg, + min: lon_min, + sec: lon_sec, + }, + }) + } + + pub fn display(&self) -> String { + if let Some(world) = libgweather::Location::world() { + let location = world.find_nearest_city(self.latitude.to_f64(), self.longitude.to_f64()); + if let (Some(city), Some(country)) = (location.city_name(), location.country_name()) { + return format!("{city}, {country}"); + } + } + + // fallback + self.latitude_display() + "\n" + &self.longitude_display() + } + + pub fn latitude_display(&self) -> String { + let coord = self.latitude; + let reference = if coord.sing { "N" } else { "S" }; + + coord.display(reference) + } + + pub fn longitude_display(&self) -> String { + let coord = self.longitude; + let reference = if coord.sing { "E" } else { "W" }; + + coord.display(reference) + } + + pub fn geo_uri(&self) -> String { + let latitude = self.latitude.to_f64(); + let longitude = self.longitude.to_f64(); + // six decimal places gives as more than a meter accuracy + format!("geo:{latitude:.6},{longitude:.6}") + } +} diff --git a/src/image_metadata/mod.rs b/src/image_metadata/mod.rs new file mode 100644 index 0000000..c4c9d1c --- /dev/null +++ b/src/image_metadata/mod.rs @@ -0,0 +1,247 @@ +/*! Resources for working with EXIF Metadata + +- +*/ + +mod gps; +mod obj; + +pub use gps::GPSLocation; +pub use obj::LpImageMetadata; + +use gio::prelude::*; + +use crate::i18n::*; + +use gtk::glib; + +#[derive(Default)] +pub enum ImageMetadata { + Exif(exif::Exif), + #[default] + None, +} + +impl std::fmt::Debug for ImageMetadata { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::Exif(exif) => { + let list = exif.fields().into_iter().map(|f| { + let mut value = f.display_value().to_string(); + // Remove long values + if value.len() > 100 { + value = String::from("…"); + } + + (f.ifd_num.to_string(), f.tag.to_string(), value) + }); + fmt.write_str("Exif")?; + fmt.debug_list().entries(list).finish() + } + Self::None => fmt.write_str("None"), + } + } +} + +impl ImageMetadata { + pub fn load(file: &gio::File) -> Self { + if let Some(path) = file.path() { + log::debug!("Loading metadata for {:?}", path); + if let Ok(file) = std::fs::File::open(path) { + let mut bufreader = std::io::BufReader::new(&file); + let exifreader = exif::Reader::new(); + + if let Ok(exif) = exifreader.read_from_container(&mut bufreader) { + return Self::Exif(exif); + } + } + } + + Self::None + } + + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + pub fn orientation(&self) -> Orientation { + match self { + Self::Exif(exif) => { + if let Some(orientation) = exif + .get_field(exif::Tag::Orientation, exif::In::PRIMARY) + .and_then(|x| x.value.get_uint(0)) + { + Orientation::from(orientation) + } else { + Orientation::default() + } + } + Self::None => Orientation::default(), + } + } + + pub fn originally_created(&self) -> Option { + if let Self::Exif(exif) = self { + if let Some(field) = exif + .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) + .or_else(|| exif.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) + { + if let exif::Value::Ascii(ascii_vec) = &field.value { + if let Some(ascii) = ascii_vec.first() { + if let Ok(dt) = exif::DateTime::from_ascii(ascii) { + if let Ok(datetime) = glib::DateTime::from_local( + dt.year.into(), + dt.month.into(), + dt.day.into(), + dt.hour.into(), + dt.minute.into(), + dt.second.into(), + ) { + // Translators: Date (%x) and time (%X) combined + if let Ok(formatted) = datetime.format(&i18n("%x %X")) { + return Some(formatted.to_string()); + } + } + } + } + } + + return Some(field.display_value().to_string()); + } + } + + None + } + + pub fn f_number(&self) -> Option { + if let Self::Exif(exif) = self { + if let Some(field) = exif.get_field(exif::Tag::FNumber, exif::In::PRIMARY) { + return Some(format!("ƒ\u{2215}{}", field.display_value())); + } + } + + None + } + + pub fn exposure_time(&self) -> Option { + if let Self::Exif(exif) = self { + let field = exif.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY)?; + if let exif::Value::Rational(rational) = &field.value { + let exposure = format!("{:.0}", 1. / rational.first()?.to_f32()); + + // Translators: Unit for exposure time in seconds + return Some(i18n_f("1\u{2215}{}\u{202F}s", &[&exposure])); + } + } + + None + } + + pub fn iso(&self) -> Option { + if let Self::Exif(exif) = self { + if let Some(field) = + exif.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) + { + return Some(field.display_value().to_string()); + } + } + + None + } + + pub fn focal_length(&self) -> Option { + if let Self::Exif(exif) = self { + let field = exif.get_field(exif::Tag::FocalLength, exif::In::PRIMARY)?; + if let exif::Value::Rational(rational) = &field.value { + let length = format!("{:.0}", rational.first()?.to_f32()); + // Translators: Unit for focal length in millimeters + return Some(i18n_f("{}\u{202F}mm", &[&length])); + } + } + + None + } + + pub fn camera_model(&self) -> Option { + if let Self::Exif(exif) = self { + if let Some(field) = exif.get_field(exif::Tag::Model, exif::In::PRIMARY) { + if let exif::Value::Ascii(value) = &field.value { + if let Some(entry) = value.first() { + return Some(String::from_utf8_lossy(entry).to_string()); + } + } + } + } + + None + } + + pub fn gps_location(&self) -> Option { + if let Self::Exif(exif) = self { + if let (Some(latitude), Some(latitude_ref), Some(longitude), Some(longitude_ref)) = ( + exif.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), + exif.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), + exif.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY), + exif.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY), + ) { + if let ( + exif::Value::Rational(latitude), + exif::Value::Ascii(latitude_ref), + exif::Value::Rational(longitude), + exif::Value::Ascii(longitude_ref), + ) = ( + &latitude.value, + &latitude_ref.value, + &longitude.value, + &longitude_ref.value, + ) { + return GPSLocation::for_exif(latitude, latitude_ref, longitude, longitude_ref); + } + } + } + + None + } +} + +#[derive(Default, Debug, Clone, Copy)] +pub struct Orientation { + pub rotation: f64, + pub mirrored: bool, +} + +impl From for Orientation { + fn from(number: u32) -> Self { + match number { + 8 => Self { + rotation: 90., + mirrored: false, + }, + 3 => Self { + rotation: 180., + mirrored: false, + }, + 6 => Self { + rotation: 270., + mirrored: false, + }, + 2 => Self { + rotation: 0., + mirrored: true, + }, + 5 => Self { + rotation: 90., + mirrored: true, + }, + 4 => Self { + rotation: 180., + mirrored: true, + }, + 7 => Self { + rotation: 270., + mirrored: true, + }, + _ => Self::default(), + } + } +} diff --git a/src/image_metadata/obj.rs b/src/image_metadata/obj.rs new file mode 100644 index 0000000..434943a --- /dev/null +++ b/src/image_metadata/obj.rs @@ -0,0 +1,107 @@ +use gio::prelude::*; + +use super::{GPSLocation, ImageMetadata, Orientation}; + +use glib::subclass::prelude::*; +use gtk::glib; +use once_cell::sync::Lazy; + +use std::cell::RefCell; + +glib::wrapper! { + pub struct LpImageMetadata(ObjectSubclass); +} + +impl LpImageMetadata { + pub fn load(file: &gio::File) -> Self { + let obj = glib::Object::new::(&[]); + + let metadata = ImageMetadata::load(file); + obj.imp().metadata.replace(metadata); + + obj + } + + pub fn orientation(&self) -> Orientation { + self.imp().metadata.borrow().orientation() + } + + pub fn gps_location(&self) -> Option { + self.imp().metadata.borrow().gps_location() + } +} + +impl From for LpImageMetadata { + fn from(metadata: ImageMetadata) -> Self { + let obj = glib::Object::new::(&[]); + + obj.imp().metadata.replace(metadata); + + obj + } +} + +impl Default for LpImageMetadata { + fn default() -> Self { + glib::Object::new(&[]) + } +} + +mod imp { + use super::*; + + #[derive(Default)] + pub struct LpImageMetadata { + pub(super) metadata: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for LpImageMetadata { + const NAME: &'static str = "LpImageMetadata"; + type Type = super::LpImageMetadata; + type ParentType = glib::Object; + } + + impl ObjectImpl for LpImageMetadata { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + let mut vec = vec![glib::ParamSpecBoolean::builder("has-information").build()]; + + vec.append( + &mut [ + "originally-created", + "location", + "f-number", + "exposure-time", + "iso", + "focal-length", + "camera-model", + ] + .iter() + .map(|name| glib::ParamSpecString::builder(name).read_only().build()) + .collect(), + ); + + vec + }); + + PROPERTIES.as_ref() + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + //let obj = self.instance(); + let metadata = self.metadata.borrow(); + match pspec.name() { + "has-information" => (!metadata.is_none()).to_value(), + "originally-created" => metadata.originally_created().to_value(), + "location" => metadata.gps_location().map(|x| x.display()).to_value(), + "f-number" => metadata.f_number().to_value(), + "exposure-time" => metadata.exposure_time().to_value(), + "iso" => metadata.iso().to_value(), + "focal-length" => metadata.focal_length().to_value(), + "camera-model" => metadata.camera_model().to_value(), + name => unimplemented!("property {name}"), + } + } + } +} diff --git a/src/widgets/image.rs b/src/widgets/image.rs index 9754cdb..a309b9f 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -26,7 +26,7 @@ use once_cell::unsync::OnceCell; use std::cell::{Cell, RefCell}; use crate::i18n::i18n; -use crate::image_metadata::ImageMetadata; +use crate::image_metadata::{ImageMetadata, LpImageMetadata}; use crate::util; const ZOOM_ANIMATION_DURATION: u32 = 200; @@ -79,7 +79,7 @@ mod imp { pub vadjustment: RefCell>, /// Currently EXIF data - pub image_metadata: RefCell, + pub image_metadata: RefCell, /// This will often be available earlier than the complete image pub image_original_size: Cell<(i32, i32)>, @@ -115,6 +115,9 @@ mod imp { glib::ParamSpecBoolean::builder("is-loaded") .read_only() .build(), + glib::ParamSpecObject::builder::("metadata") + .read_only() + .build(), glib::ParamSpecDouble::builder("rotation") .explicit_notify() .build(), @@ -148,6 +151,7 @@ mod imp { match pspec.name() { "file" => obj.file().to_value(), "is-loaded" => obj.is_loaded().to_value(), + "metadata" => obj.metadata().to_value(), "rotation" => obj.rotation().to_value(), "mirrored" => obj.mirrored().to_value(), "zoom" => obj.zoom().to_value(), @@ -572,11 +576,13 @@ impl LpImage { .await?; let orientation = metadata.orientation(); - imp.image_metadata.replace(metadata); + imp.image_metadata.replace(LpImageMetadata::from(metadata)); imp.rotation_target.set(-orientation.rotation); imp.rotation.set(-orientation.rotation); imp.mirrored.set(orientation.mirrored); + self.notify("metadata"); + log::debug!("Loading image size"); let (_, x, y) = util::spawn( "image-load-size", @@ -1082,6 +1088,10 @@ impl LpImage { self.width() as f64 } + pub fn metadata(&self) -> LpImageMetadata { + self.imp().image_metadata.borrow().clone() + } + /// Drag and drop content provider pub fn content_provider(&self) -> Option { let file = self.file()?; diff --git a/src/widgets/properties_view.rs b/src/widgets/properties_view.rs index fb8b80a..d0ddc24 100644 --- a/src/widgets/properties_view.rs +++ b/src/widgets/properties_view.rs @@ -34,7 +34,10 @@ use std::cell::RefCell; use gtk_macros::spawn; -const FALLBACK: &str = "-"; +use crate::image_metadata::LpImageMetadata; +use crate::widgets::image::LpImage; + +const FALLBACK: &str = "–"; mod imp { use super::*; @@ -43,6 +46,10 @@ mod imp { #[template(resource = "/org/gnome/Loupe/gtk/properties_view.ui")] pub struct LpPropertiesView { pub file: RefCell>>, + pub image: RefCell>>, + + pub metadata: RefCell, + pub file_info: RefCell>, pub dimensions: RefCell>, @@ -63,6 +70,10 @@ mod imp { klass.install_action("properties.open-folder", None, move |properties, _, _| { properties.open_directory(); }); + + klass.install_action("properties.open-location", None, move |properties, _, _| { + properties.open_location(); + }); } fn instance_init(obj: &glib::subclass::InitializingObject) { @@ -86,6 +97,12 @@ mod imp { .read_only() .explicit_notify() .build(), + glib::ParamSpecObject::builder::("image") + .explicit_notify() + .build(), + glib::ParamSpecObject::builder::("metadata") + .read_only() + .build(), ] }); @@ -98,6 +115,8 @@ mod imp { match prop_name { "file" => obj.file().to_value(), + "image" => obj.image().to_value(), + "metadata" => self.metadata.borrow().to_value(), "file-info" => obj.file_info().to_value(), "dimensions" => self.dimensions.borrow().to_value(), _ => unimplemented!("Failed to get property \"{prop_name}\""), @@ -110,6 +129,7 @@ mod imp { match prop_name { "file" => obj.set_file(&value.get().ok()), + "image" => obj.set_image(&value.get().ok()), _ => unimplemented!(), } } @@ -119,6 +139,24 @@ mod imp { self.parent_constructed(); obj.action_set_enabled("properties.open-folder", false); + + // watch changes to image metadata + obj.property_expression("image") + .chain_property::("metadata") + .watch( + glib::Object::NONE, + glib::clone!(@weak obj => move || { + let metadata = if let Some(image) = obj.image() { + image.metadata() + } else { + LpImageMetadata::default() + }; + + // set metadata since image might have changed + obj.imp().metadata.replace(metadata); + obj.notify("metadata"); + }), + ); } } @@ -167,6 +205,18 @@ impl LpPropertiesView { imp.file.borrow().as_ref().and_then(|w| w.upgrade()) } + fn set_image(&self, image: &Option) { + let imp = self.imp(); + + imp.image.replace(image.as_ref().map(|x| x.downgrade())); + self.notify("image"); + } + + fn image(&self) -> Option { + let imp = self.imp(); + imp.image.borrow().as_ref().and_then(|w| w.upgrade()) + } + fn file_info(&self) -> Option { let imp = self.imp(); imp.file_info.borrow().clone() @@ -304,6 +354,26 @@ impl LpPropertiesView { } } + /// Open GPS location in apps like Maps via `geo:` URI + fn open_location(&self) { + if let Some(uri) = self + .image() + .and_then(|x| x.metadata().gps_location()) + .map(|x| x.geo_uri()) + { + gio::AppInfo::launch_default_for_uri_async( + &uri, + gio::AppLaunchContext::NONE, + gio::Cancellable::NONE, + |result| { + if let Err(err) = result { + log::error!("Failed to show geo URI: {err}") + } + }, + ); + } + } + // In the LpPropertiesView UI file we define a few `gtk::Expression`s // that are closures. These closures take either the current `gio::File` // or the current file's associated `gio::FileInfo` and process them @@ -356,6 +426,11 @@ impl LpPropertiesView { .map(|t| t.to_string()) .unwrap_or_else(|| FALLBACK.to_owned()) } + + #[template_callback] + fn has_content(&self, content: Option) -> bool { + content.map_or(false, |x| !x.is_empty()) + } } async fn parse_svg_file_future(path: std::path::PathBuf) -> anyhow::Result {