Files
DarkRoom/core/dr-pipeline/src/preset.rs
T
dtourolle 90c0695c05 Let a preset name its film, and let a look reach only what it names
A preset could not choose a film stock. The stock is a choice of material
rather than a parameter, so `Preset` — a map of `op.param = value` — had
nowhere to hold it, and "Portra 400, printed" could not be saved, copied
or shipped as a look. Worse, the film node's own sliders *were*
parameters: a paste moved one stock's exposure and push onto whatever
stock the target was on, and left the target's tables baked from the
values it had just replaced.

A preset now carries a `FilmRef` beside its parameters. It travels under
whichever scope carries the film node, so the stock and its sliders are
never split, and by the replacement rule every other parameter follows:
applied at that scope, a preset without a film develops the target
without one. `Preset::apply` returns the `FilmRebake` it owes, as
`EditGraph::set_state` already did, because this crate cannot bake a
stock; the develop session pays it before recording the step, and the
batch paste writes the stock into each sidecar through `film_for`. The
library file spells it `film =` / `film_print =`, as a sidecar does, and
an older build keeps those lines as ones it does not understand.

`EditState` keeps the film in its own field only: the parameters it
captures leave it out, so one edit has one place to say which stock it
is on.

Second, a preset now has a reach. Replacement is right for a copy of a
whole edit — "make these match" — and wrong for a look: a stock-only
"Portra 400" applied that way would put the photograph's exposure, white
balance and noise reduction back to default. `Reach::Named` replaces only
the operations a preset names (whole operations, so a look that sets the
blacks resets the whites beside them) and the film only if it names one.
Saved edits and the clipboard keep `Reach::Whole`; the line `reach =
named` is written only for the other, so existing libraries write the
same bytes.
2026-09-26 13:44:32 -04:00

1787 lines
70 KiB
Rust

//! TRACES: FR-DEV-6
//! An edit lifted off one photograph and dropped onto another.
//!
//! # This is the same data a sidecar already stores
//!
//! A [`Preset`] is a map of `(op, param) -> value` holding only what differs
//! from default — which is precisely [`Version::params`](crate::Version). That
//! is not a coincidence to be tidied away later: copying settings between two
//! images and persisting one image's settings are the same operation seen from
//! two ends, so they share one representation and one capture routine
//! ([`Preset::capture`], which `sidecar` calls). A second, parallel notion of
//! "a bundle of parameter values" would be a second thing to keep in step with
//! the descriptors.
//!
//! It follows that this module names no operation either, with the single
//! exception of framing — for the reason below, which is a statement about
//! photographs rather than about code.
//!
//! # Why framing is its own scope
//!
//! A crop is a decision about *this* photograph's composition. Copying colour
//! from one frame to the next is what a photographer means by "make these
//! match"; copying the crop as well re-frames every one of them to a rectangle
//! chosen while looking at a different picture, and on a batch of forty that is
//! forty compositions destroyed by one action.
//!
//! So the default [`Scope::adjustments`] leaves the target's framing where it
//! is, and [`Scope::everything`] is available for the case the exclusion exists
//! to protect against being impossible otherwise — applying one aspect ratio
//! across a shoot. The choice is the caller's; neither is hardcoded here.
//!
//! # Why applying replaces rather than overlays
//!
//! Within its scope, [`Preset::apply`] resets first. A parameter absent from
//! the preset means *default*, exactly as absence means default in a sidecar
//! — so pasting a neutral edit clears the target rather than leaving the
//! target's own exposure standing underneath. Overlaying would make the result
//! depend on what the target happened to hold, and "these two images now match"
//! is the whole claim the action makes.
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use crate::descriptor::{Attribute, OpId, ParamId};
use crate::graph::EditGraph;
use crate::state::{FilmRebake, FilmRef};
/// Which parts of an edit a copy carries.
///
/// # A set of kinds, not a list of operations
///
/// Every operation declares what it is *about* — [`Attribute::Tone`],
/// [`Attribute::Colour`], [`Attribute::Detail`] and so on (ARCH §4.3a) — and
/// a scope is a set of those. That is the same vocabulary the develop panel
/// builds its tabs from, which is the point: the photographer ticking "tone
/// and colour" is naming the same groups they already navigate by, and
/// nothing here or in the interface has to name an operation to do it
/// (FR-DEV-3c).
///
/// It also dissolves a special case. Framing used to be excluded by an
/// explicit test against one operation's id; it is now excluded because
/// [`Attribute::Compose`] is not in the default set, and the argument below
/// is a statement about a kind of edit rather than about a particular node.
///
/// # Why geometry is out by default
///
/// A crop is a decision about *this* photograph's composition. Copying colour
/// from one frame to the next is what a photographer means by "make these
/// match"; copying the crop as well re-frames every one of them to a rectangle
/// chosen while looking at a different picture, and on a batch of forty that is
/// forty compositions destroyed by one action.
///
/// So [`Scope::adjustments`] leaves the target's framing where it is, and
/// [`Scope::everything`] is available for the case the exclusion exists to
/// protect against being impossible otherwise — applying one aspect ratio
/// across a shoot.
///
/// # An operation with two attributes travels with either
///
/// `tone_curve` declares both tone and colour, and a scope naming either one
/// carries it. The alternative — requiring every declared attribute to be
/// selected — would make the curve unreachable unless both were ticked, which
/// is not what "include the tone work" means to the person ticking it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
/// One bit per [`Attribute`], indexed by its position in [`Attribute::ALL`].
bits: u8,
/// Whether operations this build cannot classify travel too.
///
/// An operation from a newer build reaches us as a name in a file with no
/// descriptor behind it, so there is no attribute to match it against.
/// What to do about it is genuinely different for the two kinds of scope,
/// which is why this is a field rather than a rule:
///
/// * [`Scope::adjustments`] and [`Scope::everything`] are statements about
/// the *whole edit* — "all of it", or "all of it but the crop". An
/// operation nobody here recognises is still part of the whole edit, so
/// it travels, and a preset made on a newer device still applies on an
/// older one. That matters in an application that syncs between devices
/// which may not be on the same version (FR-NC-8).
/// * A hand-picked set is a statement about *kinds*: "the tone work, not
/// the colour". An operation whose kind is unknown is not one of the
/// kinds that were picked, so it stays behind rather than being smuggled
/// in under a selection that never named it.
carries_unclassified: bool,
}
impl Default for Scope {
fn default() -> Self {
Self::adjustments()
}
}
impl Scope {
/// Colour and tone and everything else the photograph is made of, but not
/// its shape. The default.
pub fn adjustments() -> Self {
Self {
bits: Self::all_bits() & !Self::bit(Attribute::Compose),
carries_unclassified: true,
}
}
/// The whole edit, framing included.
pub fn everything() -> Self {
Self {
bits: Self::all_bits(),
carries_unclassified: true,
}
}
/// Exactly these kinds and nothing else.
///
/// See `carries_unclassified`: a hand-picked set deliberately leaves
/// behind operations this build cannot classify.
pub fn of(attributes: impl IntoIterator<Item = Attribute>) -> Self {
let bits = attributes.into_iter().fold(0, |acc, a| acc | Self::bit(a));
Self {
bits,
carries_unclassified: false,
}
}
/// Whether this scope reaches the named operation.
///
/// Takes a `&str` rather than an [`OpId`] because the caller may be
/// holding a name read from a file, which has no `'static` lifetime to
/// offer — the sidecar path amends a parameter map without ever building
/// a graph.
pub fn covers(self, op: &str) -> bool {
match attributes_of(op) {
Some(attributes) => attributes.iter().any(|a| self.has(*a)),
None => self.carries_unclassified,
}
}
/// Whether this scope names a kind.
pub fn has(self, attribute: Attribute) -> bool {
self.bits & Self::bit(attribute) != 0
}
/// The kinds this scope names, in declaration order.
pub fn attributes(self) -> impl Iterator<Item = Attribute> {
Attribute::ALL.into_iter().filter(move |a| self.has(*a))
}
/// Whether this scope names nothing at all.
///
/// A scope that reaches nothing is a legitimate thing to hold — it is what
/// an interface has while the photographer is still un-ticking boxes — and
/// applying it is a no-op rather than an error.
pub fn is_empty(self) -> bool {
self.bits == 0
}
/// Whether this scope carries the film — the stock as well as its sliders.
///
/// Asked of the film node's own classification rather than decided here,
/// so the stock travels under exactly the scopes its exposure slider
/// does. Splitting the two would put one stock's exposure on another.
pub fn carries_film(self) -> bool {
self.covers(crate::ops::film_sim::ID.0)
}
fn bit(attribute: Attribute) -> u8 {
1 << Attribute::ALL
.iter()
.position(|a| *a == attribute)
.expect("every attribute is in ALL") as u8
}
fn all_bits() -> u8 {
Attribute::ALL.iter().fold(0, |acc, a| acc | Self::bit(*a))
}
}
/// The attributes an operation declares, without building a graph.
///
/// The batch path needs this: applying to forty images amends forty parameter
/// maps and never instantiates the chain (see [`Preset::amend`]), so asking a
/// graph what an operation is about would mean building one for the sole
/// purpose of reading a constant off it.
///
/// Built once from the default chain, which is where the declarations already
/// live — a second table listing operations by hand would be a second place to
/// forget an entry, and this module names no operation (see the module note).
fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
static TABLE: std::sync::LazyLock<BTreeMap<String, Vec<Attribute>>> =
std::sync::LazyLock::new(|| {
let mut chain = EditGraph::default_chain();
// With a profile in place, because one capability only exists once
// a photograph has brought a lens profile with it — the switch
// that accepts or declines it. Table entries are what
// [`Scope::covers`] classifies by, and an id missing from here
// falls through to `carries_unclassified`: the switch would then
// travel with a scope that named only tone, which is precisely
// what a scope is for refusing.
chain.set_lens_profile(Some(crate::lens::LensProfile::default()));
chain
.capabilities()
.into_iter()
.map(|cap| (cap.id.0.to_string(), cap.attributes))
.collect()
});
TABLE.get(op).map(Vec::as_slice)
}
/// How much of the target an applied preset replaces. See the module note.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Reach {
/// Everything in scope: absence means default. A copy, a saved edit.
#[default]
Whole,
/// Only the operations the preset names, and the film if it names one.
/// Everything else in the target is left as it was. A look.
Named,
}
/// A set of non-default parameter values, ready to apply elsewhere.
///
/// Ordered, so two captures of the same edit compare equal and a caller can
/// tell whether the clipboard actually changed.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Preset {
params: BTreeMap<(String, String), f32>,
reach: Reach,
/// TRACES: FR-DEV-3f
/// The stock this edit develops on, by id. See the module note.
film: Option<FilmRef>,
}
impl Preset {
/// Every non-default parameter in `graph`.
///
/// Walks [`EditGraph::capabilities`] — the same list the panel builds
/// controls from and the sidecar persists — so an operation is copyable by
/// virtue of being in the chain, with nothing to register (FR-DEV-3c).
///
/// Captured at full [`Scope::everything`]: filtering happens when the
/// preset is *applied*, not when it is taken. Otherwise the clipboard would
/// have to be re-copied to change one's mind about framing, and a preset
/// that had already discarded the crop could never grow it back.
pub fn capture(graph: &EditGraph) -> Self {
Self {
film: graph.film().map(|f| FilmRef {
stock: f.stock.clone(),
print: f.print.clone(),
}),
..Self::capture_params(graph)
}
}
/// The parameters alone, without the film.
///
/// For [`EditGraph::state`], which carries the film in a field of its own
/// ([`crate::EditState::film`]). Capturing it twice would give one edit
/// two places to disagree about its stock.
pub(crate) fn capture_params(graph: &EditGraph) -> Self {
let mut params = BTreeMap::new();
for cap in graph.capabilities() {
for p in &cap.params {
if p.is_modified() {
params.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value);
}
}
}
Self::from_params(params)
}
/// Build from an already-captured parameter map — a sidecar's, typically.
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
Self {
params,
reach: Reach::Whole,
film: None,
}
}
/// The same preset, reaching as far as `reach` says.
pub fn with_reach(self, reach: Reach) -> Self {
Self { reach, ..self }
}
/// How much of the target this preset replaces.
pub fn reach(&self) -> Reach {
self.reach
}
/// Whether applying this preset replaces `op` — before any scope is asked.
fn reaches(&self, op: &str) -> bool {
match self.reach {
Reach::Whole => true,
Reach::Named => {
self.params.keys().any(|(o, _)| o == op)
|| (self.film.is_some() && op == crate::ops::film_sim::ID.0)
}
}
}
/// The same preset, developed on `film`.
pub fn with_film(self, film: Option<FilmRef>) -> Self {
Self { film, ..self }
}
/// The stock this preset develops on, if it names one.
pub fn film(&self) -> Option<&FilmRef> {
self.film.as_ref()
}
/// What applying at `scope` does to the target's film.
///
/// `None` when the scope leaves the film alone; `Some(None)` when it
/// develops the target without one. The same two levels the sidecar
/// writer takes, which is who asks: the batch path amends files rather
/// than graphs, and has to know whether the film line is being written at
/// all before it knows what to write.
pub fn film_for(&self, scope: Scope) -> Option<Option<&FilmRef>> {
(scope.carries_film() && self.reaches(crate::ops::film_sim::ID.0))
.then_some(self.film.as_ref())
}
/// The parameters, for a caller that stores them.
pub fn params(&self) -> &BTreeMap<(String, String), f32> {
&self.params
}
/// Consume into the parameter map.
pub fn into_params(self) -> BTreeMap<(String, String), f32> {
self.params
}
/// Whether this preset carries anything at all.
///
/// An empty preset is a *neutral* edit rather than a missing one, and
/// applying it is meaningful: it returns the target to default. What this
/// answers is whether there is a clipboard to offer, which is why the UI
/// asks it before enabling a paste.
pub fn is_empty(&self) -> bool {
self.params.is_empty() && self.film.is_none()
}
/// How many parameters were captured.
pub fn len(&self) -> usize {
self.params.len()
}
/// Whether this preset carries any framing — a crop, a rotation, a flip or
/// a straightening angle.
///
/// What the interface asks to decide whether offering "include crop and
/// rotation" would change anything for *this* clipboard. Offering it on a
/// copy that has no framing in it promises an effect that cannot happen.
pub fn touches_framing(&self) -> bool {
self.params
.keys()
.any(|(op, _)| attributes_of(op).is_some_and(|a| a.contains(&Attribute::Compose)))
}
/// How many operations this preset touches, in scope.
///
/// For the interface's "3 adjustments" readout. Counted over operations
/// rather than parameters because thirty-six mixer sliders is a number
/// about the mixer's shape, not about how much was copied.
pub fn op_count(&self, scope: Scope) -> usize {
// A stock with every film slider at default is still the film node
// at work, and a preset holding nothing else is not "Neutral".
let film = self.film.is_some().then_some(crate::ops::film_sim::ID.0);
let mut ops: Vec<&str> = self
.params
.keys()
.map(|(op, _)| op.as_str())
.chain(film)
.filter(|op| scope.covers(op))
.collect();
ops.sort_unstable();
ops.dedup();
ops.len()
}
/// Apply to a graph, replacing whatever it held within `scope`.
///
/// Parameters this build does not recognise are skipped with a warning by
/// the same route a sidecar's are — a preset may have been captured by a
/// newer build, and an unknown name must cost its own line rather than the
/// paste.
///
/// The **view is preserved**. Zoom and pan say where the user is looking,
/// not what the photograph is; resetting the framing would throw them back
/// to a fitted view mid-comparison, which reads as the paste having
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
/// lives here so every caller inherits it rather than each remembering.
///
/// The **film** is replaced when the scope carries it, and handed back as
/// a [`FilmRebake`] because this crate cannot bake a stock — the same debt
/// [`EditGraph::set_state`] returns, for the same reason. It is cleared
/// even when the preset names the stock already on the graph: the tables
/// were baked from the film sliders this call has just replaced.
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) -> FilmRebake {
let view = graph.framing().view();
// Clear the scope first, so absence means default (see the module
// note). Collected before writing because `capabilities` borrows the
// graph and `set_param` needs it mutably.
let clears: Vec<(OpId, ParamId, f32)> = graph
.capabilities()
.iter()
.filter(|cap| scope.covers(cap.id.0) && self.reaches(cap.id.0))
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
.collect();
for (op, param, default) in clears {
graph.set_param(op, param, default);
}
for ((op, param), value) in &self.params {
if !scope.covers(op) {
continue;
}
let Some((op, param)) = resolve(graph, op, param) else {
log::warn!("preset: unknown parameter {op}.{param}; ignoring");
continue;
};
graph.set_param(op, param, *value);
}
graph.framing_mut().set_view(view);
match self.film_for(scope) {
None => FilmRebake::NotNeeded,
Some(film) => {
graph.set_film(None);
match film {
None => FilmRebake::NotNeeded,
Some(film) => FilmRebake::Wanted(film.clone()),
}
}
}
}
/// Apply to a parameter map — the sidecar of an image that is not open.
///
/// The batch path. Applying to forty images by loading forty edit graphs
/// would mean instantiating the whole chain forty times to move some
/// numbers between two maps; the graph adds nothing here because there is
/// no rendering to do and clamping happens when the file is next read into
/// one ([`EditGraph::set_param`] clamps, and `Version::apply` goes through
/// it).
///
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
/// the preset's arrive, and out-of-scope keys — the target's own crop, on
/// the default scope — are left exactly as they were.
///
/// A parameter map has no film in it, so the stock is not written here:
/// the caller asks [`Self::film_for`] and writes it beside the map.
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
target.retain(|(op, _), _| !(scope.covers(op) && self.reaches(op)));
for ((op, param), value) in &self.params {
if scope.covers(op) {
target.insert((op.clone(), param.clone()), *value);
}
}
}
}
/// Find the `'static` ids matching these names, or `None` if this build has no
/// such parameter.
///
/// Looking them up in the descriptors rather than leaking the caller's strings
/// is what bounds memory: a name read from a file or carried on a clipboard
/// never becomes a `'static`.
pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> {
let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?;
let p = cap.params.iter().find(|p| p.id.0 == param)?;
Some((cap.id, p.id))
}
// ---------------------------------------------------------------------------
// Named presets
// ---------------------------------------------------------------------------
/// TRACES: FR-DEV-6
/// Format version of a preset library file.
///
/// Present where `settings.json` has no version field, and the difference is
/// not an inconsistency. Settings are a flat bag of `#[serde(default)]`
/// fields, so an older file is *missing* keys rather than wrong about them and
/// additive change needs no version. This file has structure — blocks, and a
/// name carried in a block header — and a change to what a block *means* is
/// not something a reader can detect by noticing an absent key.
pub const LIBRARY_FORMAT_VERSION: u32 = 1;
/// The file extension for a DarkRoom preset library.
pub const LIBRARY_EXTENSION: &str = "drpl";
/// Why a name was refused.
///
/// A closed set rather than a string, so the interface can say something
/// specific about each and the message is not written here — this crate
/// depends on nothing and has no business holding user-facing prose.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameError {
/// Empty, or nothing but whitespace.
Empty,
/// Contains a character the block header cannot carry: `[`, `]`, or a
/// line break.
///
/// A round-trip constraint rather than a matter of taste. The header is
/// `[preset <name>]`, so a `]` inside the name would make the file parse
/// back as a *different* library, and a newline would make it parse back
/// as two.
Unrepresentable,
}
/// TRACES: FR-DEV-6
/// A set of named presets, as stored.
///
/// # Why one file rather than one file per preset
///
/// A preset per file makes the name a *path*, and every name then has to
/// survive a filesystem: a `/` becomes a directory, a name that differs only
/// in case collides on one platform and not another, and renaming becomes two
/// operations that can half-fail. Here the name is a key in a document, so
/// renaming is a map operation, deleting cannot leave an orphan, and the whole
/// library is written atomically by the same tmp-and-rename the settings store
/// uses.
///
/// The cost is that the file is rewritten whole on every change. A preset is a
/// few dozen floats and a photographer has tens of them, not thousands, so the
/// file is kilobytes; the trade would look different at a scale this is not.
///
/// # Why the sidecar's shape rather than JSON
///
/// A preset *is* the non-default half of a version (see the module note), so
/// the lines here are the lines a sidecar carries, keyed the same way. That
/// makes the two files diffable against each other and lets someone debugging
/// an edit paste a block from one into the other. It also keeps this crate
/// dependency-free, which is the property that lets it be tested without a
/// device (ARCH §6.5a).
///
/// # Ordering
///
/// By name, so the same library always writes the same bytes and a caller may
/// compare content to decide whether a write is needed — the same
/// determinism [`Sidecar::to_text`](crate::Sidecar::to_text) offers, for the
/// same reason.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PresetLibrary {
presets: BTreeMap<String, Preset>,
/// Lines inside a `[preset]` block that were not `op.param = float`.
///
/// Keyed by preset name and written back verbatim, so a build that
/// predates whatever wrote them round-trips the file without discarding
/// it. Unknown *parameters* need no such machinery: [`Preset`] holds
/// whatever keys it was given and resolves them against the descriptors
/// only at apply time, so a parameter this build has never heard of
/// survives simply by being stored.
unknown: BTreeMap<String, Vec<String>>,
}
impl PresetLibrary {
/// Whether a name is storable, and why not if it is not.
///
/// Leading and trailing whitespace is trimmed rather than refused — it is
/// almost always a stray keystroke, and refusing it would mean a dialogue
/// about a space.
pub fn check_name(name: &str) -> Result<String, NameError> {
let name = name.trim();
if name.is_empty() {
return Err(NameError::Empty);
}
if name.contains([']', '[', '\n', '\r']) {
return Err(NameError::Unrepresentable);
}
Ok(name.to_string())
}
/// Store `preset` under `name`, replacing any preset already there.
///
/// Returns whether something was replaced.
///
/// Replacing rather than refusing, with [`Self::contains`] beside it for a
/// caller that wants to ask first: whether overwriting needs a
/// confirmation is a question about the interface, and answering it here
/// would force every caller into the same answer.
///
/// An *empty* preset is stored like any other. A neutral edit is a real
/// thing to save — applying it returns an image to default, which is the
/// fastest "undo everything on these forty frames" there is — and the
/// module note above is the same argument made about the clipboard.
pub fn insert(&mut self, name: &str, preset: Preset) -> Result<bool, NameError> {
let name = Self::check_name(name)?;
let replaced = self.presets.insert(name, preset).is_some();
Ok(replaced)
}
/// The preset stored under `name`.
pub fn get(&self, name: &str) -> Option<&Preset> {
self.presets.get(name)
}
/// Whether a preset is stored under `name`.
pub fn contains(&self, name: &str) -> bool {
self.presets.contains_key(name)
}
/// Remove the preset stored under `name`, reporting whether there was one.
pub fn remove(&mut self, name: &str) -> bool {
self.unknown.remove(name);
self.presets.remove(name).is_some()
}
/// Rename `from` to `to`.
///
/// `Ok(false)` means there was nothing called `from` — not an error, since
/// the caller may be acting on a list another window has already changed.
/// Renaming onto an existing name replaces it, for the same reason
/// [`Self::insert`] does.
pub fn rename(&mut self, from: &str, to: &str) -> Result<bool, NameError> {
let to = Self::check_name(to)?;
let Some(preset) = self.presets.remove(from) else {
return Ok(false);
};
if let Some(unknown) = self.unknown.remove(from) {
self.unknown.insert(to.clone(), unknown);
}
self.presets.insert(to, preset);
Ok(true)
}
/// Every stored name, in the order they are written.
pub fn names(&self) -> impl Iterator<Item = &str> {
self.presets.keys().map(String::as_str)
}
/// Every stored preset with its name, in the order they are written.
pub fn iter(&self) -> impl Iterator<Item = (&str, &Preset)> {
self.presets.iter().map(|(n, p)| (n.as_str(), p))
}
/// How many presets are stored.
pub fn len(&self) -> usize {
self.presets.len()
}
/// Whether nothing is stored.
pub fn is_empty(&self) -> bool {
self.presets.is_empty()
}
/// How many lines were kept without being understood.
///
/// For a file this build wrote itself, or shipped, that should be none: a
/// misspelt key is preserved faithfully and does nothing.
pub fn unread_lines(&self) -> usize {
self.unknown.values().map(Vec::len).sum()
}
/// Serialise to the on-disk form.
///
/// Deterministic, like the sidecar's: the same library always produces the
/// same bytes.
pub fn to_text(&self) -> String {
let mut out = format!("drpl {LIBRARY_FORMAT_VERSION}\n");
for (name, preset) in &self.presets {
let _ = write!(out, "\n[preset {name}]\n");
for ((op, param), value) in preset.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
}
// TRACES: FR-DEV-3f
// Spelled as the sidecar spells them, so a block can still be
// pasted from one file into the other. A build that predates
// these lines reads them as lines it does not understand and
// writes them back untouched, which is the promise below.
// Only when it differs from the default, so every library written
// before looks existed still writes the same bytes.
if preset.reach() == Reach::Named {
let _ = writeln!(out, "reach = named");
}
if let Some(film) = preset.film() {
let _ = writeln!(out, "film = {}", film.stock);
if let Some(print) = &film.print {
let _ = writeln!(out, "film_print = {print}");
}
}
for line in self.unknown.get(name).into_iter().flatten() {
let _ = writeln!(out, "{line}");
}
}
out
}
/// Parse the on-disk form.
///
/// Tolerant on the same terms as the sidecar's parser, and for a weaker
/// version of the same reason: a preset library is not the authoritative
/// store an edit lives in, but it is still work the user did by hand, and
/// one bad line must cost that line rather than the collection. The only
/// hard failures are a file that is not a preset library at all and one
/// written by a newer build, where continuing would mean guessing.
pub fn parse(text: &str) -> Result<Self, LibraryParseError> {
let mut lines = text.lines();
let header = lines.next().unwrap_or_default().trim();
let Some(version) = header.strip_prefix("drpl ") else {
return Err(LibraryParseError::NotALibrary);
};
match version.trim().parse::<u32>() {
Ok(v) if v <= LIBRARY_FORMAT_VERSION => {}
Ok(v) => return Err(LibraryParseError::UnsupportedVersion(v)),
Err(_) => return Err(LibraryParseError::NotALibrary),
}
let mut library = Self::default();
let mut current: Option<String> = None;
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
let mut film: Option<FilmRef> = None;
let mut reach = Reach::Whole;
for line in lines {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(head) = line
.strip_prefix("[preset ")
.and_then(|l| l.strip_suffix(']'))
{
if let Some(name) = current.take() {
library.presets.insert(
name,
Preset::from_params(std::mem::take(&mut params))
.with_film(film.take())
.with_reach(reach),
);
}
params.clear();
film = None;
reach = Reach::Whole;
// A name the writer should never have produced is dropped
// rather than taken: accepting it would mean writing a file
// back out that no longer parses as this one.
match Self::check_name(head) {
Ok(name) => current = Some(name),
Err(_) => {
log::warn!("preset library: unusable preset name {head:?}; skipping");
current = None;
}
}
continue;
}
let Some(name) = current.clone() else {
log::warn!("preset library: line outside any preset: {line}");
continue;
};
match line.split_once('=') {
// TRACES: FR-DEV-3f
// Not checked against the installed stocks: this crate does
// not link them, and a preset naming a stock this device
// lacks must survive being stored here. Whoever bakes it
// reports the miss — the sidecar's rule, for its reason.
// `get_or_insert_with` because a hand-edited block may name
// the paper first.
Some((key, value)) if key.trim() == "reach" && value.trim() == "named" => {
reach = Reach::Named;
}
Some((key, value)) if key.trim() == "film" && !value.trim().is_empty() => {
film.get_or_insert_with(FilmRef::default).stock = value.trim().to_string();
}
Some((key, value)) if key.trim() == "film_print" && !value.trim().is_empty() => {
film.get_or_insert_with(FilmRef::default).print =
Some(value.trim().to_string());
}
Some((key, value)) => {
let key = key.trim();
let value = value.trim();
match (key.split_once('.'), value.parse::<f32>()) {
(Some((op, param)), Ok(v)) if !op.is_empty() && !param.is_empty() => {
params.insert((op.to_string(), param.to_string()), v);
}
_ => library
.unknown
.entry(name)
.or_default()
.push(line.to_string()),
}
}
None => library
.unknown
.entry(name)
.or_default()
.push(line.to_string()),
}
}
if let Some(name) = current {
library.presets.insert(
name,
Preset::from_params(params)
.with_film(film)
.with_reach(reach),
);
}
// A paper with no film named beside it is a print of nothing. Dropped
// rather than kept, since baking it would have no stock to start from.
for preset in library.presets.values_mut() {
if preset.film.as_ref().is_some_and(|f| f.stock.is_empty()) {
log::warn!("preset library: a paper without a film; ignoring it");
preset.film = None;
}
}
// A block whose every line was unreadable still produced a preset, and
// an unknown block belonging to no preset would be written back into
// whichever one happened to sort first. Drop the orphans.
library
.unknown
.retain(|k, _| library.presets.contains_key(k));
Ok(library)
}
}
/// Format a value the way the sidecar does — no trailing `.0`, no exponent —
/// so the two files stay comparable line for line.
fn format_value(v: f32) -> String {
let mut s = format!("{v:.6}");
if s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
if s == "-0" {
s = "0".to_string();
}
s
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LibraryParseError {
/// The header line was missing or not a `drpl` header.
NotALibrary,
/// Written by a newer build, in a format this one cannot read.
UnsupportedVersion(u32),
}
impl fmt::Display for LibraryParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotALibrary => f.write_str("not a DarkRoom preset library"),
Self::UnsupportedVersion(v) => {
write!(
f,
"preset library format version {v} is newer than this build"
)
}
}
}
}
impl std::error::Error for LibraryParseError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::framing;
use crate::ops::{exposure, saturation, white_balance};
use crate::CropRect;
/// A graph with colour *and* framing moved off default, which is what makes
/// the scope distinction observable.
fn edited() -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
g.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
g.set_param(framing::ID, framing::ANGLE, -2.0);
g.set_param(framing::ID, framing::KEYSTONE_V, 40.0);
g
}
#[test]
fn a_copy_carries_only_what_was_changed() {
// The property the whole format rests on, restated for the clipboard:
// a neutral operation contributes nothing, so pasting cannot carry a
// value the source never set.
let preset = Preset::capture(&edited());
assert!(preset
.params()
.contains_key(&("exposure".into(), "exposure".into())));
assert!(
!preset.params().keys().any(|(op, _)| op == saturation::ID.0),
"an untouched operation must not be copied"
);
}
#[test]
fn a_neutral_graph_copies_nothing() {
assert!(Preset::capture(&EditGraph::default_chain()).is_empty());
}
/// TRACES: FR-DEV-3 | FR-DEV-3c
/// The lens profile switch is classified, so a scope can refuse it.
///
/// It is the one capability that exists only once a photograph has brought
/// a lens profile with it, and the attribute table is built from a chain
/// — so it was missing from that table before the chain used to build it
/// was given a profile. An unclassified id falls through to
/// `carries_unclassified`, and a copy of somebody's tone edit would have
/// arrived carrying "do not correct this lens".
#[test]
fn declining_a_lens_profile_is_an_optical_edit() {
use crate::lens::profile_switch;
let mut g = EditGraph::default_chain();
g.set_lens_profile(Some(crate::lens::LensProfile::default()));
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
let preset = Preset::capture(&g);
assert!(
preset
.params()
.contains_key(&(profile_switch::ID.0.into(), profile_switch::APPLY.0.into())),
"declining the profile is an edit and must be copyable"
);
assert!(Scope::of([Attribute::Optics]).covers(profile_switch::ID.0));
assert!(!Scope::of([Attribute::Tone]).covers(profile_switch::ID.0));
}
#[test]
fn a_copy_captures_framing_even_though_the_default_scope_drops_it() {
// Capture is deliberately unfiltered: the decision about framing is
// made at paste time, so a user who ticks "include crop" after copying
// must not have to copy again.
let preset = Preset::capture(&edited());
assert!(preset.touches_framing());
}
#[test]
fn pasting_adjustments_leaves_the_targets_composition_alone() {
// The case the default scope exists for: two photographs framed
// differently, made to match in colour without either being re-cropped.
let source = edited();
let preset = Preset::capture(&source);
let mut target = EditGraph::default_chain();
let target_crop = CropRect {
x: 0.0,
y: 0.25,
width: 1.0,
height: 0.5,
};
target.set_crop(target_crop);
preset
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
Some(0.75),
"the colour must arrive"
);
assert!(
(target.crop().width - target_crop.width).abs() < 1e-5
&& (target.crop().y - target_crop.y).abs() < 1e-5,
"the target's own crop must survive: {:?}",
target.crop()
);
assert_eq!(
target.param(framing::ID, framing::ANGLE),
Some(0.0),
"the source's straightening must not travel on this scope"
);
// TRACES: FR-DEV-20
// Perspective is composition on the same terms as the crop: it is
// withheld by the default scope, not pasted over the target's frame.
assert_eq!(
target.param(framing::ID, framing::KEYSTONE_V),
Some(0.0),
"the source's keystone must not travel on this scope"
);
}
#[test]
fn pasting_everything_carries_the_composition_too() {
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
preset
.apply(&mut target, Scope::everything())
.expect_no_film();
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
assert!(
(target.crop().width - 0.5).abs() < 1e-5,
"{:?}",
target.crop()
);
}
#[test]
fn pasting_replaces_rather_than_overlaying() {
// Pasting a neutral copy must *clear* the target. Were this an
// overlay, "make these match" would leave whatever the target already
// had underneath, and the two images would not in fact match.
let neutral = Preset::capture(&EditGraph::default_chain());
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
neutral
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!(
target.param(saturation::ID, saturation::SATURATION),
Some(0.0)
);
}
#[test]
fn a_paste_that_excludes_framing_does_not_clear_the_targets_framing() {
// The other half of the replacement rule: "replace" is scoped. A
// neutral paste must not straighten the target back to zero, or
// excluding framing would still destroy it — just to a different value.
let neutral = Preset::capture(&EditGraph::default_chain());
let mut target = EditGraph::default_chain();
target.set_param(framing::ID, framing::ANGLE, 3.5);
neutral
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
}
#[test]
fn pasting_leaves_the_view_where_the_user_was_looking() {
// Zoom is navigation, not an edit. A paste that reset it would throw
// the user out of a 4x inspection they were making the comparison at.
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
target.framing_mut().set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.25,
height: 0.25,
});
preset
.apply(&mut target, Scope::everything())
.expect_no_film();
assert!(
target.framing().is_zoomed(),
"the paste threw away the viewport: {:?}",
target.framing().view()
);
}
#[test]
fn a_paste_does_not_disturb_how_the_file_stored_its_pixels() {
// Orientation is a fact about the target's own file. A preset copied
// from a landscape frame must not lay that frame's sensor scan over a
// portrait one — the image would open on its side.
let preset = Preset::capture(&edited());
let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6));
preset
.apply(&mut sideways, Scope::everything())
.expect_no_film();
assert_eq!(
sideways.framing().baseline(),
dr_types::Orientation::from_exif(6)
);
}
#[test]
fn an_unknown_parameter_costs_its_own_line_and_not_the_paste() {
// A clipboard captured by a newer build. The rest must still apply, or
// one unfamiliar operation would silently discard the whole copy.
let mut params = BTreeMap::new();
params.insert(("time_machine".to_string(), "year".to_string()), 1994.0);
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
let mut target = EditGraph::default_chain();
Preset::from_params(params)
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
}
#[test]
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
let mut params = BTreeMap::new();
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
let mut target = EditGraph::default_chain();
Preset::from_params(params)
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
// --- the batch path, which has no graph ---------------------------------
#[test]
fn amending_a_map_replaces_the_scope_and_spares_the_rest() {
let preset = Preset::capture(&edited());
// A target that has its own crop and its own, different, exposure.
let mut target = BTreeMap::new();
target.insert(("exposure".to_string(), "exposure".to_string()), -1.0);
target.insert(("saturation".to_string(), "saturation".to_string()), 40.0);
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
preset.amend(&mut target, Scope::adjustments());
assert_eq!(
target.get(&("exposure".to_string(), "exposure".to_string())),
Some(&0.75),
"the copied value must land"
);
assert!(
!target.contains_key(&("saturation".to_string(), "saturation".to_string())),
"an in-scope key the preset does not set must be cleared, not kept"
);
assert_eq!(
target.get(&("framing".to_string(), "crop_w".to_string())),
Some(&0.3),
"the target's own crop must survive an adjustments-only paste"
);
}
#[test]
fn amending_at_full_scope_replaces_the_targets_crop_too() {
let preset = Preset::capture(&edited());
let mut target = BTreeMap::new();
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
preset.amend(&mut target, Scope::everything());
assert_eq!(
target.get(&("framing".to_string(), "crop_w".to_string())),
Some(&0.5)
);
}
#[test]
fn the_map_path_and_the_graph_path_agree() {
// Two routes to the same result — one through a graph, one through a
// bare map — and a batch apply must not produce a different edit from
// pasting onto the image with it open. Asserted by round-tripping the
// amended map back through a graph and comparing every parameter.
let preset = Preset::capture(&edited());
for scope in [Scope::adjustments(), Scope::everything()] {
let mut target_graph = EditGraph::default_chain();
target_graph.set_param(saturation::ID, saturation::SATURATION, 20.0);
target_graph.set_param(framing::ID, framing::ANGLE, 4.0);
let mut target_map = Preset::capture(&target_graph).into_params();
preset.apply(&mut target_graph, scope).expect_no_film();
preset.amend(&mut target_map, scope);
let mut rebuilt = EditGraph::default_chain();
Preset::from_params(target_map)
.apply(&mut rebuilt, Scope::everything())
.expect_no_film();
for cap in target_graph.capabilities() {
for p in &cap.params {
assert_eq!(
rebuilt.param(cap.id, p.id),
Some(p.value),
"{scope:?}: {}.{} differs between the graph and map paths",
cap.id,
p.id
);
}
}
}
}
#[test]
fn a_copy_round_trips_through_a_paste() {
// The end-to-end claim the feature makes: after pasting, the target
// holds the source's edit. Checked over the whole chain rather than a
// sample, so an operation that needed special handling would fail here.
let source = edited();
let preset = Preset::capture(&source);
let mut target = EditGraph::default_chain();
preset
.apply(&mut target, Scope::everything())
.expect_no_film();
for cap in source.capabilities() {
for p in &cap.params {
assert_eq!(
target.param(cap.id, p.id),
Some(p.value),
"{}.{} did not survive the copy",
cap.id,
p.id
);
}
}
}
#[test]
fn operations_are_counted_in_scope() {
let preset = Preset::capture(&edited());
// exposure, white_balance and framing were touched.
assert_eq!(preset.op_count(Scope::everything()), 3);
assert_eq!(preset.op_count(Scope::adjustments()), 2);
}
#[test]
fn scope_names_framing_and_nothing_else() {
// The one operation this module knows by name. If a second ever
// appears here, it should be because someone decided it is about the
// picture's shape rather than because it was convenient.
let g = EditGraph::default_chain();
let excluded: Vec<&str> = g
.capabilities()
.iter()
.map(|c| c.id.0)
.filter(|id| !Scope::adjustments().covers(id))
.collect();
assert_eq!(excluded, vec![framing::ID.0]);
}
// --- the film ------------------------------------------------------------
/// A graph developing on `stock`. The tables are invented; what is under
/// test is whether the *choice* travels.
fn on_film(stock: &str) -> EditGraph {
let mut g = edited();
g.set_film(Some(crate::graph::Film {
stock: stock.to_string(),
print: Some("kodak_portra_endura".to_string()),
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}));
g
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_copy_carries_the_stock_it_was_developed_on() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let film = preset.film().expect("the stock was captured");
assert_eq!(film.stock, "kodak_portra_400");
assert_eq!(film.print.as_deref(), Some("kodak_portra_endura"));
assert!(Preset::capture(&edited()).film().is_none());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// The graph's own state keeps the film in one place only.
#[test]
fn an_edit_state_does_not_carry_the_film_twice() {
let state = on_film("kodak_portra_400").state();
assert!(state.film.is_some());
assert_eq!(state.params.film(), None);
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// Applying a preset that names a stock asks for it to be baked, and
/// clears the tables it found — they came from the sliders it replaced.
#[test]
fn applying_a_film_preset_asks_for_its_stock() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let mut target = on_film("ilford_hp5");
let rebake = preset.apply(&mut target, Scope::adjustments());
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("kodak_portra_400")
);
assert!(target.film().is_none(), "the old tables were left standing");
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// Replacement, as for every parameter: a preset with no stock, at a
/// scope that carries the film, develops the target without one.
#[test]
fn a_preset_without_a_film_clears_the_targets() {
let mut target = on_film("ilford_hp5");
Preset::capture(&edited())
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert!(target.film().is_none());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// A scope that leaves the film node behind leaves the stock behind too,
/// so an exposure never lands on a stock it was not set for.
#[test]
fn a_scope_without_the_film_leaves_the_stock_alone() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let mut target = on_film("ilford_hp5");
let tone = Scope::of([Attribute::Tone]);
assert!(!tone.carries_film());
preset.apply(&mut target, tone).expect_no_film();
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
assert_eq!(preset.film_for(tone), None);
assert!(preset.film_for(Scope::adjustments()).is_some());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_stock_alone_is_not_a_neutral_preset() {
let preset = Preset::default().with_film(Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}));
assert!(!preset.is_empty());
assert_eq!(preset.op_count(Scope::adjustments()), 1);
assert_eq!(preset.op_count(Scope::of([Attribute::Tone])), 0);
}
// --- reach ---------------------------------------------------------------
/// A look: a stock and a contrast, and nothing else.
fn look() -> Preset {
let mut params = BTreeMap::new();
params.insert(("contrast".to_string(), "contrast".to_string()), 20.0);
Preset::from_params(params)
.with_film(Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}))
.with_reach(Reach::Named)
}
/// TRACES: FR-DEV-6
/// The reason `Reach` exists: a look applied over a corrected photograph
/// keeps the correction.
#[test]
fn a_look_leaves_what_it_does_not_name_alone() {
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
target.set_param(saturation::ID, saturation::SATURATION, -40.0);
let rebake = look().apply(&mut target, Scope::adjustments());
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
assert_eq!(
target.param(saturation::ID, saturation::SATURATION),
Some(-40.0)
);
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("kodak_portra_400")
);
}
/// TRACES: FR-DEV-6
/// A look without a film leaves the target's film alone, where a whole
/// edit without one would clear it.
#[test]
fn a_look_without_a_film_keeps_the_targets() {
let mut target = on_film("ilford_hp5");
let only_contrast = look().with_film(None);
assert_eq!(only_contrast.film_for(Scope::adjustments()), None);
only_contrast
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
}
/// TRACES: FR-DEV-6
/// The batch path agrees: a look amends only the operations it names.
#[test]
fn a_look_amends_only_what_it_names() {
let mut target = BTreeMap::new();
target.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
target.insert(("contrast".to_string(), "contrast".to_string()), -50.0);
look().amend(&mut target, Scope::adjustments());
assert_eq!(
target.get(&("exposure".to_string(), "exposure".to_string())),
Some(&1.25)
);
assert_eq!(
target.get(&("contrast".to_string(), "contrast".to_string())),
Some(&20.0)
);
}
/// TRACES: FR-DEV-6
#[test]
fn a_look_survives_the_library_round_trip() {
let mut lib = named();
lib.insert("Look", look()).unwrap();
let text = lib.to_text();
assert!(text.contains("reach = named\n"), "{text}");
let back = PresetLibrary::parse(&text).unwrap();
assert_eq!(back, lib);
// And the default is not written, so an existing library's bytes are
// unchanged by this format ever having grown the line.
assert_eq!(named().to_text().matches("reach").count(), 0);
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
fn named() -> PresetLibrary {
let mut lib = PresetLibrary::default();
lib.insert("Warm portrait", Preset::capture(&edited()))
.unwrap();
lib.insert("Neutral", Preset::default()).unwrap();
lib
}
#[test]
fn a_library_round_trips_through_its_text_form() {
let lib = named();
let back = PresetLibrary::parse(&lib.to_text()).unwrap();
assert_eq!(back, lib);
}
#[test]
fn the_same_library_always_writes_the_same_bytes() {
// What lets a caller skip a write by comparing content. Built in the
// opposite order to `named()` so insertion order cannot be what makes
// this pass.
let mut other = PresetLibrary::default();
other.insert("Neutral", Preset::default()).unwrap();
other
.insert("Warm portrait", Preset::capture(&edited()))
.unwrap();
assert_eq!(other.to_text(), named().to_text());
}
#[test]
fn a_neutral_preset_is_storable_and_survives_the_round_trip() {
// The empty preset is the "clear these forty frames" action, so it has
// to be a real entry rather than an absence — and a block with no
// lines under it has to parse back as a preset rather than vanish.
let back = PresetLibrary::parse(&named().to_text()).unwrap();
assert_eq!(back.get("Neutral"), Some(&Preset::default()));
}
#[test]
fn an_unreadable_line_costs_that_line_and_not_the_library() {
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
this line is not a setting\nsaturation.amount = not a number\n"
);
let lib = PresetLibrary::parse(&text).unwrap();
let preset = lib.get("Keep").expect("the preset survived");
assert_eq!(
preset.params().get(&("exposure".into(), "exposure".into())),
Some(&0.5)
);
}
#[test]
fn lines_this_build_cannot_read_are_written_back_untouched() {
// The sidecar's version-skew promise, applied here: a build running
// behind must not silently strip what a newer one wrote.
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
something_new_entirely\n"
);
let out = PresetLibrary::parse(&text).unwrap().to_text();
assert!(out.contains("something_new_entirely"), "{out}");
}
#[test]
fn an_unknown_parameter_survives_without_any_machinery_for_it() {
// `Preset` stores whatever keys it is given and resolves them against
// the descriptors only at apply time, so a parameter from a newer
// build needs no preservation path of its own.
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nnot_an_op.not_a_param = 0.25\n"
);
let out = PresetLibrary::parse(&text).unwrap().to_text();
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_film_survives_the_library_round_trip() {
let mut lib = named();
lib.insert("Portra", Preset::capture(&on_film("kodak_portra_400")))
.unwrap();
let text = lib.to_text();
assert!(text.contains("film = kodak_portra_400\n"), "{text}");
assert!(
text.contains("film_print = kodak_portra_endura\n"),
"{text}"
);
assert_eq!(PresetLibrary::parse(&text).unwrap(), lib);
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// The paper may come first in a hand-edited block, and a paper with no
/// film is dropped rather than baked from nothing.
#[test]
fn film_lines_read_in_either_order_and_a_lone_paper_is_dropped() {
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset A]\nfilm_print = p\nfilm = s\n\n\
[preset B]\nfilm_print = p\nexposure.exposure = 1\n"
);
let lib = PresetLibrary::parse(&text).unwrap();
let a = lib.get("A").unwrap().film().unwrap();
assert_eq!((a.stock.as_str(), a.print.as_deref()), ("s", Some("p")));
assert_eq!(lib.get("B").unwrap().film(), None);
assert_eq!(lib.get("B").unwrap().len(), 1);
}
#[test]
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
assert_eq!(
PresetLibrary::parse(&text),
Err(LibraryParseError::UnsupportedVersion(
LIBRARY_FORMAT_VERSION + 1
))
);
}
#[test]
fn something_that_is_not_a_preset_library_is_refused() {
assert_eq!(
PresetLibrary::parse("drsc 1\n\n[version abc]\n"),
Err(LibraryParseError::NotALibrary)
);
assert_eq!(
PresetLibrary::parse(""),
Err(LibraryParseError::NotALibrary)
);
}
#[test]
fn a_name_that_would_not_parse_back_is_refused() {
// Round-trip safety, not taste: a `]` would close the header early and
// the file would read back as a different library.
let mut lib = PresetLibrary::default();
assert_eq!(
lib.insert("bracket] inside", Preset::default()),
Err(NameError::Unrepresentable)
);
assert_eq!(
lib.insert("two\nlines", Preset::default()),
Err(NameError::Unrepresentable)
);
assert_eq!(lib.insert(" ", Preset::default()), Err(NameError::Empty));
}
#[test]
fn surrounding_whitespace_is_trimmed_rather_than_refused() {
let mut lib = PresetLibrary::default();
lib.insert(" Warm ", Preset::default()).unwrap();
assert!(lib.contains("Warm"));
}
#[test]
fn saving_over_a_name_replaces_it_and_says_so() {
let mut lib = named();
assert_eq!(lib.insert("Warm portrait", Preset::default()), Ok(true));
assert_eq!(lib.insert("Brand new", Preset::default()), Ok(false));
assert_eq!(lib.get("Warm portrait"), Some(&Preset::default()));
}
#[test]
fn renaming_moves_the_preset_and_leaves_nothing_behind() {
let mut lib = named();
let before = lib.get("Warm portrait").cloned().unwrap();
assert_eq!(lib.rename("Warm portrait", "Cool portrait"), Ok(true));
assert!(!lib.contains("Warm portrait"));
assert_eq!(lib.get("Cool portrait"), Some(&before));
}
#[test]
fn renaming_something_that_is_gone_is_not_an_error() {
// Another window may have deleted it since this list was drawn.
let mut lib = named();
assert_eq!(lib.rename("Never existed", "Whatever"), Ok(false));
}
#[test]
fn deleting_reports_whether_there_was_anything_to_delete() {
let mut lib = named();
assert!(lib.remove("Neutral"));
assert!(!lib.remove("Neutral"));
assert_eq!(lib.len(), 1);
}
#[test]
fn a_stored_preset_applies_exactly_as_a_pasted_one_does() {
// The whole point of sharing one representation: a named preset is not
// a second kind of thing with a second apply path.
let lib = named();
let stored = PresetLibrary::parse(&lib.to_text()).unwrap();
let preset = stored.get("Warm portrait").unwrap();
let mut target = EditGraph::default_chain();
preset
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
// The target keeps its own framing on the default scope.
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
}
#[test]
fn a_stored_preset_amends_a_sidecar_without_a_graph() {
// The batch path: applying to forty images must not build forty
// graphs, so this is the call the library's batch apply makes.
let lib = named();
let preset = lib.get("Warm portrait").unwrap();
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
params.insert(("framing".into(), "angle".into()), 5.0);
params.insert(("exposure".into(), "exposure".into()), -1.0);
preset.amend(&mut params, Scope::adjustments());
assert_eq!(
params.get(&("exposure".into(), "exposure".into())),
Some(&0.75)
);
// Out of scope, so the target's own crop is untouched.
assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0));
}
// -----------------------------------------------------------------------
// Partial scope
// -----------------------------------------------------------------------
#[test]
fn a_hand_picked_scope_carries_only_the_kinds_it_names() {
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
preset
.apply(&mut target, Scope::of([Attribute::Tone]))
.expect_no_film();
// Tone was picked, so the exposure travelled.
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
Some(0.75),
"the tone work should have travelled"
);
// Colour was not, so the white balance stayed behind.
assert_eq!(
target.param(white_balance::ID, white_balance::TEMPERATURE),
Some(0.0),
"the colour work should have stayed behind"
);
}
#[test]
fn an_operation_with_two_attributes_travels_with_either_of_them() {
// `tone_curve` declares tone *and* colour. Requiring both would make
// the curve unreachable unless both were ticked, which is not what
// "include the tone work" means to the person ticking it.
let curve = "tone_curve";
assert!(Scope::of([Attribute::Tone]).covers(curve));
assert!(Scope::of([Attribute::Colour]).covers(curve));
assert!(!Scope::of([Attribute::Detail]).covers(curve));
}
#[test]
fn geometry_is_the_only_thing_the_default_scope_leaves_out() {
let default_scope = Scope::adjustments();
let excluded: Vec<Attribute> = Attribute::ALL
.into_iter()
.filter(|a| !default_scope.has(*a))
.collect();
assert_eq!(excluded, vec![Attribute::Compose]);
assert!(Scope::everything().has(Attribute::Compose));
}
#[test]
fn a_whole_edit_scope_carries_an_operation_this_build_cannot_classify() {
// The version-skew case, and the reason `carries_unclassified` is a
// field rather than a rule: a preset made on a newer device still
// applies on an older one (FR-NC-8).
assert!(Scope::adjustments().covers("an_operation_from_the_future"));
assert!(Scope::everything().covers("an_operation_from_the_future"));
}
#[test]
fn a_hand_picked_scope_leaves_an_unclassifiable_operation_behind() {
// "The tone work, not the colour" is a statement about kinds, and an
// operation whose kind is unknown is not one of the kinds picked. It
// must not be smuggled in under a selection that never named it.
let picked = Scope::of([Attribute::Tone, Attribute::Colour]);
assert!(!picked.covers("an_operation_from_the_future"));
}
#[test]
fn a_scope_naming_nothing_moves_nothing_and_destroys_nothing() {
// What an interface holds while the photographer is still un-ticking
// boxes. Applying it must be a no-op rather than a reset.
let empty = Scope::of([]);
assert!(empty.is_empty());
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, -1.25);
Preset::capture(&edited())
.apply(&mut target, empty)
.expect_no_film();
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
Some(-1.25),
"an empty scope cleared the target"
);
}
#[test]
fn an_empty_scope_amends_a_map_without_touching_it() {
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
params.insert(("exposure".into(), "exposure".into()), -1.25);
let before = params.clone();
Preset::capture(&edited()).amend(&mut params, Scope::of([]));
assert_eq!(params, before);
}
#[test]
fn a_scope_lists_the_kinds_it_names_in_declaration_order() {
// Declaration order is roughly the order a photographer works in, and
// an interface drawing chips from this should not have to sort them.
let scope = Scope::of([Attribute::Detail, Attribute::Tone]);
assert_eq!(
scope.attributes().collect::<Vec<_>>(),
vec![Attribute::Tone, Attribute::Detail]
);
}
#[test]
fn an_attribute_name_survives_the_round_trip_it_is_stored_through() {
// These strings go into settings and come back. A change to one would
// silently reset a photographer's choice of what a paste carries.
for attribute in Attribute::ALL {
assert_eq!(Attribute::from_name(attribute.name()), Some(attribute));
}
}
}