mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #1966: LR-EXP-BITDEPTH: app.export refuses a bitDepth the format doesn't have
This commit is contained in:
@@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
for (k, v) in opts {
|
||||
p[k] = v;
|
||||
}
|
||||
let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
|
||||
if p.get("format").is_none() {
|
||||
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
// given before validating, so `bitDepth` is checked against the format written (issue #1952)
|
||||
p["format"] = json!(ext);
|
||||
}
|
||||
let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = s.after_export(&o)?;
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
|
||||
@@ -17,6 +17,12 @@ impl Session {
|
||||
/// `app.export` params with a named `preset` expanded: the preset's params, overridden by the
|
||||
/// call's own (`preset` itself removed). Unknown preset names are an error.
|
||||
pub fn export_params(&self, p: &Value) -> std::result::Result<Value, String> {
|
||||
self.export_params_or(p, crate::export::ExportFormat::default())
|
||||
}
|
||||
|
||||
/// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format
|
||||
/// (see [`crate::export::ExportOptions::export_format`]).
|
||||
pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result<Value, String> {
|
||||
let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) };
|
||||
let (preset, _) = self
|
||||
.all_export_presets()
|
||||
@@ -24,12 +30,17 @@ impl Session {
|
||||
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
|
||||
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
|
||||
let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
|
||||
let mut out = crate::export::ExportOptions::known_keys(&preset.params);
|
||||
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
|
||||
o.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
// the preset's depth is judged against the format written, known only now (`path`); a
|
||||
// depth the call passes itself is the caller's, and validated as such
|
||||
if !p.get("bitDepth").is_some_and(|d| !d.is_null()) {
|
||||
crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut params = match p.get("params") {
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")),
|
||||
None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?,
|
||||
// a stored setting, not the caller's typo: a depth it holds that its `format` lacks was
|
||||
// written before #1952 and is dropped, as Export with Previous does; without `format`
|
||||
// it is judged when the preset exports
|
||||
None => {
|
||||
let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?;
|
||||
let mut last = crate::export::ExportOptions::known_keys(last);
|
||||
if last.get("format").is_some_and(Value::is_string) {
|
||||
crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default());
|
||||
}
|
||||
last
|
||||
}
|
||||
};
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
|
||||
}
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen
|
||||
crate::export::ExportOptions::validate(ID, ¶ms)?;
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen; without
|
||||
// `format`, its `bitDepth` is judged when it exports, against the format written then
|
||||
let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default()));
|
||||
crate::export::ExportOptions::validate_as(ID, ¶ms, format)?;
|
||||
let preset = ExportPreset { name: name.to_string(), params };
|
||||
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = preset,
|
||||
|
||||
+107
-3
@@ -816,8 +816,17 @@ impl ExportOptions {
|
||||
/// newer version may have written and this one doesn't know dropped, also inside `watermark`.
|
||||
/// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are
|
||||
/// not the caller's typo, and refusing them would make a preset or Export with Previous fail
|
||||
/// for good after an update.
|
||||
/// for good after an update. A `bitDepth` the stored format doesn't have (written at the
|
||||
/// format's own depth before issue #1952) is dropped too.
|
||||
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = Self::known_keys(p);
|
||||
Self::drop_unfit_depth(&mut p, ExportFormat::default());
|
||||
p
|
||||
}
|
||||
|
||||
/// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be
|
||||
/// known once the call's own params (`path`) are merged in.
|
||||
pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str()));
|
||||
@@ -828,13 +837,44 @@ impl ExportOptions {
|
||||
p
|
||||
}
|
||||
|
||||
/// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with
|
||||
/// `fallback`) doesn't have: it was written at the format's own depth before issue #1952.
|
||||
pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) {
|
||||
let format = Self::export_format(p, fallback);
|
||||
if let Some(o) = p.as_object_mut()
|
||||
&& let Some(depth) = o.get("bitDepth").and_then(whole)
|
||||
&& !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth)
|
||||
{
|
||||
o.remove("bitDepth");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an
|
||||
/// unknown parameter or a value that cannot be read as what it is for is an error, never a
|
||||
/// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`)
|
||||
/// are accepted and left to the caller.
|
||||
pub fn from_params(p: &serde_json::Value) -> crate::Result<Self> {
|
||||
Self::validate("app.export", p)?;
|
||||
Ok(Self::from_json(p))
|
||||
Self::from_params_or(p, ExportFormat::default())
|
||||
}
|
||||
|
||||
/// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no
|
||||
/// format; the format returned is [`ExportOptions::export_format`], the one written.
|
||||
pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result<Self> {
|
||||
let format = Self::export_format(p, fallback);
|
||||
Self::validate_as("app.export", p, Some(format))?;
|
||||
Ok(Self { format, ..Self::from_json(p) })
|
||||
}
|
||||
|
||||
/// The format `app.export` writes: `format` (`null`: the default), else the one the extension of
|
||||
/// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there,
|
||||
/// the headless backend PNG), else the default.
|
||||
pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat {
|
||||
use serde_json::Value;
|
||||
match (p.get("format"), p.get("path").and_then(Value::as_str)) {
|
||||
(Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(),
|
||||
(None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback),
|
||||
(None, None) => ExportFormat::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
|
||||
@@ -842,6 +882,12 @@ impl ExportOptions {
|
||||
/// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`]
|
||||
/// with its sizes in range (issue #183). `null` means "not given", as a dropped key does.
|
||||
pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> {
|
||||
Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default())))
|
||||
}
|
||||
|
||||
/// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported
|
||||
/// (`None`: not known yet, as for a preset without `format`).
|
||||
pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option<ExportFormat>) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) };
|
||||
@@ -949,6 +995,18 @@ impl ExportOptions {
|
||||
_ => return Err(bad(unknown_key("parameter", k, &known))),
|
||||
}
|
||||
}
|
||||
// issue #1952: a depth the format doesn't have would be written at the format's own depth
|
||||
if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) {
|
||||
let mut depths: Vec<u8> = Self::bit_depths(format).iter().map(|d| d.0).collect();
|
||||
if !depths.iter().any(|d| i64::from(*d) == depth) {
|
||||
depths.sort_unstable();
|
||||
let mut list: Vec<String> = depths.iter().map(u8::to_string).collect();
|
||||
let last = list.pop().unwrap_or_default();
|
||||
let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) };
|
||||
let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
return Err(bad(format!("`bitDepth` must be {list} for {name}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3222,6 +3280,7 @@ mod tests {
|
||||
resize: Some(Resize::default()),
|
||||
limit_kb: Some(500),
|
||||
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
|
||||
format: ExportFormat::Png,
|
||||
bit_depth: Some(16),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -3258,6 +3317,51 @@ mod tests {
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers
|
||||
/// it), not written at the format's own depth; the error names the depths the format takes.
|
||||
#[test]
|
||||
fn bit_depth_must_suit_the_format() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg"));
|
||||
assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png"));
|
||||
assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff"));
|
||||
// no `format`: the one `path` names, else the default (JPEG)
|
||||
assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png"));
|
||||
assert!(err(json!({"bitDepth": 16})).contains("for jpeg"));
|
||||
assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok());
|
||||
// a path naming no format, or a `null` format, exports the default too
|
||||
assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg"));
|
||||
assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg"));
|
||||
assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg"));
|
||||
// or the caller's own format for such a path, which is the one returned
|
||||
let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png);
|
||||
assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png"));
|
||||
assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png);
|
||||
assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff);
|
||||
// stored settings (a preset, Export with Previous) lose such a depth instead of failing
|
||||
let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16}));
|
||||
assert_eq!(stored.get("bitDepth"), None);
|
||||
assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32);
|
||||
for f in [
|
||||
ExportFormat::Jpeg,
|
||||
ExportFormat::Png,
|
||||
ExportFormat::Tiff,
|
||||
ExportFormat::Psd,
|
||||
ExportFormat::Webp,
|
||||
ExportFormat::Avif,
|
||||
ExportFormat::Jxl,
|
||||
ExportFormat::Original,
|
||||
ExportFormat::Dng,
|
||||
ExportFormat::RenderedDng,
|
||||
] {
|
||||
for &(b, _) in ExportOptions::bit_depths(f) {
|
||||
let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() };
|
||||
assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own
|
||||
/// folder; it is checked like the other params and kept in presets and Export with Previous.
|
||||
#[test]
|
||||
|
||||
@@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is
|
||||
/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth
|
||||
/// gets its own, as stored settings do, while a depth the call itself passes is still refused.
|
||||
#[test]
|
||||
fn a_preset_depth_is_judged_against_the_format_written() {
|
||||
use crate::export::ExportPreset;
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) });
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap();
|
||||
let o = ExportOptions::from_params(&p).unwrap();
|
||||
assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16)));
|
||||
assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG");
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap();
|
||||
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None);
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap();
|
||||
assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg"));
|
||||
// such a preset can be saved: its depth is judged when it exports; with a format, at once
|
||||
s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap();
|
||||
assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err());
|
||||
}
|
||||
|
||||
/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A
|
||||
/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does,
|
||||
/// instead of refusing the save; without `format`, the depth waits for the format written.
|
||||
#[test]
|
||||
fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() {
|
||||
let mut s = Session::with_demo();
|
||||
s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"}));
|
||||
s.execute("export.savePreset", &json!({"name": "Old"})).unwrap();
|
||||
assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80}));
|
||||
s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"}));
|
||||
s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap();
|
||||
let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap();
|
||||
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_conflicts_and_subfolders() {
|
||||
use crate::export::{Destination, export_batch};
|
||||
|
||||
@@ -61,16 +61,13 @@ impl Headless {
|
||||
/// With `path` and no `format`, the format follows the path's extension.
|
||||
fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch};
|
||||
let p = &self.session.export_params(p)?;
|
||||
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
let p = &self.session.export_params_or(p, ExportFormat::Png)?;
|
||||
// PNG for a `path` that names no format; `bitDepth` is checked against the format written
|
||||
let mut opts = ExportOptions::from_params_or(p, ExportFormat::Png).map_err(|e| e.to_string())?;
|
||||
if !ExportOptions::has_size_param(p) {
|
||||
opts.resize = Some(Resize::long_edge(3000));
|
||||
}
|
||||
let exact = p.get("path").and_then(Value::as_str);
|
||||
if let (Some(path), None) = (exact, p.get("format")) {
|
||||
let ext = Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
opts.format = ExportFormat::parse(&ext).unwrap_or(ExportFormat::Png);
|
||||
}
|
||||
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
|
||||
// like the desktop app: the selection (in grid order), else the given / active photo
|
||||
|
||||
@@ -147,6 +147,43 @@ fn export_refuses_unknown_params_and_bad_values() {
|
||||
assert!(e.contains("did you mean `quality`"), "{e}");
|
||||
}
|
||||
|
||||
/// Review of #1966: a `path` whose extension names no format exports PNG through the headless
|
||||
/// backend, so its `bitDepth` is checked against PNG's, not let through to an 8-bit file.
|
||||
#[test]
|
||||
fn export_checks_the_depth_of_the_format_a_bare_path_gets() {
|
||||
let mut b = Headless::demo();
|
||||
let base = std::env::temp_dir().join(format!("lc-mcp-bare-path-depth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
for name in ["output", "output.xyz"] {
|
||||
let e = b.call("app.export", json!({"path": base.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10})).unwrap_err();
|
||||
assert!(e.contains("`bitDepth` must be 8 or 16 for png"), "{name}: {e}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 0, "nothing written");
|
||||
// the format such a path gets is still PNG, and 16 bits is one of its depths
|
||||
b.call("app.export", json!({"path": base.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 16})).unwrap();
|
||||
let written = std::fs::read_dir(&base).unwrap().next().unwrap().unwrap().path();
|
||||
let bytes = std::fs::read(&written).unwrap();
|
||||
assert!(bytes.starts_with(b"\x89PNG"), "{written:?} is a PNG");
|
||||
assert_eq!(bytes[24], 16, "{written:?} is 16-bit");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Review of #1966: a preset's `bitDepth` is judged against the format this backend writes, PNG
|
||||
/// for a `path` that names none, so a 16-bit preset without `format` writes a 16-bit PNG.
|
||||
#[test]
|
||||
fn a_preset_depth_is_judged_against_the_format_written() {
|
||||
let mut b = Headless::demo();
|
||||
let base = std::env::temp_dir().join(format!("lc-mcp-preset-depth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
b.session.export_presets.push(lightcraft_engine::export::ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16}) });
|
||||
b.call("app.export", json!({"preset": "Deep", "path": base.join("output").to_string_lossy(), "longEdge": 32})).unwrap();
|
||||
let bytes = std::fs::read(base.join("output")).unwrap();
|
||||
assert!(bytes.starts_with(b"\x89PNG") && bytes[24] == 16, "a 16-bit PNG: {:?}", bytes.get(..25));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped).
|
||||
#[test]
|
||||
fn export_tool_forwards_its_advertised_options() {
|
||||
|
||||
@@ -484,11 +484,8 @@ pub fn default_export_dir() -> String {
|
||||
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{Destination, ExportOptions, export_batch};
|
||||
let p = &app.session.export_params(p)?;
|
||||
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) {
|
||||
let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
|
||||
opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format);
|
||||
}
|
||||
// the format follows `path` (JPEG when it names none), and `bitDepth` is checked against it
|
||||
let opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
|
||||
None if !app.session.selection.ids.is_empty() => {
|
||||
|
||||
@@ -2191,6 +2191,54 @@ mod tests {
|
||||
assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}");
|
||||
}
|
||||
|
||||
/// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made
|
||||
/// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export,
|
||||
/// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept.
|
||||
#[test]
|
||||
fn export_dialog_drops_a_depth_the_format_lacks() {
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let opened = |h: &mut Headless, last: Value| {
|
||||
h.app.ui.dialog = None;
|
||||
h.app.session.last_export = Some(last);
|
||||
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
|
||||
match &h.app.ui.dialog {
|
||||
Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(),
|
||||
_ => panic!("the export dialog is open"),
|
||||
}
|
||||
};
|
||||
let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16}));
|
||||
assert_eq!(o.bit_depth, None);
|
||||
lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export");
|
||||
assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32));
|
||||
}
|
||||
|
||||
/// Review of #1966: a `path` whose extension names no format exports JPEG here, so its
|
||||
/// `bitDepth` is checked against JPEG's, not let through to an 8-bit file.
|
||||
#[test]
|
||||
fn export_checks_the_depth_of_the_format_a_bare_path_gets() {
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes)));
|
||||
for name in ["output", "output.xyz"] {
|
||||
let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert_eq!(r["ok"], false, "{name}: {r}");
|
||||
assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written");
|
||||
// the format such a path gets is still JPEG
|
||||
let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path();
|
||||
assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG");
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dialog_runs_in_the_background() {
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
|
||||
@@ -1410,7 +1410,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
let prev = app.session.last_export.clone().unwrap_or_default();
|
||||
let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d);
|
||||
let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir);
|
||||
let opts = lightcraft_engine::export::ExportOptions::from_json(&prev);
|
||||
// stored settings: a depth the format lacks (saved before #1952) opens at the format's own
|
||||
let opts = lightcraft_engine::export::ExportOptions::from_json(&lightcraft_engine::export::ExportOptions::known_keys_only(&prev));
|
||||
// no previous export: 2048 px long edge; a previous full-size export: full size
|
||||
let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev);
|
||||
let resize = opts.resize.unwrap_or_default();
|
||||
|
||||
Reference in New Issue
Block a user