use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
use serde_json::{Map, Value};
pub type Metadata = Map<String, Value>;
pub fn have(tool: &str) -> bool {
which(tool).is_some()
}
pub fn which(tool: &str) -> Option<std::path::PathBuf> {
if tool.contains('/') {
let direct = std::path::PathBuf::from(tool);
return direct.is_file().then_some(direct);
}
std::env::var_os("PATH")?
.to_str()?
.split(':')
.filter(|dir| !dir.is_empty())
.map(|dir| std::path::Path::new(dir).join(tool))
.find(|candidate| candidate.is_file())
}
pub fn require(tool: &str, why: &str) -> Result<(), String> {
if have(tool) {
return Ok(());
}
let debian = if tool == "exiftool" {
"libimage-exiftool-perl"
} else {
tool
};
Err(format!(
"{tool} is not installed, and {why}.\n \
Debian/Ubuntu: sudo apt install {debian}\n \
macOS: brew install {tool}\n \
Windows: choco install {tool}"
))
}
pub fn read_metadata(path: &Path) -> Result<Metadata, String> {
let path = path
.canonicalize()
.map_err(|_| "not a regular file".to_string())?;
if !path.is_file() {
return Err("not a regular file".into());
}
require("exiftool", "reading metadata needs it")?;
let out = Command::new("exiftool")
.args(["-j", "-G", "-n", "-a", "-u"])
.arg(&path)
.output()
.map_err(|e| format!("could not run exiftool: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.trim().is_empty() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if err.is_empty() {
"ExifTool could not read this file".into()
} else {
truncate(&err, 300)
});
}
let reports: Vec<Value> =
serde_json::from_str(&stdout).map_err(|e| format!("invalid ExifTool JSON: {e}"))?;
let metadata = reports
.into_iter()
.next()
.and_then(|v| match v {
Value::Object(map) => Some(map),
_ => None,
})
.ok_or_else(|| "invalid ExifTool JSON: no report".to_string())?;
if let Some(error) = metadata.get("ExifTool:Error") {
return Err(render(error));
}
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if err.is_empty() {
format!("ExifTool exited {}", out.status.code().unwrap_or(-1))
} else {
truncate(&err, 300)
});
}
let mime = metadata
.get("File:MIMEType")
.map(render)
.unwrap_or_default();
if !mime.starts_with("image/") {
let kind = metadata
.get("File:FileType")
.map(render)
.unwrap_or_else(|| "unknown".into());
return Err(format!("not an image (ExifTool identified {kind})"));
}
Ok(metadata)
}
pub fn render(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Array(items) => items.iter().map(render).collect::<Vec<_>>().join(", "),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
pub fn orientation_value(meta: &Metadata) -> Option<u16> {
let raw = meta.get("EXIF:Orientation")?;
let n = match raw {
Value::Number(n) => n.as_u64()?,
Value::String(s) => s.parse().ok()?,
_ => return None,
};
(2..=8).contains(&n).then_some(n as u16)
}
pub fn orientation_payload(orientation: u16) -> Vec<u8> {
let mut out = Vec::with_capacity(32);
out.extend_from_slice(b"Exif\x00\x00");
out.extend_from_slice(b"MM\x00\x2a"); out.extend_from_slice(&8u32.to_be_bytes()); out.extend_from_slice(&1u16.to_be_bytes()); out.extend_from_slice(&0x0112u16.to_be_bytes()); out.extend_from_slice(&3u16.to_be_bytes()); out.extend_from_slice(&1u32.to_be_bytes()); out.extend_from_slice(&orientation.to_be_bytes()); out.extend_from_slice(&[0, 0]);
out.extend_from_slice(&0u32.to_be_bytes()); out
}
#[derive(Debug, Default, Clone)]
pub struct Credit {
pub creator: Option<String>,
pub credit: Option<String>,
pub copyright: Option<String>,
pub terms: Option<String>,
pub rights_url: Option<String>,
pub licensor: Option<String>,
pub licensor_url: Option<String>,
pub contact: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub keywords: Vec<String>,
}
impl Credit {
pub fn is_empty(&self) -> bool {
self.creator.is_none()
&& self.credit.is_none()
&& self.copyright.is_none()
&& self.terms.is_none()
&& self.rights_url.is_none()
&& self.licensor.is_none()
&& self.licensor_url.is_none()
&& self.contact.is_none()
&& self.title.is_none()
&& self.description.is_none()
&& self.keywords.is_empty()
}
pub fn args(&self, drop_gps: bool) -> Vec<String> {
let mut a: Vec<String> = Vec::new();
let mut add = |s: String| a.push(s);
let writes_iptc = self.creator.is_some()
|| self.credit.is_some()
|| self.copyright.is_some()
|| self.title.is_some()
|| self.description.is_some()
|| !self.keywords.is_empty();
if writes_iptc {
add("-IPTC:CodedCharacterSet=UTF8".into());
}
if let Some(v) = &self.creator {
add(format!("-XMP-dc:Creator={v}"));
add(format!("-IPTC:By-line={v}"));
add(format!("-EXIF:Artist={v}"));
}
if let Some(v) = &self.credit {
add(format!("-XMP-photoshop:Credit={v}"));
add(format!("-IPTC:Credit={v}"));
add(format!("-XMP-photoshop:Source={v}"));
add(format!("-IPTC:Source={v}"));
}
if let Some(v) = &self.copyright {
add(format!("-XMP-dc:Rights={v}"));
add(format!("-IPTC:CopyrightNotice={v}"));
add(format!("-EXIF:Copyright={v}"));
add("-XMP-xmpRights:Marked=True".into());
}
if let Some(v) = &self.terms {
add(format!("-XMP-xmpRights:UsageTerms={v}"));
}
if let Some(v) = &self.rights_url {
add(format!("-XMP-xmpRights:WebStatement={v}"));
}
if let Some(v) = &self.licensor {
add(format!("-XMP-plus:LicensorName={v}"));
}
if let Some(v) = &self.licensor_url {
add(format!("-XMP-plus:LicensorURL={v}"));
}
if let Some(v) = &self.contact {
let key = if v.contains('@') {
"CreatorWorkEmail"
} else {
"CreatorWorkURL"
};
add(format!("-XMP-iptcCore:{key}={v}"));
}
if let Some(v) = &self.title {
add(format!("-XMP-dc:Title={v}"));
add(format!("-IPTC:ObjectName={v}"));
add(format!("-IPTC:Headline={v}"));
}
if let Some(v) = &self.description {
add(format!("-XMP-dc:Description={v}"));
add(format!("-IPTC:Caption-Abstract={v}"));
}
for k in &self.keywords {
add(format!("-XMP-dc:Subject+={k}"));
add(format!("-IPTC:Keywords+={k}"));
}
if drop_gps {
add("-gps:all=".into());
}
a
}
}
pub fn write_credit(path: &Path, credit: &Credit, drop_gps: bool) -> Result<(), String> {
require("exiftool", "writing metadata needs it")?;
let fields = credit.args(drop_gps);
if fields.is_empty() {
return Err("nothing to write".into());
}
let out = Command::new("exiftool")
.args(["-overwrite_original", "-q", "-P"])
.args(&fields)
.arg(path)
.output()
.map_err(|e| format!("could not run exiftool: {e}"))?;
if out.status.success() {
return Ok(());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() {
"exiftool failed".into()
} else {
truncate(&err, 300)
})
}
pub const CREDIT_FIELDS: [(&str, &[&str]); 10] = [
("Creator", &["XMP:Creator", "IPTC:By-line", "EXIF:Artist"]),
("Credit", &["XMP:Credit", "IPTC:Credit"]),
(
"Copyright",
&["XMP:Rights", "IPTC:CopyrightNotice", "EXIF:Copyright"],
),
("Usage terms", &["XMP:UsageTerms"]),
("Rights URL", &["XMP:WebStatement"]),
("Licensor", &["XMP:LicensorName", "XMP:LicensorURL"]),
("Contact", &["XMP:CreatorWorkEmail", "XMP:CreatorWorkURL"]),
("Title", &["XMP:Title", "IPTC:ObjectName"]),
("Description", &["XMP:Description", "IPTC:Caption-Abstract"]),
("Keywords", &["XMP:Subject", "IPTC:Keywords"]),
];
pub const GPS_FIELDS: [&str; 4] = [
"EXIF:GPSLatitude",
"EXIF:GPSLongitude",
"Composite:GPSPosition",
"EXIF:GPSAltitude",
];
pub const CAMERA_FIELDS: [(&str, &[&str]); 4] = [
("Camera", &["EXIF:Make", "EXIF:Model"]),
("Lens", &["EXIF:LensModel", "EXIF:LensInfo"]),
("Taken", &["EXIF:DateTimeOriginal", "EXIF:CreateDate"]),
("Software", &["EXIF:Software", "XMP:CreatorTool"]),
];
pub fn first(meta: &Metadata, keys: &[&str]) -> Option<String> {
for key in keys {
match meta.get(*key) {
None | Some(Value::Null) => continue,
Some(Value::String(s)) if s.is_empty() => continue,
Some(Value::Array(a)) if a.is_empty() => continue,
Some(v) => return Some(render(v)),
}
}
None
}
pub fn group(meta: &Metadata, fields: &[(&str, &[&str])]) -> BTreeMap<String, String> {
fields
.iter()
.filter_map(|(label, keys)| first(meta, keys).map(|v| (label.to_string(), v)))
.collect()
}
pub fn ordered_group(meta: &Metadata, fields: &[(&str, &[&str])]) -> Vec<(String, Value)> {
fields
.iter()
.filter_map(|(label, keys)| first_value(meta, keys).map(|v| (label.to_string(), v)))
.collect()
}
pub fn first_value(meta: &Metadata, keys: &[&str]) -> Option<Value> {
for key in keys {
match meta.get(*key) {
None | Some(Value::Null) => continue,
Some(Value::String(s)) if s.is_empty() => continue,
Some(Value::Array(a)) if a.is_empty() => continue,
Some(v) => return Some(v.clone()),
}
}
None
}