Files
DarkRoom/core/dr-preset-xmp/src/lib.rs
T
dtourolle 013596e1bd Translate Lightroom's HSL panel, and read the edit inside a DNG
The library's DNGs carry a Lightroom house look in their embedded XMP
— Blue +58, Aqua +50, Yellow and Purple +23, Highlights -40, Blacks
-20 on most — and that, not the camera profile, is why the same files
look richer in Lightroom. The importer skipped exactly that part: the
HSL panel was on its list of structures it did not translate.

Lightroom's eight HSL bands now map onto the colour mixer, hue,
saturation and luminance each one for one: Aqua to our cyan and Purple
to our violet, the nearest of our twelve bands by hue; chartreuse,
spring, azure and rose are left alone. The figures are a first
translation that lr-fit's measurement against Lightroom's output may
yet scale.

read_embedded finds the XMP packet in a photograph's bytes by its
delimiters and translates it, or answers None for a file whose XMP has
no Camera Raw settings — darktable's sidecars, a camera's own packet.
A test reads the library's _MG_9080.dng when it is present.
2026-10-03 14:22:48 -04:00

997 lines
36 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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,
},
// TRACES: FR-DEV-6
// Lightroom's HSL panel: eight bands, each ±100 for hue, saturation and
// luminance, onto the colour mixer's twelve. Lightroom's bands sit where
// ours do except two, matched to the nearest of ours by hue: Aqua (180°)
// is our cyan, Purple (270°) our violet; chartreuse, spring, azure and
// rose have no Lightroom counterpart and are left alone. One for one, as
// a first translation — the band widths differ, and `lr-fit`'s
// measurement against Lightroom's own output may yet scale these.
Mapping {
crs: "HueAdjustmentRed",
op: "colour_mixer",
param: "red_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentRed",
op: "colour_mixer",
param: "red_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentRed",
op: "colour_mixer",
param: "red_lum",
convert: Convert::Direct,
},
Mapping {
crs: "HueAdjustmentOrange",
op: "colour_mixer",
param: "orange_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentOrange",
op: "colour_mixer",
param: "orange_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentOrange",
op: "colour_mixer",
param: "orange_lum",
convert: Convert::Direct,
},
Mapping {
crs: "HueAdjustmentYellow",
op: "colour_mixer",
param: "yellow_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentYellow",
op: "colour_mixer",
param: "yellow_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentYellow",
op: "colour_mixer",
param: "yellow_lum",
convert: Convert::Direct,
},
Mapping {
crs: "HueAdjustmentGreen",
op: "colour_mixer",
param: "green_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentGreen",
op: "colour_mixer",
param: "green_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentGreen",
op: "colour_mixer",
param: "green_lum",
convert: Convert::Direct,
},
Mapping {
crs: "HueAdjustmentAqua",
op: "colour_mixer",
param: "cyan_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentAqua",
op: "colour_mixer",
param: "cyan_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentAqua",
op: "colour_mixer",
param: "cyan_lum",
convert: Convert::Direct,
},
Mapping {
crs: "HueAdjustmentBlue",
op: "colour_mixer",
param: "blue_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentBlue",
op: "colour_mixer",
param: "blue_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentBlue",
op: "colour_mixer",
param: "blue_lum",
convert: Convert::Direct,
},
Mapping {
crs: "HueAdjustmentPurple",
op: "colour_mixer",
param: "violet_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentPurple",
op: "colour_mixer",
param: "violet_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentPurple",
op: "colour_mixer",
param: "violet_lum",
convert: Convert::Direct,
},
Mapping {
crs: "HueAdjustmentMagenta",
op: "colour_mixer",
param: "magenta_hue",
convert: Convert::Direct,
},
Mapping {
crs: "SaturationAdjustmentMagenta",
op: "colour_mixer",
param: "magenta_sat",
convert: Convert::Direct,
},
Mapping {
crs: "LuminanceAdjustmentMagenta",
op: "colour_mixer",
param: "magenta_lum",
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<String>,
/// 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<String>,
}
#[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,
}
/// TRACES: FR-DEV-6
/// The Lightroom edit stored inside a photograph — the XMP packet Lightroom
/// writes into a DNG — translated as a preset is.
///
/// `None` where the file carries no packet, or one with no Camera Raw
/// settings in it (darktable's sidecars, a camera's own XMP). The packet is
/// found by its delimiters rather than by walking the TIFF structure: it is
/// plain text by specification, and the same search serves any container.
pub fn read_embedded(bytes: &[u8]) -> Option<Import> {
const OPEN: &[u8] = b"<x:xmpmeta";
const CLOSE: &[u8] = b"</x:xmpmeta>";
let start = find(bytes, OPEN)?;
let end = start + find(&bytes[start..], CLOSE)? + CLOSE.len();
let text = std::str::from_utf8(&bytes[start..end]).ok()?;
read_xmp(text).ok().filter(|i| !i.preset.is_empty())
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
/// 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<Import, ImportError> {
let mut reader = quick_xml::Reader::from_str(text);
reader.config_mut().trim_text(true);
let mut found: BTreeMap<String, String> = BTreeMap::new();
let mut name: Option<String> = None;
let mut pending_element: Option<String> = None;
// Inside `<crs:Name><rdf:Alt><rdf:li>` 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 `<?xpacket?>` 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::<f32>() 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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:PresetType="Normal"
crs:Exposure2012="+0.75"
crs:Contrast2012="+25"
crs:Highlights2012="-40"
crs:Shadows2012="+30"
crs:Whites2012="+10"
crs:Blacks2012="-15"
crs:Clarity2012="+12"
crs:Texture="+8"
crs:Vibrance="+20"
crs:Saturation="-5"
crs:Sharpness="75"
crs:Temperature="5400"
crs:Tint="+12">
<crs:Name>
<rdf:Alt>
<rdf:li xml:lang="x-default">Warm Portrait</rdf:li>
</rdf:Alt>
</crs:Name>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
/// The element form: the same settings as child elements.
const ELEMENT_FORM: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
<crs:Exposure2012>+0.75</crs:Exposure2012>
<crs:Contrast2012>+25</crs:Contrast2012>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
fn value(import: &Import, op: &str, param: &str) -> Option<f32> {
import
.preset
.params()
.get(&(op.to_string(), param.to_string()))
.copied()
}
#[test]
fn a_dngs_embedded_lightroom_edit_comes_across_with_its_hsl() {
// TRACES: FR-DEV-6
// The shape Lightroom 6 writes into a DNG, trimmed: the library's
// house look, as camera-profiles.md's D21 note records it.
let mut file = b"II*\0 binary header bytes ".to_vec();
file.extend_from_slice(
br#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ProcessVersion="6.7" crs:Exposure2012="0.00" crs:Highlights2012="-40" crs:Blacks2012="-20" crs:SaturationAdjustmentBlue="+58" crs:SaturationAdjustmentAqua="+50" crs:SaturationAdjustmentPurple="+23" crs:HueAdjustmentRed="-5" crs:LuminanceAdjustmentGreen="+7"/></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#,
);
file.extend_from_slice(b"\0 more binary");
let import = read_embedded(&file).expect("an edit");
let p = import.preset.params();
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
assert_eq!(get("colour_mixer", "violet_sat"), Some(23.0));
assert_eq!(get("colour_mixer", "red_hue"), Some(-5.0));
assert_eq!(get("colour_mixer", "green_lum"), Some(7.0));
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0));
assert_eq!(get("blacks_whites", "blacks"), Some(-20.0));
}
#[test]
fn the_libraries_dngs_carry_the_house_look() {
// TRACES: FR-DEV-6
// The library's own Lightroom 6 DNG, where it is on this machine.
let Some(home) = std::env::var_os("HOME") else {
return;
};
let path =
std::path::Path::new(&home).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skipped: no sample DNG at {}", path.display());
return;
};
let import = read_embedded(&bytes).expect("Lightroom's edit");
let p = import.preset.params();
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0));
}
#[test]
fn a_file_with_no_camera_raw_settings_has_no_edit() {
assert!(read_embedded(b"no packet at all").is_none());
let darktable = br#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/></rdf:RDF></x:xmpmeta>"#;
assert!(read_embedded(darktable).is_none());
}
#[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("<x:xmpmeta><unclosed>"),
Err(ImportError::NotXml(_)) | Err(ImportError::NoSettings)
));
}
#[test]
fn a_file_with_no_camera_raw_settings_is_refused() {
let text = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"></x:xmpmeta>"#;
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<String>,
}
/// 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<std::path::PathBuf> = 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;
}
}
}