mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
815 lines
36 KiB
Rust
815 lines
36 KiB
Rust
//! Importing other editors' presets: XMP preset files, classic `.lrtemplate` files (a Lua table
|
|
//! literal), photos that carry their edits as an XMP packet ("DNG presets" from mobile apps,
|
|
//! edited JPEG/TIFF), and zip bundles of any of these. Everything ends up as `crs:` properties
|
|
//! mapped by [`crate::crs`]. Luminar looks (`.lmp`, `.mplumpack` collections) are read by
|
|
//! [`crate::preset_luminar`].
|
|
//!
|
|
//! Written from the formats' public structure (Lua's table syntax, the ZIP application note,
|
|
//! ISO 16684-1 XMP) and black-box observation; no third-party code or preset content was used.
|
|
|
|
use std::path::Path;
|
|
|
|
use lightcraft_develop::Preset;
|
|
use serde_json::{Map, Value};
|
|
|
|
use crate::crs::Props;
|
|
|
|
/// One preset read from a file, with the settings we could not carry over.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Imported {
|
|
pub preset: Preset,
|
|
/// Names of the other editor's settings that have no counterpart here (local masks, its own
|
|
/// profiles…).
|
|
pub unmapped: Vec<String>,
|
|
}
|
|
|
|
/// Extensions of files read as presets (beyond our own `.lcpreset`).
|
|
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"];
|
|
|
|
// ------------------------------------------------------------------------------------- zip
|
|
|
|
/// The most a preset zip entry may hold unpacked (a "DNG preset" photo is the largest kind, tens
|
|
/// of MB): its stated size is the file's word, so a small archive could otherwise ask for 8 GB.
|
|
pub const MAX_ZIP_ENTRY: usize = 256 << 20;
|
|
/// The most a whole preset zip may hold unpacked.
|
|
pub const MAX_ZIP_TOTAL: usize = 1 << 30;
|
|
|
|
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
|
|
/// directories, macOS resource forks and hidden files are skipped. An entry that unpacks past
|
|
/// [`MAX_ZIP_ENTRY`], or an archive past [`MAX_ZIP_TOTAL`], is an error (a zip bomb would make
|
|
/// the allocation fail, which ends the app).
|
|
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
|
read_zip_limited(bytes, MAX_ZIP_ENTRY, MAX_ZIP_TOTAL)
|
|
}
|
|
|
|
fn read_zip_limited(bytes: &[u8], max_entry: usize, max_total: usize) -> Result<Vec<(String, Vec<u8>)>, String> {
|
|
let u16_at = |o: usize| bytes.get(o..o.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
|
let u32_at = |o: usize| bytes.get(o..o.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
|
// offsets from the file, added up without overflowing (a 32-bit build reads them too)
|
|
let add = |a: usize, b: usize| a.checked_add(b).ok_or("bad zip");
|
|
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
|
|
let tail = bytes.len().saturating_sub(65_557);
|
|
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
|
|
let count = u16_at(eocd + 10).ok_or("bad zip")?;
|
|
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
|
|
let mut out = Vec::new();
|
|
let mut total = 0usize;
|
|
for _ in 0..count {
|
|
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
|
|
return Err("bad zip central directory".into());
|
|
}
|
|
let method = u16_at(add(at, 10)?).ok_or("bad zip")?;
|
|
let csize = u32_at(add(at, 20)?).ok_or("bad zip")?;
|
|
let usize_ = u32_at(add(at, 24)?).ok_or("bad zip")?;
|
|
let (nlen, xlen, clen) =
|
|
(u16_at(add(at, 28)?).ok_or("bad zip")?, u16_at(add(at, 30)?).ok_or("bad zip")?, u16_at(add(at, 32)?).ok_or("bad zip")?);
|
|
let local = u32_at(add(at, 42)?).ok_or("bad zip")?;
|
|
let name_at = add(at, 46)?;
|
|
let name = String::from_utf8_lossy(bytes.get(name_at..add(name_at, nlen)?).ok_or("bad zip")?).replace('\\', "/");
|
|
at = add(add(add(name_at, nlen)?, xlen)?, clen)?;
|
|
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
|
|
if skip {
|
|
continue;
|
|
}
|
|
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
|
|
return Err(format!("{name}: bad local header"));
|
|
}
|
|
let data_at = add(add(add(local, 30)?, u16_at(add(local, 26)?).ok_or("bad zip")?)?, u16_at(add(local, 28)?).ok_or("bad zip")?)?;
|
|
let data = bytes.get(data_at..add(data_at, csize)?).ok_or_else(|| format!("{name}: truncated"))?;
|
|
let left = max_total.saturating_sub(total);
|
|
let limit = max_entry.min(left);
|
|
let too_big = || format!("{name}: unpacks to more than {} MB, too much for a preset", limit >> 20);
|
|
let content = match method {
|
|
0 if data.len() > limit => return Err(too_big()),
|
|
0 => data.to_vec(),
|
|
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1).saturating_mul(2).saturating_add(1024).min(limit)).map_err(
|
|
|e| if matches!(e.status, miniz_oxide::inflate::TINFLStatus::HasMoreOutput) { too_big() } else { format!("{name}: {e:?}") },
|
|
)?,
|
|
m => return Err(format!("{name}: unsupported compression method {m}")),
|
|
};
|
|
total = total.saturating_add(content.len());
|
|
out.push((name, content));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------- Lua tables
|
|
|
|
/// A value of a Lua table literal (what `.lrtemplate` files contain).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Lua {
|
|
Nil,
|
|
Bool(bool),
|
|
Num(f64),
|
|
Str(String),
|
|
/// Positional items and named fields, in file order.
|
|
Table(Vec<Lua>, Vec<(String, Lua)>),
|
|
}
|
|
|
|
impl Lua {
|
|
pub fn get(&self, key: &str) -> Option<&Lua> {
|
|
match self {
|
|
Lua::Table(_, m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn str(&self) -> Option<&str> {
|
|
match self {
|
|
Lua::Str(s) => Some(s),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LuaParser<'a> {
|
|
s: &'a [u8],
|
|
i: usize,
|
|
depth: usize,
|
|
}
|
|
|
|
impl LuaParser<'_> {
|
|
fn err<T>(&self, what: &str) -> Result<T, String> {
|
|
Err(format!("{what} at byte {}", self.i))
|
|
}
|
|
fn skip_ws(&mut self) {
|
|
loop {
|
|
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
|
|
self.i += 1;
|
|
}
|
|
if self.s[self.i..].starts_with(b"--") {
|
|
self.i += 2;
|
|
if let Some(level) = self.long_bracket_level() {
|
|
let _ = self.long_string(level);
|
|
} else {
|
|
while self.i < self.s.len() && self.s[self.i] != b'\n' {
|
|
self.i += 1;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
/// `[[` / `[==[` at the cursor: its level (number of `=`).
|
|
fn long_bracket_level(&self) -> Option<usize> {
|
|
let r = &self.s[self.i..];
|
|
if r.first() != Some(&b'[') {
|
|
return None;
|
|
}
|
|
let eq = r[1..].iter().take_while(|c| **c == b'=').count();
|
|
(r.get(1 + eq) == Some(&b'[')).then_some(eq)
|
|
}
|
|
fn long_string(&mut self, level: usize) -> Result<String, String> {
|
|
self.i += level + 2;
|
|
let close = format!("]{}]", "=".repeat(level));
|
|
let rest = &self.s[self.i..];
|
|
let end = rest.windows(close.len()).position(|w| w == close.as_bytes()).ok_or("unterminated long string")?;
|
|
let mut text = String::from_utf8_lossy(&rest[..end]).to_string();
|
|
if text.starts_with('\n') {
|
|
text.remove(0);
|
|
}
|
|
self.i += end + close.len();
|
|
Ok(text)
|
|
}
|
|
fn string(&mut self) -> Result<String, String> {
|
|
let q = self.s[self.i];
|
|
self.i += 1;
|
|
let mut out = Vec::new();
|
|
while self.i < self.s.len() {
|
|
let c = self.s[self.i];
|
|
self.i += 1;
|
|
match c {
|
|
c if c == q => return Ok(String::from_utf8_lossy(&out).to_string()),
|
|
b'\\' => {
|
|
let e = *self.s.get(self.i).ok_or("bad escape")?;
|
|
self.i += 1;
|
|
match e {
|
|
b'n' => out.push(b'\n'),
|
|
b't' => out.push(b'\t'),
|
|
b'r' => out.push(b'\r'),
|
|
b'0'..=b'9' => {
|
|
let mut v = (e - b'0') as u32;
|
|
for _ in 0..2 {
|
|
match self.s.get(self.i) {
|
|
Some(d @ b'0'..=b'9') => {
|
|
v = v * 10 + (d - b'0') as u32;
|
|
self.i += 1;
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
out.push(v.min(255) as u8);
|
|
}
|
|
b'\n' => out.push(b'\n'),
|
|
other => out.push(other),
|
|
}
|
|
}
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
self.err("unterminated string")
|
|
}
|
|
fn ident(&mut self) -> Option<String> {
|
|
let start = self.i;
|
|
while self.i < self.s.len() && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') {
|
|
self.i += 1;
|
|
}
|
|
(self.i > start && !self.s[start].is_ascii_digit()).then(|| String::from_utf8_lossy(&self.s[start..self.i]).to_string())
|
|
}
|
|
fn number(&mut self) -> Result<f64, String> {
|
|
let start = self.i;
|
|
if self.s[self.i..].starts_with(b"0x") || self.s[self.i..].starts_with(b"0X") {
|
|
self.i += 2;
|
|
let h = self.i;
|
|
while self.i < self.s.len() && self.s[self.i].is_ascii_hexdigit() {
|
|
self.i += 1;
|
|
}
|
|
return i64::from_str_radix(std::str::from_utf8(&self.s[h..self.i]).unwrap_or(""), 16).map(|v| v as f64).map_err(|e| e.to_string());
|
|
}
|
|
while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'e' | b'E' | b'+' | b'-')) {
|
|
if matches!(self.s[self.i], b'+' | b'-') && self.i > start && !matches!(self.s[self.i - 1], b'e' | b'E') {
|
|
break;
|
|
}
|
|
self.i += 1;
|
|
}
|
|
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
|
|
}
|
|
fn value(&mut self) -> Result<Lua, String> {
|
|
if self.depth >= 64 {
|
|
return self.err("Lua settings exceed nesting limit");
|
|
}
|
|
self.depth += 1;
|
|
let result = self.value_inner();
|
|
self.depth -= 1;
|
|
result
|
|
}
|
|
fn value_inner(&mut self) -> Result<Lua, String> {
|
|
self.skip_ws();
|
|
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
|
|
match c {
|
|
b'{' => self.table(),
|
|
b'"' | b'\'' => self.string().map(Lua::Str),
|
|
b'[' if self.long_bracket_level().is_some() => {
|
|
let level = self.long_bracket_level().unwrap_or(0);
|
|
self.long_string(level).map(Lua::Str)
|
|
}
|
|
b'-' => {
|
|
self.i += 1;
|
|
self.skip_ws();
|
|
match self.value()? {
|
|
Lua::Num(n) => Ok(Lua::Num(-n)),
|
|
_ => self.err("bad negation"),
|
|
}
|
|
}
|
|
b'0'..=b'9' | b'.' => self.number().map(Lua::Num),
|
|
_ => match self.ident().as_deref() {
|
|
Some("true") => Ok(Lua::Bool(true)),
|
|
Some("false") => Ok(Lua::Bool(false)),
|
|
Some("nil") => Ok(Lua::Nil),
|
|
// a call with one string / table argument, e.g. ZSTR "…" (localisation helpers)
|
|
Some(_) => {
|
|
self.skip_ws();
|
|
match self.s.get(self.i) {
|
|
Some(b'"' | b'\'' | b'{') => self.value(),
|
|
Some(b'(') => {
|
|
self.i += 1;
|
|
let v = self.value()?;
|
|
self.skip_ws();
|
|
if self.s.get(self.i) == Some(&b')') {
|
|
self.i += 1;
|
|
}
|
|
Ok(v)
|
|
}
|
|
_ => Ok(Lua::Nil),
|
|
}
|
|
}
|
|
None => self.err("unexpected character"),
|
|
},
|
|
}
|
|
}
|
|
fn table(&mut self) -> Result<Lua, String> {
|
|
self.i += 1; // {
|
|
let (mut arr, mut map) = (Vec::new(), Vec::new());
|
|
loop {
|
|
self.skip_ws();
|
|
match self.s.get(self.i) {
|
|
None => return self.err("unterminated table"),
|
|
Some(b'}') => {
|
|
self.i += 1;
|
|
return Ok(Lua::Table(arr, map));
|
|
}
|
|
Some(b',' | b';') => {
|
|
self.i += 1;
|
|
continue;
|
|
}
|
|
Some(b'[') if self.long_bracket_level().is_none() => {
|
|
self.i += 1;
|
|
let k = self.value()?;
|
|
self.skip_ws();
|
|
if self.s.get(self.i) != Some(&b']') {
|
|
return self.err("expected ]");
|
|
}
|
|
self.i += 1;
|
|
self.expect_eq()?;
|
|
let v = self.value()?;
|
|
match k {
|
|
Lua::Str(s) => map.push((s, v)),
|
|
Lua::Num(n) => map.push((format!("{n}"), v)),
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {
|
|
// `name = value`, or a positional value
|
|
let save = self.i;
|
|
if let Some(name) = self.ident() {
|
|
self.skip_ws();
|
|
if self.s.get(self.i) == Some(&b'=') && self.s.get(self.i + 1) != Some(&b'=') {
|
|
self.i += 1;
|
|
let v = self.value()?;
|
|
map.push((name, v));
|
|
continue;
|
|
}
|
|
}
|
|
self.i = save;
|
|
arr.push(self.value()?);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn expect_eq(&mut self) -> Result<(), String> {
|
|
self.skip_ws();
|
|
if self.s.get(self.i) == Some(&b'=') {
|
|
self.i += 1;
|
|
Ok(())
|
|
} else {
|
|
self.err("expected =")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
|
|
pub fn parse_lua(text: &str) -> Result<Lua, String> {
|
|
if text.len() > 16 << 20 {
|
|
return Err("Lua settings exceed 16 MiB".into());
|
|
}
|
|
let mut p = LuaParser { s: text.as_bytes(), i: 0, depth: 0 };
|
|
p.skip_ws();
|
|
let save = p.i;
|
|
match p.ident().as_deref() {
|
|
Some("return") => {}
|
|
Some(_) => {
|
|
p.skip_ws();
|
|
if p.s.get(p.i) == Some(&b'=') {
|
|
p.i += 1;
|
|
} else {
|
|
p.i = save;
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
p.value()
|
|
}
|
|
|
|
/// A localisable string `"$$$/Key/Path=Default text"` → its default text.
|
|
fn delocalize(s: &str) -> String {
|
|
match s.strip_prefix("$$$/") {
|
|
Some(rest) => rest.split_once('=').map_or(rest, |(_, t)| t).to_string(),
|
|
None => s.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The develop settings of an `.lrtemplate` as `crs:` properties (flat, and structured for local
|
|
/// corrections), plus its title.
|
|
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs_masks::Values), String> {
|
|
let root = parse_lua(text)?;
|
|
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
|
|
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
|
|
let (props, values) = lua_settings_props(settings)?;
|
|
Ok((title, props, values))
|
|
}
|
|
|
|
/// Data-only Lua settings shared by preset and native catalog import. Never executes Lua.
|
|
pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_masks::Values), String> {
|
|
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
|
|
let mut props = Props::new();
|
|
let mut values = crate::crs_masks::Values::new();
|
|
for (k, v) in fields {
|
|
let key = format!("crs:{k}");
|
|
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
|
|
values.insert(key.clone(), crate::crs_masks::from_lua(v));
|
|
}
|
|
let vals: Vec<String> = match v {
|
|
Lua::Num(n) => vec![format!("{n}")],
|
|
Lua::Bool(b) => vec![if *b { "True" } else { "False" }.to_string()],
|
|
Lua::Str(s) => vec![delocalize(s)],
|
|
// point curves are flat lists x1, y1, x2, y2, …
|
|
Lua::Table(arr, map) if map.is_empty() && !arr.is_empty() && arr.iter().all(|a| matches!(a, Lua::Num(_))) => arr
|
|
.chunks(2)
|
|
.filter_map(|c| match c {
|
|
[Lua::Num(x), Lua::Num(y)] => Some(format!("{x}, {y}")),
|
|
_ => None,
|
|
})
|
|
.collect(),
|
|
Lua::Table(a, m) if a.is_empty() && m.is_empty() => continue,
|
|
// anything else (local corrections, retouch…) is only reported
|
|
Lua::Table(..) => vec!["(table)".into()],
|
|
Lua::Nil => continue,
|
|
};
|
|
props.insert(key, vals);
|
|
}
|
|
Ok((props, values))
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------- XMP in files
|
|
|
|
/// The XMP packet embedded in a file of any container (DNG, JPEG, TIFF…): the text from
|
|
/// `<x:xmpmeta` to `</x:xmpmeta>`.
|
|
pub fn embedded_xmp(bytes: &[u8]) -> Option<String> {
|
|
let start = bytes.windows(10).position(|w| w == b"<x:xmpmeta")?;
|
|
let end = bytes[start..].windows(12).position(|w| w == b"</x:xmpmeta>")? + start + 12;
|
|
Some(String::from_utf8_lossy(&bytes[start..end]).to_string())
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------- files
|
|
|
|
/// A preset group named after the innermost folder of `dir` (a path inside the imported folder or
|
|
/// bundle) that isn't a container named for an app or a format (`Lightroom Classic`, `XMP`,
|
|
/// `Mobile (DNG)`, `Develop Presets`…).
|
|
pub fn group_from_dir(dir: &str) -> Option<String> {
|
|
const FILLER: &[&str] = &[
|
|
"lightroom",
|
|
"lr",
|
|
"lrc",
|
|
"classic",
|
|
"cc",
|
|
"camera",
|
|
"raw",
|
|
"acr",
|
|
"photoshop",
|
|
"desktop",
|
|
"mobile",
|
|
"xmp",
|
|
"lrtemplate",
|
|
"lrtemplates",
|
|
"dng",
|
|
"dngs",
|
|
"preset",
|
|
"presets",
|
|
"settings",
|
|
"lcpreset",
|
|
"lightcraft",
|
|
"user",
|
|
"develop",
|
|
"files",
|
|
"for",
|
|
"and",
|
|
"version",
|
|
"versions",
|
|
"format",
|
|
"formats",
|
|
"new",
|
|
"old",
|
|
"adobe",
|
|
"luminar",
|
|
"skylum",
|
|
"looks",
|
|
"lmp",
|
|
"mplumpack",
|
|
"contents",
|
|
"resources",
|
|
];
|
|
let generic = |name: &str| {
|
|
let lower = name.to_ascii_lowercase();
|
|
lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).all(|w| FILLER.contains(&w) || w.chars().all(|c| c.is_ascii_digit()))
|
|
};
|
|
// a macOS bundle (`Look.lmp/Contents/…`) is a file, not a group
|
|
let bundle = |p: &str| p.to_ascii_lowercase().ends_with(".lmp");
|
|
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p) && !bundle(p)).map(str::to_string)
|
|
}
|
|
|
|
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
|
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT);
|
|
if from_photo && let Some(o) = settings.as_object_mut() {
|
|
// a photo's own framing and absolute white balance don't belong in a look
|
|
o.remove("crop");
|
|
o.remove("geometry");
|
|
if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") {
|
|
o.remove("wb");
|
|
}
|
|
}
|
|
if settings.as_object().is_none_or(Map::is_empty) {
|
|
return None;
|
|
}
|
|
let group = props
|
|
.get("crs:Group")
|
|
.and_then(|v| v.first())
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.or(group)
|
|
.unwrap_or_else(|| "Imported Presets".into());
|
|
let uuid = props.get("crs:UUID").and_then(|v| v.first()).map(|u| u.to_ascii_lowercase());
|
|
let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
|
|
Some(Imported { preset: Preset { id, name, group, settings, favorite: false, builtin: false }, unmapped })
|
|
}
|
|
|
|
/// Read the presets in file `name` (`bytes`); `group` names the group when the file doesn't
|
|
/// (its folder). Zip bundles are read recursively.
|
|
pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<Vec<Imported>, String> {
|
|
let path = Path::new(name);
|
|
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
|
|
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
|
match ext.as_str() {
|
|
"zip" => {
|
|
let mut out = Vec::new();
|
|
let mut errors = Vec::new();
|
|
for (inner, data) in read_zip(bytes)? {
|
|
let iext = Path::new(&inner).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
|
if !(PRESET_EXTS.contains(&iext.as_str()) || iext == crate::presets::LCPRESET_EXT) || iext == "zip" {
|
|
continue;
|
|
}
|
|
let dir_group =
|
|
inner.rsplit_once('/').and_then(|(d, _)| group_from_dir(d)).or_else(|| group_from_dir(&stem)).or_else(|| group.clone());
|
|
match read_presets(&inner, &data, dir_group) {
|
|
Ok(v) => out.extend(v),
|
|
Err(e) => errors.push(format!("{inner}: {e}")),
|
|
}
|
|
}
|
|
if out.is_empty() {
|
|
return Err(if errors.is_empty() { "no presets in this archive".into() } else { errors.join("; ") });
|
|
}
|
|
Ok(out)
|
|
}
|
|
"lmp" => crate::preset_luminar::read_lmp(name, bytes, group).map(|i| vec![i]),
|
|
"mplumpack" => crate::preset_luminar::read_mplumpack(name, bytes),
|
|
"lrtemplate" => {
|
|
let text = String::from_utf8_lossy(bytes);
|
|
let (title, props, values) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
|
|
build(&props, &values, title.unwrap_or(stem), group, false)
|
|
.map(|i| vec![i])
|
|
.ok_or_else(|| "no develop settings we can use in this template".into())
|
|
}
|
|
"xmp" => {
|
|
let text = String::from_utf8_lossy(bytes);
|
|
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
|
|
let props = &d.properties;
|
|
let name = props.get("crs:Name").and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or(stem);
|
|
build(props, &d.values, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
|
|
}
|
|
// a photo carrying its edits (mobile "DNG presets", edited JPEG / TIFF)
|
|
"dng" | "jpg" | "jpeg" | "tif" | "tiff" => {
|
|
let xmp = embedded_xmp(bytes).ok_or("this photo carries no edits (no XMP)")?;
|
|
let d = lightcraft_meta::parse_xmp(&xmp).map_err(|e| e.to_string())?;
|
|
build(&d.properties, &d.values, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
|
|
}
|
|
_ => crate::presets::parse_preset_file(name, bytes).map(|v| {
|
|
v.into_iter()
|
|
.map(|mut p| {
|
|
if p.group == "Imported Presets"
|
|
&& let Some(g) = &group
|
|
{
|
|
p.group = g.clone();
|
|
}
|
|
Imported { preset: p, unmapped: Vec::new() }
|
|
})
|
|
.collect()
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
/// A zip archive (`deflate`: compress entries) — enough of a writer for the tests.
|
|
pub(crate) fn zip(entries: &[(&str, &[u8])], deflate: bool) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
let mut central = Vec::new();
|
|
for (name, data) in entries {
|
|
let (method, body) = if deflate { (8u16, miniz_oxide::deflate::compress_to_vec(data, 6)) } else { (0, data.to_vec()) };
|
|
let off = out.len() as u32;
|
|
let head = |sig: &[u8], out: &mut Vec<u8>, central: bool| {
|
|
out.extend_from_slice(sig);
|
|
if central {
|
|
out.extend_from_slice(&20u16.to_le_bytes());
|
|
}
|
|
out.extend_from_slice(&20u16.to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes());
|
|
out.extend_from_slice(&method.to_le_bytes());
|
|
out.extend_from_slice(&[0; 8]); // time, date, crc (not checked)
|
|
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
|
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
|
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes());
|
|
};
|
|
head(b"PK\x03\x04", &mut out, false);
|
|
out.extend_from_slice(name.as_bytes());
|
|
out.extend_from_slice(&body);
|
|
head(b"PK\x01\x02", &mut central, true);
|
|
central.extend_from_slice(&[0; 10]); // comment length, disk, internal + external attributes
|
|
central.extend_from_slice(&off.to_le_bytes());
|
|
central.extend_from_slice(name.as_bytes());
|
|
}
|
|
let cd = out.len() as u32;
|
|
out.extend_from_slice(¢ral);
|
|
out.extend_from_slice(b"PK\x05\x06\0\0\0\0");
|
|
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
|
out.extend_from_slice(&cd.to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes());
|
|
out
|
|
}
|
|
|
|
/// A template in the classic Lua-table form (written for this test).
|
|
pub(crate) const TEMPLATE: &str = r#"s = {
|
|
id = "0D2F9C1E-TEST",
|
|
internalName = "Warm Fade",
|
|
title = "$$$/Test/Warm=Warm Fade",
|
|
type = "Develop",
|
|
value = {
|
|
settings = {
|
|
Exposure2012 = 0.35,
|
|
Contrast2012 = -20,
|
|
ConvertToGrayscale = false,
|
|
ToneCurvePV2012 = { 0, 20, 128, 128, 255, 240, },
|
|
SplitToningShadowHue = 210,
|
|
SplitToningShadowSaturation = 15,
|
|
HueAdjustmentOrange = -8,
|
|
CameraProfile = "Some Profile",
|
|
RetouchInfo = {},
|
|
ProcessVersion = "11.0",
|
|
-- a comment
|
|
EnableColorAdjustments = true,
|
|
},
|
|
uuid = "0D2F9C1E-TEST",
|
|
},
|
|
version = 0,
|
|
}
|
|
"#;
|
|
|
|
#[test]
|
|
fn lua_tables() {
|
|
let v = parse_lua(
|
|
r#"return { a = 1, ["b c"] = 'x\'y', -2.5e1, 0x10, t = { true, nil }, [[long
|
|
text]], z = ZSTR "loc" }"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(v.get("a"), Some(&Lua::Num(1.0)));
|
|
assert_eq!(v.get("b c"), Some(&Lua::Str("x'y".into())));
|
|
assert_eq!(v.get("z"), Some(&Lua::Str("loc".into())));
|
|
let Lua::Table(arr, _) = &v else { panic!() };
|
|
assert_eq!(arr[..2], [Lua::Num(-25.0), Lua::Num(16.0)]);
|
|
assert_eq!(arr[2], Lua::Str("long\ntext".into()));
|
|
assert!(parse_lua("s = { a = ").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn lrtemplate_maps_settings_and_reports_the_rest() {
|
|
let v = read_presets("Warm Fade.lrtemplate", TEMPLATE.as_bytes(), None).unwrap();
|
|
assert_eq!(v.len(), 1);
|
|
let p = &v[0].preset;
|
|
assert_eq!((p.name.as_str(), p.group.as_str()), ("Warm Fade", "Imported Presets"));
|
|
assert_eq!(p.settings["light"]["exposure"], json!(0.35));
|
|
assert_eq!(p.settings["light"]["contrast"], json!(-20.0));
|
|
assert_eq!(p.settings["treatment"], json!("color"));
|
|
assert_eq!(p.settings["grading"]["shadows"]["hue"], json!(210.0));
|
|
assert_eq!(p.settings["mixer"]["orange"]["hue"], json!(-8.0));
|
|
let c = p.settings["curve"]["master"].as_array().unwrap();
|
|
assert_eq!(c.len(), 3);
|
|
assert!((c[0]["y"].as_f64().unwrap() - 20.0 / 255.0).abs() < 1e-9);
|
|
assert_eq!(v[0].unmapped, vec!["CameraProfile".to_string()], "profiles are ours; the rest is mapped or bookkeeping");
|
|
// it parses into real develop settings
|
|
let d = lightcraft_develop::DevelopSettings::default().merged(&p.settings).expect("valid develop settings");
|
|
assert_eq!(d.light.exposure, 0.35);
|
|
}
|
|
|
|
#[test]
|
|
fn old_process_version_fields_are_approximated() {
|
|
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
|
|
let p = &read_presets("Old.lrtemplate", t.as_bytes(), None).unwrap()[0].preset;
|
|
assert_eq!(p.settings["light"]["exposure"], json!(0.5));
|
|
assert_eq!(p.settings["light"]["contrast"], json!(25.0), "relative to the old default of 25");
|
|
assert_eq!(p.settings["light"]["shadows"], json!(20.0));
|
|
assert_eq!(p.settings["light"]["highlights"], json!(-30.0));
|
|
assert_eq!(p.settings["effects"]["clarity"], json!(10.0));
|
|
assert_eq!(p.settings["curve"]["master"].as_array().unwrap().len(), 3);
|
|
// newer presets carry the old fields at their defaults: those must not shift anything
|
|
let t2 = "s = { value = { settings = { Contrast2012 = 10, Contrast = 25, Shadows = 5, Brightness = 50 } } }";
|
|
let p2 = &read_presets("New.lrtemplate", t2.as_bytes(), None).unwrap()[0];
|
|
assert_eq!(p2.preset.settings, json!({"light": {"contrast": 10.0}}));
|
|
assert!(p2.unmapped.is_empty(), "{:?}", p2.unmapped);
|
|
}
|
|
|
|
const XMP: &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.50" crs:Vibrance="20" crs:WhiteBalance="As Shot" crs:Temperature="5600" crs:HasCrop="True" crs:CropLeft="0.1" crs:CropRight="0.9" crs:CropTop="0" crs:CropBottom="1"/>
|
|
</rdf:RDF></x:xmpmeta>"#;
|
|
|
|
#[test]
|
|
fn photos_carrying_edits_become_looks() {
|
|
// a "DNG preset": any container with an XMP packet; framing and white balance stay behind
|
|
let mut file = b"II*\0 binary junk ".to_vec();
|
|
file.extend_from_slice(XMP.as_bytes());
|
|
file.extend_from_slice(b" more junk");
|
|
let v = read_presets("/x/Moody.dng", &file, None).unwrap();
|
|
let p = &v[0].preset;
|
|
assert_eq!(p.name, "Moody");
|
|
assert_eq!(p.settings, json!({"light": {"exposure": 0.5}, "color": {"vibrance": 20.0}}));
|
|
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn zip_bombs_are_refused() {
|
|
// an entry unpacking past the limit (3 MB of zeros pack into a few kB): refused, named
|
|
let zeros = vec![0u8; 3 << 20];
|
|
let z = zip(&[("Pack/huge.xmp", &zeros)], true);
|
|
assert!(z.len() < 64 << 10, "a small archive: {} bytes", z.len());
|
|
let e = read_zip_limited(&z, 1 << 20, 1 << 30).unwrap_err();
|
|
assert!(e.contains("Pack/huge.xmp") && e.contains("more than 1 MB"), "{e}");
|
|
// the whole archive counts too
|
|
let two = zip(&[("a.xmp", &zeros[..1 << 20]), ("b.xmp", &zeros[..1 << 20])], true);
|
|
assert!(read_zip_limited(&two, 2 << 20, (3 << 20) / 2).is_err());
|
|
assert_eq!(read_zip_limited(&two, 2 << 20, 3 << 20).unwrap().len(), 2);
|
|
// and stored entries
|
|
let stored = zip(&[("s.xmp", &zeros[..2 << 20])], false);
|
|
assert!(read_zip_limited(&stored, 1 << 20, 1 << 30).is_err());
|
|
// the size an entry states (here 4 GB) no longer sets how much may be unpacked
|
|
let mut lying = zip(&[("a.xmp", b"<x:xmpmeta/>")], true);
|
|
let cd = lying.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
|
|
lying[cd + 24..cd + 28].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
assert_eq!(read_zip(&lying).unwrap()[0].1, b"<x:xmpmeta/>");
|
|
// offsets near the top of a u32 are an error, not an overflow
|
|
let mut far = zip(&[("a.xmp", b"x")], false);
|
|
let cd = far.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
|
|
far[cd + 42..cd + 46].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
assert!(read_zip(&far).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn zip_bundles_with_folders_as_groups() {
|
|
for deflate in [false, true] {
|
|
let z = zip(
|
|
&[
|
|
("Pack/Film/Warm Fade.lrtemplate", TEMPLATE.as_bytes()),
|
|
("Pack/Film/Bright.xmp", XMP.as_bytes()),
|
|
("__MACOSX/Pack/._Bright.xmp", b"junk"),
|
|
("Pack/readme.txt", b"thanks for downloading"),
|
|
],
|
|
deflate,
|
|
);
|
|
let v = read_presets("pack.zip", &z, None).unwrap();
|
|
let names: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
|
assert_eq!(names, [("Warm Fade", "Film"), ("Bright", "Film")], "deflate={deflate}");
|
|
}
|
|
assert!(read_presets("empty.zip", &zip(&[("a.txt", b"x")], true), None).is_err());
|
|
assert!(read_presets("bad.zip", b"not a zip", None).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn generic_folder_names_are_not_groups() {
|
|
assert_eq!(group_from_dir("Settings"), None);
|
|
assert_eq!(group_from_dir("Develop Presets/"), None);
|
|
assert_eq!(group_from_dir("Looks/My Film Looks"), Some("My Film Looks".into()));
|
|
// commercial packs nest by format: the pack's name wins
|
|
assert_eq!(group_from_dir("Film Pack/Lightroom Classic"), Some("Film Pack".into()));
|
|
assert_eq!(group_from_dir("Film Pack/Mobile (DNG)/"), Some("Film Pack".into()));
|
|
assert_eq!(group_from_dir("Film Pack/XMP Presets for Lightroom CC 7.3+"), Some("Film Pack".into()));
|
|
assert_eq!(group_from_dir("Sacred Light"), Some("Sacred Light".into()), "whole words only");
|
|
}
|
|
|
|
#[test]
|
|
fn import_command_reads_folders_bundles_and_applies() {
|
|
let dir = std::env::temp_dir().join(format!("lc-preset-import-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(dir.join("Looks/Film")).unwrap();
|
|
std::fs::write(dir.join("Looks/Film/Warm Fade.lrtemplate"), TEMPLATE).unwrap();
|
|
std::fs::write(dir.join("Looks/bundle.zip"), zip(&[("Street/Bright.xmp", XMP.as_bytes())], true)).unwrap();
|
|
std::fs::write(dir.join("Looks/notes.txt"), "not a preset").unwrap();
|
|
let mut s = crate::Session::with_demo();
|
|
let before = s.presets.len();
|
|
let paths = json!([dir.join("Looks").to_string_lossy()]);
|
|
// a dry run reports without adding
|
|
let r = s.execute("preset.import", &json!({"paths": paths, "dryRun": true})).unwrap();
|
|
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
|
|
assert_eq!(s.presets.len(), before);
|
|
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
|
let got: Vec<(String, String)> =
|
|
r["imported"].as_array().unwrap().iter().map(|i| (i["name"].as_str().unwrap().into(), i["group"].as_str().unwrap().into())).collect();
|
|
assert_eq!(got, [("Warm Fade".to_string(), "Film".to_string()), ("Bright".into(), "Street".into())]);
|
|
assert_eq!(r["imported"][0]["unmapped"], json!(["CameraProfile"]));
|
|
assert_eq!(s.presets.len(), before + 2);
|
|
// importing again adds nothing
|
|
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
|
assert_eq!((r["imported"].as_array().unwrap().len(), r["skipped"].as_u64()), (0, Some(2)));
|
|
// and the imported look applies
|
|
let id = s.catalog.photos().next().unwrap().id;
|
|
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
|
let pid = s.presets.iter().find(|p| p.name == "Warm Fade").unwrap().id.clone();
|
|
s.execute("preset.apply", &json!({"id": pid})).unwrap();
|
|
let d = s.develop_of(id).unwrap();
|
|
assert_eq!((d.light.exposure, d.light.contrast), (0.35, -20.0));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|