//! TRACES: FR-DEV-6 //! Reading a Lightroom `.xmp` preset into one of ours. //! //! # Why this is possible at all //! //! Adobe's Camera Raw settings and ours agree on more than they had any //! obligation to. Exposure is in stops in both; contrast, the four recovery //! controls, clarity, texture, vibrance and saturation are all ±100 in both. //! That is not imitation — it is the convention raw developers converged on, //! and `highlights_shadows.yaml` cites it in as many words ("matching the //! convention every other developer uses"). So most of the table below is a //! rename rather than a conversion. //! //! # Why this is its own crate //! //! Two walls, and it sits between them. It cannot live in `dr-pipeline`, //! which depends on nothing deliberately — that is what lets the descriptor //! and codegen logic be tested without a device (ARCH §6.5a) — and XMP is real //! XML with namespaces, not worth hand-rolling. And it cannot live in `dr-ui`, //! because the table below *names operations* and nothing in the interface may //! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a //! mapping from Adobe's vocabulary to ours is a statement about the pipeline, //! not about any interface. //! //! So: a small crate below the interface and beside the pipeline, which is how //! the rest of `core/` is already organised. //! //! # What is deliberately not imported //! //! **White balance.** Lightroom writes `crs:Temperature` as absolute Kelvin //! for a raw file; ours is a relative ±100 nudge away from what the camera //! recorded. Converting between them needs the *target image's* as-shot white //! balance, which is exactly what a preset does not carry — the same preset //! lands on a frame shot at 3200K and one shot at 7000K. A guess here would be //! wrong on most images and invisibly so, which is worse than an honest gap, //! so these keys are counted as skipped and reported. //! //! **Tone curves, colour mixing, masks, lens profiles and grain.** Each is a //! structure rather than a number, and each would need its own argument about //! whether the two applications mean the same thing. They are skipped by //! omission — a key not in the table is simply not understood — and the //! [`Import::skipped`] count is what tells the user their preset arrived //! partial. use std::collections::BTreeMap; use dr_pipeline::{Preset, Reach}; use quick_xml::events::Event; use quick_xml::XmlVersion; /// How a Lightroom value becomes one of ours. #[derive(Debug, Clone, Copy)] enum Convert { /// Same units, same range. Most of the table. Direct, /// Same zero, different full deflection — multiply. /// /// Carries the factor rather than the two ranges because that is what the /// arithmetic needs, and a range pair would invite the reader to check it /// against a descriptor that is not the authority for Adobe's half. Scale(f32), } /// One key we understand. struct Mapping { /// The attribute name inside the `crs` namespace. crs: &'static str, op: &'static str, param: &'static str, convert: Convert, } /// Every Camera Raw key this build can translate. /// /// The op and param names are checked against the pipeline's own descriptors /// by a test, so a renamed parameter breaks the build rather than silently /// dropping a setting on import. const MAPPINGS: &[Mapping] = &[ // Stops in both. The one mapping that needed no thought at all. Mapping { crs: "Exposure2012", op: "exposure", param: "exposure", convert: Convert::Direct, }, Mapping { crs: "Contrast2012", op: "contrast", param: "contrast", convert: Convert::Direct, }, Mapping { crs: "Highlights2012", op: "highlights_shadows", param: "highlights", convert: Convert::Direct, }, Mapping { crs: "Shadows2012", op: "highlights_shadows", param: "shadows", convert: Convert::Direct, }, Mapping { crs: "Whites2012", op: "blacks_whites", param: "whites", convert: Convert::Direct, }, Mapping { crs: "Blacks2012", op: "blacks_whites", param: "blacks", convert: Convert::Direct, }, Mapping { crs: "Clarity2012", op: "clarity", param: "amount", convert: Convert::Direct, }, Mapping { crs: "Texture", op: "texture", param: "amount", convert: Convert::Direct, }, Mapping { crs: "Vibrance", op: "vibrance", param: "vibrance", convert: Convert::Direct, }, Mapping { crs: "Saturation", op: "saturation", param: "saturation", convert: Convert::Direct, }, // Adobe's sharpening runs 0…150 where ours runs 0…100, so a preset asking // for its maximum gets ours rather than being clamped there silently. Mapping { crs: "Sharpness", op: "capture_sharpen", param: "amount", convert: Convert::Scale(100.0 / 150.0), }, Mapping { crs: "LuminanceSmoothing", op: "noise_reduction", param: "luminance", convert: Convert::Direct, }, Mapping { crs: "ColorNoiseReduction", op: "noise_reduction", param: "chroma", convert: Convert::Direct, }, ]; /// Keys that are understood well enough to know we are *not* importing them. /// /// Separate from silence so the report can say "the white balance did not come /// across" rather than leaving the photographer to notice. See the module note. const KNOWN_UNSUPPORTED: &[&str] = &[ "Temperature", "Tint", "ToneCurvePV2012", "ToneCurvePV2012Red", "ToneCurvePV2012Green", "ToneCurvePV2012Blue", ]; /// What came of reading one file. #[derive(Debug, Clone, PartialEq)] pub struct Import { /// The preset's name, from `crs:Name` where the file carries one. pub name: Option, /// The settings that translated. pub preset: Preset, /// Keys recognised as deliberately unsupported — the white balance, the /// tone curve. Named, so the report can be specific. pub skipped: Vec, } #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum ImportError { #[error("not readable as XML: {0}")] NotXml(String), #[error("no Camera Raw settings in this file")] NoSettings, } /// Read one Lightroom `.xmp` preset. /// /// Tolerant in the same direction the sidecar parser is: a value that will not /// parse as a number costs that setting and not the file, because a preset /// that arrives missing its contrast is still worth having and an error /// message is not. /// /// Camera Raw writes its settings either as attributes on an /// `rdf:Description` or as child elements of one, depending on version and on /// whether the value is a structure. Both are read, because a photographer's /// preset folder spans a decade of Lightroom versions and the file does not /// say which shape it used. pub fn read_xmp(text: &str) -> Result { let mut reader = quick_xml::Reader::from_str(text); reader.config_mut().trim_text(true); let mut found: BTreeMap = BTreeMap::new(); let mut name: Option = None; let mut pending_element: Option = None; // Inside `` the text is the preset's name. let mut in_name = false; loop { match reader.read_event() { Err(e) => return Err(ImportError::NotXml(e.to_string())), Ok(Event::Eof) => break, Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { let local = local_name(e.name().as_ref()); if local == "Name" { in_name = true; } pending_element = Some(local.clone()); for attribute in e.attributes().flatten() { let key = local_name(attribute.key.as_ref()); // `normalized_value` rather than the deprecated // `unescape_value`. `Implicit1_0` is the right version // here: an XMP packet opens with `` rather than // an XML declaration, so the version is unstated and the // specification says to assume 1.0. let Ok(value) = attribute.normalized_value(XmlVersion::Implicit1_0) else { continue; }; found.entry(key).or_insert_with(|| value.to_string()); } } Ok(Event::End(e)) => { if local_name(e.name().as_ref()) == "Name" { in_name = false; } pending_element = None; } Ok(Event::Text(e)) => { let Ok(text) = e.xml10_content() else { continue; }; let text = text.trim().to_string(); if text.is_empty() { continue; } if in_name && name.is_none() { name = Some(text.clone()); } if let Some(key) = pending_element.clone() { found.entry(key).or_insert(text); } } _ => {} } } if found.is_empty() { return Err(ImportError::NoSettings); } let mut params: BTreeMap<(String, String), f32> = BTreeMap::new(); for mapping in MAPPINGS { let Some(raw) = found.get(mapping.crs) else { continue; }; // Adobe writes a leading `+` on positive numbers, which Rust's float // parser accepts, and occasionally a trailing `%`, which it does not. let Ok(value) = raw.trim().trim_end_matches('%').parse::() else { log::debug!("xmp: {} is not a number: {raw:?}", mapping.crs); continue; }; let value = match mapping.convert { Convert::Direct => value, Convert::Scale(by) => value * by, }; // Out-of-range values are left as they are: `EditGraph::set_param` // clamps when the preset is applied, and clamping here as well would // mean two places to be wrong about a range. params.insert((mapping.op.to_string(), mapping.param.to_string()), value); } let skipped = KNOWN_UNSUPPORTED .iter() .filter(|k| found.contains_key(**k)) .map(|k| k.to_string()) .collect(); if params.is_empty() && name.is_none() { return Err(ImportError::NoSettings); } Ok(Import { name, // A look, because that is what a Lightroom preset is: it changes the // settings it was saved with and leaves every other one where the // photograph had it. Applied as a whole edit instead, a preset // holding only a grade would reset the exposure it was put on top of. preset: Preset::from_params(params).with_reach(Reach::Named), skipped, }) } /// The part of `ns:Local` after the colon. /// /// Namespace prefixes are declared per document and Camera Raw is not the only /// thing that writes XMP, so matching on the prefix would be matching on a /// convention rather than on the standard. Comparing local names is what makes /// a file written by a different tool with a different prefix still readable. fn local_name(raw: &[u8]) -> String { let name = String::from_utf8_lossy(raw); match name.split_once(':') { Some((_, local)) => local.to_string(), None => name.to_string(), } } #[cfg(test)] mod tests { use super::*; use dr_pipeline::{EditGraph, Scope}; /// The attribute form: settings as attributes on `rdf:Description`. const ATTRIBUTE_FORM: &str = r#" Warm Portrait "#; /// The element form: the same settings as child elements. const ELEMENT_FORM: &str = r#" +0.75 +25 "#; fn value(import: &Import, op: &str, param: &str) -> Option { import .preset .params() .get(&(op.to_string(), param.to_string())) .copied() } #[test] fn every_mapping_names_a_parameter_this_build_actually_has() { // The test that keeps the table honest. Adobe's half cannot be checked // from here, but ours can: a renamed operation or parameter must break // this rather than silently drop a setting on every import. let graph = EditGraph::default_chain(); let capabilities = graph.capabilities(); for mapping in MAPPINGS { let op = capabilities .iter() .find(|c| c.id.0 == mapping.op) .unwrap_or_else(|| panic!("no operation {:?}", mapping.op)); assert!( op.params.iter().any(|p| p.id.0 == mapping.param), "operation {:?} has no parameter {:?}", mapping.op, mapping.param ); } } #[test] fn no_two_mappings_claim_the_same_key_or_the_same_target() { let mut keys: Vec<&str> = MAPPINGS.iter().map(|m| m.crs).collect(); keys.sort_unstable(); let before = keys.len(); keys.dedup(); assert_eq!(before, keys.len(), "two mappings read the same crs key"); let mut targets: Vec<(&str, &str)> = MAPPINGS.iter().map(|m| (m.op, m.param)).collect(); targets.sort_unstable(); let before = targets.len(); targets.dedup(); assert_eq!( before, targets.len(), "two mappings write the same parameter" ); } #[test] fn the_settings_that_share_a_convention_come_across_unchanged() { let import = read_xmp(ATTRIBUTE_FORM).unwrap(); assert_eq!(value(&import, "exposure", "exposure"), Some(0.75)); assert_eq!(value(&import, "contrast", "contrast"), Some(25.0)); assert_eq!( value(&import, "highlights_shadows", "highlights"), Some(-40.0) ); assert_eq!(value(&import, "highlights_shadows", "shadows"), Some(30.0)); assert_eq!(value(&import, "blacks_whites", "whites"), Some(10.0)); assert_eq!(value(&import, "blacks_whites", "blacks"), Some(-15.0)); assert_eq!(value(&import, "clarity", "amount"), Some(12.0)); assert_eq!(value(&import, "texture", "amount"), Some(8.0)); assert_eq!(value(&import, "vibrance", "vibrance"), Some(20.0)); assert_eq!(value(&import, "saturation", "saturation"), Some(-5.0)); } #[test] fn a_range_that_differs_is_rescaled_rather_than_clamped() { // Adobe's sharpening runs to 150. A preset asking for half of its // range should ask for half of ours, not for two thirds of it. let import = read_xmp(ATTRIBUTE_FORM).unwrap(); assert_eq!(value(&import, "capture_sharpen", "amount"), Some(50.0)); } #[test] fn the_white_balance_is_reported_as_skipped_rather_than_guessed_at() { // The honest gap. Adobe's Kelvin cannot become our relative nudge // without the target image's as-shot white balance, which a preset // does not carry. let import = read_xmp(ATTRIBUTE_FORM).unwrap(); assert!(value(&import, "white_balance", "temperature").is_none()); assert!(value(&import, "white_balance", "tint").is_none()); assert!(import.skipped.contains(&"Temperature".to_string())); assert!(import.skipped.contains(&"Tint".to_string())); } #[test] fn the_preset_carries_its_name() { assert_eq!( read_xmp(ATTRIBUTE_FORM).unwrap().name.as_deref(), Some("Warm Portrait") ); } #[test] fn settings_written_as_elements_read_the_same_as_settings_written_as_attributes() { // A preset folder spans a decade of Lightroom versions and the file // does not say which shape it used. let import = read_xmp(ELEMENT_FORM).unwrap(); assert_eq!(value(&import, "exposure", "exposure"), Some(0.75)); assert_eq!(value(&import, "contrast", "contrast"), Some(25.0)); } #[test] fn an_imported_preset_applies_like_any_other() { // The point of the exercise: what comes out is a `Preset`, not a // second kind of thing with a second apply path. let import = read_xmp(ATTRIBUTE_FORM).unwrap(); let mut graph = EditGraph::default_chain(); import .preset .apply(&mut graph, Scope::adjustments()) .expect_no_film(); assert_eq!( graph.param( dr_pipeline::ops::exposure::ID, dr_pipeline::ops::exposure::EXPOSURE ), Some(0.75) ); } /// TRACES: FR-DEV-6 /// Lightroom's own rule: a preset changes what it was saved with and /// nothing else, so a grade lands on top of the photograph's correction. #[test] fn an_imported_preset_leaves_what_it_does_not_set_alone() { use dr_pipeline::ops::dehaze; let import = read_xmp(ATTRIBUTE_FORM).unwrap(); assert_eq!(import.preset.reach(), Reach::Named); assert!(value(&import, "dehaze", "amount").is_none()); let mut graph = EditGraph::default_chain(); graph.set_param(dehaze::ID, dehaze::AMOUNT, 30.0); import .preset .apply(&mut graph, Scope::adjustments()) .expect_no_film(); assert_eq!(graph.param(dehaze::ID, dehaze::AMOUNT), Some(30.0)); } #[test] fn a_value_that_is_not_a_number_costs_that_setting_and_not_the_file() { let text = ATTRIBUTE_FORM.replace(r#"crs:Contrast2012="+25""#, r#"crs:Contrast2012="lots""#); let import = read_xmp(&text).unwrap(); assert_eq!(value(&import, "contrast", "contrast"), None); assert_eq!(value(&import, "exposure", "exposure"), Some(0.75)); } #[test] fn a_file_that_is_not_xml_is_refused() { assert!(matches!( read_xmp(""), Err(ImportError::NotXml(_)) | Err(ImportError::NoSettings) )); } #[test] fn a_file_with_no_camera_raw_settings_is_refused() { let text = r#""#; assert_eq!(read_xmp(text), Err(ImportError::NoSettings)); } #[test] fn a_prefix_other_than_crs_is_still_read() { // Namespace prefixes are declared per document. Matching on `crs:` // would be matching on a convention rather than on the standard. let text = ELEMENT_FORM.replace("crs:", "cameraraw:"); let import = read_xmp(&text).unwrap(); assert_eq!(value(&import, "exposure", "exposure"), Some(0.75)); } // ----------------------------------------------------------------------- // Reading a folder // ----------------------------------------------------------------------- fn tempdir(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( "dr-preset-import-{name}-{}-{:?}", std::process::id(), std::thread::current().id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn a_folder_of_presets_is_read_including_its_subfolders() { // The shape a photographer's presets are actually in: an exported // Lightroom folder, nested one level per group. let dir = tempdir("folder"); std::fs::write(dir.join("one.xmp"), ATTRIBUTE_FORM).unwrap(); let group = dir.join("Portraits"); std::fs::create_dir_all(&group).unwrap(); std::fs::write(group.join("two.xmp"), ELEMENT_FORM).unwrap(); // Not a preset, and not an error either. std::fs::write(dir.join("notes.txt"), "ignore me").unwrap(); let report = read_path(&dir); assert_eq!(report.presets.len(), 2, "{report:?}"); assert_eq!(report.failed, 0); } #[test] fn a_subfolder_becomes_the_category_of_what_it_holds() { // The folder picked is the root and names nothing; each folder under // it is a level of category, as Lightroom's groups were. let dir = tempdir("categories"); std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap(); let nested = dir.join("Film").join("Colour"); std::fs::create_dir_all(&nested).unwrap(); std::fs::write(nested.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap(); let names: Vec<_> = read_path(&dir).presets.into_iter().map(|p| p.0).collect(); assert_eq!(names, ["Film/Colour/Golden Hour", "Golden Hour"]); } #[test] fn a_slash_in_a_displayed_name_is_not_a_category() { let dir = tempdir("slash"); std::fs::write( dir.join("p.xmp"), ATTRIBUTE_FORM.replace("Warm Portrait", "Warm / Cool"), ) .unwrap(); let report = read_path(&dir); assert_eq!(report.presets[0].0, "Warm \u{2215} Cool"); } #[test] fn a_preset_without_a_name_is_called_after_its_file() { // Lightroom writes the name it displays, which is not always the file // name — but a file with no `crs:Name` still has to arrive callable. let dir = tempdir("unnamed"); std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap(); let report = read_path(&dir); assert_eq!(report.presets[0].0, "Golden Hour"); } #[test] fn the_displayed_name_wins_over_the_file_name() { let dir = tempdir("named"); std::fs::write(dir.join("preset_04b.xmp"), ATTRIBUTE_FORM).unwrap(); let report = read_path(&dir); assert_eq!(report.presets[0].0, "Warm Portrait"); } #[test] fn an_unreadable_file_is_counted_rather_than_stopping_the_import() { // Ninety presets and one bad file should import eighty-nine. let dir = tempdir("partial"); std::fs::write(dir.join("good.xmp"), ATTRIBUTE_FORM).unwrap(); std::fs::write(dir.join("bad.xmp"), "not xml at all <<<").unwrap(); let report = read_path(&dir); assert_eq!(report.presets.len(), 1); assert_eq!(report.failed, 1); } #[test] fn the_unsupported_keys_are_gathered_once_and_not_once_per_file() { // After forty presets, "the white balance did not come across" is the // useful sentence; forty repetitions of it is not. let dir = tempdir("gathered"); std::fs::write(dir.join("a.xmp"), ATTRIBUTE_FORM).unwrap(); std::fs::write(dir.join("b.xmp"), ATTRIBUTE_FORM).unwrap(); let report = read_path(&dir); assert_eq!(report.presets.len(), 2); assert!(report.unsupported.contains("Temperature")); assert_eq!( report .unsupported .iter() .filter(|k| *k == "Temperature") .count(), 1 ); } #[test] fn a_single_file_can_be_imported_on_its_own() { let dir = tempdir("single"); let file = dir.join("one.xmp"); std::fs::write(&file, ATTRIBUTE_FORM).unwrap(); assert_eq!(read_path(&file).presets.len(), 1); } #[test] fn a_path_that_is_not_there_reports_nothing_rather_than_failing() { let report = read_path(std::path::Path::new("/definitely/not/here")); assert_eq!(report, Report::default()); } } // --------------------------------------------------------------------------- // Reading a folder of them // --------------------------------------------------------------------------- /// What came of reading a path. #[derive(Debug, Clone, Default, PartialEq)] pub struct Report { /// Presets read and named, ready to be stored. pub presets: Vec<(String, Preset)>, /// Files that were `.xmp` but did not yield a preset. pub failed: usize, /// Every deliberately-unsupported key seen across the whole run. /// /// Gathered rather than counted: after importing forty presets, "the white /// balance did not come across" is the useful sentence, and forty /// repetitions of it is not. pub unsupported: std::collections::BTreeSet, } /// Read one `.xmp` file, or every `.xmp` under a folder. /// /// A folder because that is the shape a photographer's presets are in — an /// exported Lightroom preset folder, nested one level per group — and asking /// them to import ninety files one at a time would be asking them not to /// bother. Nested folders are walked, and each one below `path` becomes a /// category: a preset in `Portraits/` is named `Portraits/Warm skin`, which is /// how the preset menu files it (see `PresetLibrary`'s note on categories). /// /// The *name* comes from `crs:Name` where the file carries one and from the /// file stem where it does not. Lightroom writes the name it displays, which /// is not always the file name, and the displayed name is the one the /// photographer will look for. A `/` inside that name would read as a /// category it never had, so it becomes `∕`, which looks the same and /// separates nothing. pub fn read_path(path: &std::path::Path) -> Report { let mut report = Report::default(); read_into(path, "", &mut report); report.presets.sort_by(|a, b| a.0.cmp(&b.0)); report } /// The category a folder below the import root files its presets under. fn category_of(parent: &str, folder: &std::path::Path) -> String { let Some(name) = folder.file_name() else { return parent.to_string(); }; let name = name.to_string_lossy().replace('/', "\u{2215}"); if parent.is_empty() { name } else { format!("{parent}/{name}") } } fn read_into(path: &std::path::Path, category: &str, report: &mut Report) { if path.is_dir() { let Ok(entries) = std::fs::read_dir(path) else { log::warn!("preset import: cannot read {}", path.display()); return; }; // Sorted, so importing the same folder twice reports the same order // and a name collision resolves the same way both times. let mut paths: Vec = entries.flatten().map(|e| e.path()).collect(); paths.sort(); for path in paths { let category = if path.is_dir() { category_of(category, &path) } else { category.to_string() }; read_into(&path, &category, report); } return; } let is_xmp = path .extension() .is_some_and(|e| e.eq_ignore_ascii_case("xmp")); if !is_xmp { return; } let Ok(text) = std::fs::read_to_string(path) else { report.failed += 1; return; }; match read_xmp(&text) { Ok(import) => { let name = import.name.unwrap_or_else(|| { path.file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default() }); report.unsupported.extend(import.skipped); let name = name.replace('/', "\u{2215}"); let name = if category.is_empty() { name } else { format!("{category}/{name}") }; report.presets.push((name, import.preset)); } Err(e) => { log::debug!("preset import: {}: {e}", path.display()); report.failed += 1; } } }