diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index 663d6f826..e83ad8c67 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -138,7 +138,8 @@ impl Prepared { size: 0.5, opacity: 1.0, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, color: [32; 3], shadow: false, ..Default::default() @@ -305,7 +306,8 @@ mod tests { size: 0.5, opacity: 1.0, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, color: [32; 3], shadow: false, ..Default::default() @@ -320,7 +322,8 @@ mod tests { size: 0.5, opacity: 1.0, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, color: [32; 3], shadow: false, ..Default::default() diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6a9e9147c..1f1f72b5a 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -209,8 +209,12 @@ pub struct Watermark { /// 0..1. pub opacity: f32, pub anchor: Anchor, - /// Margin as a fraction of the short edge. - pub inset: f32, + /// Margin from the left or right edge, as a fraction of the short edge (unused when the anchor + /// is centred across: top, center, bottom). + pub inset_x: f32, + /// Margin from the top or bottom edge, as a fraction of the short edge (unused when the anchor + /// is centred down: left, center, right). + pub inset_y: f32, /// sRGB colour. pub color: [u8; 3], /// Soft dark drop shadow for legibility on bright areas. @@ -232,7 +236,8 @@ impl Default for Watermark { size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, - inset: 0.025, + inset_x: 0.025, + inset_y: 0.025, color: [255; 3], shadow: true, image: String::new(), @@ -438,18 +443,18 @@ fn watermark_coverage( } } let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; - let inset = wm.inset.clamp(0.0, 0.4) * short; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); let (w, h) = (width as f32, height as f32); use Anchor::*; let ox = match wm.anchor { - TopLeft | Left | BottomLeft => inset, + TopLeft | Left | BottomLeft => ix, Top | Center | Bottom => (w - tw) / 2.0, - TopRight | Right | BottomRight => w - inset - tw, + TopRight | Right | BottomRight => w - ix - tw, }; let oy = match wm.anchor { - TopLeft | Top | TopRight => inset, + TopLeft | Top | TopRight => iy, Left | Center | Right => (h - th) / 2.0, - BottomLeft | Bottom | BottomRight => h - inset - th, + BottomLeft | Bottom | BottomRight => h - iy - th, }; let alpha = wm.opacity.clamp(0.0, 1.0); let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { @@ -514,19 +519,20 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] }); let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); - let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32; + let short = width.min(height) as f32; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32); use Anchor::*; let ox = match wm.anchor { - TopLeft | Left | BottomLeft => inset, + TopLeft | Left | BottomLeft => ix, Top | Center | Bottom => (w - lwf) / 2.0, - TopRight | Right | BottomRight => w - inset - lwf, + TopRight | Right | BottomRight => w - ix - lwf, } .max(0.0) as usize; let oy = match wm.anchor { - TopLeft | Top | TopRight => inset, + TopLeft | Top | TopRight => iy, Left | Center | Right => (h - lhf) / 2.0, - BottomLeft | Bottom | BottomRight => h - inset - lhf, + BottomLeft | Bottom | BottomRight => h - iy - lhf, } .max(0.0) as usize; let space = wm.target.unwrap_or(OutputSpace::Srgb); @@ -667,8 +673,10 @@ pub const OPTION_PARAMS: &[&str] = &[ "hdr", ]; -/// The keys of a `watermark` object ([`Watermark`], camelCase). -pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"]; +/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY` +/// together (what versions before the two margins wrote); either of those, when given, wins. +pub const WATERMARK_PARAMS: &[&str] = + &["text", "vertical", "size", "opacity", "anchor", "insetX", "insetY", "inset", "color", "shadow", "image", "imageWidth"]; /// Text height as a fraction of the short edge: the range the renderer draws without clamping. pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); @@ -710,6 +718,19 @@ fn whole(v: &serde_json::Value) -> Option { v.as_i64() } +/// A `watermark` object as a [`Watermark`]; a legacy `inset` fills whichever of `insetX` and +/// `insetY` is missing. +fn watermark_from_json(v: &serde_json::Value) -> serde_json::Result { + let mut v = v.clone(); + if let Some(o) = v.as_object_mut() + && let Some(inset) = o.remove("inset") + { + o.entry("insetX").or_insert_with(|| inset.clone()); + o.entry("insetY").or_insert(inset); + } + serde_json::from_value(v) +} + impl ExportOptions { /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or /// newer version may have written and this one doesn't know dropped, also inside `watermark`. @@ -841,7 +862,7 @@ impl ExportOptions { Value::Object(o) => o, _ => { return Err(bad( - "`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}".into(), + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, color, shadow, image, imageWidth}".into(), )); } }; @@ -864,7 +885,7 @@ impl ExportOptions { } "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, - "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, + "insetX" | "insetY" | "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, "anchor" => { serde_json::from_value::(v.clone()).map_err(|_| { @@ -880,7 +901,7 @@ impl ExportOptions { _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), } } - serde_json::from_value::(v.clone()).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) + watermark_from_json(v).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) } /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, @@ -923,7 +944,7 @@ impl ExportOptions { remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), - Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(), + Some(v @ Value::Object(_)) => watermark_from_json(v).ok(), _ => None, } .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), @@ -1996,6 +2017,40 @@ mod tests { assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } + /// Horizontal and vertical margins move a text watermark separately; settings saved with one + /// `inset` (before the two margins) read as both, and either new key overrides it. + #[test] + fn watermark_insets_are_horizontal_and_vertical() { + // gaps between the drawn text and the right and bottom edges of a 400 × 300 image + let gaps = |wm: &Watermark| { + let mut img = Rgba8::new(400, 300); + draw_watermark(&mut img, wm); + let lit: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); + let right = lit.iter().map(|p| p.0).max().unwrap(); + let bottom = lit.iter().map(|p| p.1).max().unwrap(); + (399 - right as i64, 299 - bottom as i64) + }; + let base = Watermark { text: "LC".into(), size: 0.1, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + let (r0, b0) = gaps(&base); + // 0.2 and 0.1 of the 300 px short edge + let (r1, b1) = gaps(&Watermark { inset_x: 0.2, ..base.clone() }); + assert!((r1 - r0 - 60).abs() <= 1 && (b1 - b0).abs() <= 1, "horizontal: {r0},{b0} → {r1},{b1}"); + let (r2, b2) = gaps(&Watermark { inset_y: 0.1, ..base.clone() }); + assert!((r2 - r0).abs() <= 1 && (b2 - b0 - 30).abs() <= 1, "vertical: {r0},{b0} → {r2},{b2}"); + // the old single margin + let wm = |w: serde_json::Value| ExportOptions::from_json(&json!({"watermark": w})).watermark.unwrap(); + let old = wm(json!({"text": "LC", "inset": 0.1})); + assert_eq!((old.inset_x, old.inset_y), (0.1, 0.1)); + let mixed = wm(json!({"text": "LC", "inset": 0.1, "insetY": 0.3})); + assert_eq!((mixed.inset_x, mixed.inset_y), (0.1, 0.3)); + assert!(ExportOptions::from_params(&json!({"watermark": {"text": "LC", "inset": 0.1}})).is_ok()); + // saved and read back as the two margins + let o = ExportOptions { watermark: Some(Watermark { inset_x: 0.05, inset_y: 0.15, ..base }), ..Default::default() }; + let saved = o.to_json(); + assert!(saved["watermark"].get("inset").is_none(), "{saved}"); + assert_eq!(ExportOptions::from_json(&saved).watermark.map(|w| (w.inset_x, w.inset_y)), Some((0.05, 0.15))); + } + fn vertical_japanese() -> Watermark { let options = ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); @@ -2050,7 +2105,8 @@ mod tests { vertical: true, size: 0.1, anchor: Anchor::TopLeft, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, shadow: false, opacity: 1.0, ..Default::default() @@ -2075,7 +2131,8 @@ mod tests { vertical: true, size: 0.1, anchor: Anchor::Center, - inset: 0.0, + inset_x: 0.0, + inset_y: 0.0, shadow: false, opacity: 1.0, ..Default::default() @@ -2137,7 +2194,7 @@ mod tests { let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); assert!(h > w * 2, "vertical Japanese must extend down the column"); - let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset: 0.0, ..wm }; + let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset_x: 0.0, inset_y: 0.0, ..wm }; img.data.fill([0, 0, 0, 255]); draw_watermark(&mut img, &two); for (text, dx) in [("本", 0), ("日", 30)] { @@ -2163,13 +2220,27 @@ mod tests { std::fs::write(&logo_path, png).unwrap(); let mut img = Rgba8::new(400, 300); img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() }; + let wm = Watermark { + image: logo_path.to_string_lossy().into(), + image_width: 0.25, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; draw_watermark(&mut img, &wm); // 100 px wide × 50 px high in the bottom-right corner assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo"); assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo"); assert_eq!(img.get(10, 10), [0, 0, 0, 255]); + // 30 px from the right edge and 15 px from the bottom: x 270..370, y 235..285 + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + draw_watermark(&mut img, &Watermark { inset_x: 0.1, inset_y: 0.05, ..wm.clone() }); + assert!(img.get(272, 283)[0] > 200, "inside the moved logo: {:?}", img.get(272, 283)); + assert_eq!(img.get(267, 283), [0, 0, 0, 255], "left of the logo"); + assert_eq!(img.get(272, 288), [0, 0, 0, 255], "below the logo"); // an image-only watermark survives the params (no text needed) let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}})); assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1)); @@ -2508,14 +2579,12 @@ mod tests { fn watermark_params_are_validated() { use serde_json::json; let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); - assert!( - err(json!(5)) - .contains("`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}") - ); - assert!( - err(json!({"text": "x", "bogus": 1})) - .contains("unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth)") - ); + assert!(err(json!(5)).contains( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, color, shadow, image, imageWidth}" + )); + assert!(err(json!({"text": "x", "bogus": 1})).contains( + "unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, insetX, insetY, inset, color, shadow, image, imageWidth)" + )); assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); for size in [json!(3), json!(0.5001), json!(0), json!("big")] { let e = err(json!({"text": "x", "size": size})); @@ -2523,6 +2592,8 @@ mod tests { } assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetX": 0.5})).contains("`watermark.insetX` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetY": "far"})).contains("`watermark.insetY` must be a number 0..0.4")); assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index acbecb60b..2c9497d84 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -131,7 +131,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index dd52da402..16b9699b9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -259,7 +259,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, - "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} + "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], ), diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa24e0f10..28c259a24 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -657,6 +657,7 @@ "History & Activity (Y)": "Verlauf und Aktivität (Y)", "Home": "Persönlicher Ordner", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Horizontaler Abstand (% der kurzen Kante)", "Horizontal text": "Horizontaler Text", "Hours": "Stunden", "Hue": "Farbton", @@ -1562,6 +1563,7 @@ "Versions": "Versionen", "Versions (Shift+V)": "Versionen (Umschalt+V)", "Vertical": "Vertikal", + "Vertical inset (% of short edge)": "Vertikaler Abstand (% der kurzen Kante)", "Vertical text": "Vertikaler Text", "Vibrance": "Dynamik", "Videos": "Videos", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c4f2e8357..9f02b8190 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -624,6 +624,7 @@ "History & Activity (Y)": "Historial y actividad (Y)", "Home": "Carpeta personal", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Margen horizontal (% del lado corto)", "Horizontal text": "Texto horizontal", "Hours": "Horas", "How to install the model": "Cómo instalar el modelo", @@ -1501,6 +1502,7 @@ "Versions": "Versiones", "Versions (Shift+V)": "Versiones (Mayús+V)", "Vertical": "Vertical", + "Vertical inset (% of short edge)": "Margen vertical (% del lado corto)", "Vertical text": "Texto vertical", "Vibrance": "Intensidad", "Videos": "Vídeos", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f3422bc0a..86ed164c2 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -624,6 +624,7 @@ "History & Activity (Y)": "Historique et activité (Y)", "Home": "Dossier personnel", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Marge horizontale (% du petit côté)", "Horizontal text": "Texte horizontal", "Hours": "Heures", "How to install the model": "Comment installer le modèle", @@ -1499,6 +1500,7 @@ "Versions": "Versions", "Versions (Shift+V)": "Versions (Maj+V)", "Vertical": "Vertical", + "Vertical inset (% of short edge)": "Marge verticale (% du petit côté)", "Vertical text": "Texte vertical", "Vibrance": "Vibrance", "Videos": "Vidéos", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a7a3a32ef..20553442f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -507,6 +507,7 @@ "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", "Home": "ホーム", + "Horizontal inset (% of short edge)": "水平方向の余白(短辺に対する%)", "Hours": "時間", "ISO Speed": "ISO感度", "ISO speed": "ISO感度", @@ -1421,6 +1422,7 @@ "Versions": "バージョン", "Versions (Shift+V)": "バージョン(⇧V)", "Vertical": "垂直", + "Vertical inset (% of short edge)": "垂直方向の余白(短辺に対する%)", "Vibrance": "自然な彩度", "Videos": "動画", "View": "表示", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c3d9a8d78..1a8619599 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -630,6 +630,7 @@ "History & Activity (Y)": "Histórico e atividade (Y)", "Home": "Pasta pessoal", "Horizontal": "Horizontal", + "Horizontal inset (% of short edge)": "Margem horizontal (% da aresta curta)", "Horizontal text": "Texto horizontal", "Hours": "Horas", "How to install the model": "Como instalar o modelo", @@ -1512,6 +1513,7 @@ "Versions": "Versões", "Versions (Shift+V)": "Versões (Shift+V)", "Vertical": "Vertical", + "Vertical inset (% of short edge)": "Margem vertical (% da aresta curta)", "Vertical text": "Texto vertical", "Vibrance": "Intensidade", "Videos": "Vídeos", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf9974740..4877e3cc0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -519,6 +519,7 @@ "History": "История", "History & Activity (Y)": "История и активность (Y)", "Horizontal": "По горизонтали", + "Horizontal inset (% of short edge)": "Отступ по горизонтали (% от короткой стороны)", "Horizontal text": "Горизонтальный текст", "How to install the model": "Как установить модель", "Hue": "Тон", @@ -1258,6 +1259,7 @@ "Versions": "Версии", "Versions (Shift+V)": "Версии (Shift+V)", "Vertical": "По вертикали", + "Vertical inset (% of short edge)": "Отступ по вертикали (% от короткой стороны)", "Vertical text": "Вертикальный текст", "Vibrance": "Красочность", "Videos": "Видео", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c73c05b93..49bd10120 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -97,6 +97,7 @@ "Grab bag": "Випадкова добірка", "Group name": "Назва групи", "Home": "Домашня тека", + "Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)", "ISO Speed": "Чутливість ISO", "Imported": "Імпортовані", "Join the ArtCraft Discord": "Приєднатися до Discord ArtCraft", @@ -294,6 +295,7 @@ "Variance": "Дисперсія", "Version": "Версія", "Vertical": "Вертикальна", + "Vertical inset (% of short edge)": "Відступ по вертикалі (% короткого краю)", "Vibrance": "Соковитість", "View": "Вигляд", "Vignetting": "Віньєтування", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 21fa4e4e9..7832f4856 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -513,6 +513,7 @@ "Has Edits": "有编辑", "Has GPS": "有 GPS", "Home": "主文件夹", + "Horizontal inset (% of short edge)": "水平边距 (占短边 %)", "Hours": "小时", "ISO Speed": "ISO 感光度", "ISO speed": "ISO 感光度", @@ -1495,6 +1496,7 @@ "Versions": "版本", "Versions (Shift+V)": "版本 (Shift+V)", "Vertical": "垂直", + "Vertical inset (% of short edge)": "垂直边距 (占短边 %)", "Vertical text": "竖排文字", "Vibrance": "鲜艳度", "Videos": "视频", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d2e0a30c2..578d7dece 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -513,6 +513,7 @@ "Has Edits": "有編輯", "Has GPS": "有 GPS", "Home": "個人資料夾", + "Horizontal inset (% of short edge)": "水平邊距(短邊百分比)", "Hours": "小時", "ISO Speed": "ISO 感光度", "ISO speed": "ISO 感光度", @@ -1495,6 +1496,7 @@ "Versions": "版本", "Versions (Shift+V)": "版本(Shift+V)", "Vertical": "垂直", + "Vertical inset (% of short edge)": "垂直邊距(短邊百分比)", "Vertical text": "垂直文字", "Vibrance": "自然飽和度", "Videos": "影片", diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8db84a83d..45723eb50 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -677,9 +677,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if num(ui, &WM_OPACITY, &mut op) { wm.opacity = (op / 100.0) as f32; } - let mut inset = wm.inset as f64 * 100.0; - if num(ui, &WM_INSET, &mut inset) { - wm.inset = (inset / 100.0) as f32; + // like Lightroom Classic: a margin from the side edges and one from the top and bottom + // edges, each unused (greyed) when the position is centred along that axis + use lightcraft_engine::export::Anchor as P; + let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { + wm.inset_x = (v / 100.0) as f32; + } + let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { + wm.inset_y = (v / 100.0) as f32; } if !graphic { ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); @@ -1516,17 +1523,16 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex /// Keep them in grid order so bottom-centre and edge-centre anchors are available in Export. const WM_POSITIONS: [(lightcraft_engine::export::Anchor, &str); 9] = { use lightcraft_engine::export::Anchor::*; - [ - (TopLeft, "↖"), (Top, "↑"), (TopRight, "↗"), - (Left, "←"), (Center, "•"), (Right, "→"), - (BottomLeft, "↙"), (Bottom, "↓"), (BottomRight, "↘"), - ] + [(TopLeft, "↖"), (Top, "↑"), (TopRight, "↗"), (Left, "←"), (Center, "•"), (Right, "→"), (BottomLeft, "↙"), (Bottom, "↓"), (BottomRight, "↘")] }; const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0); const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); -const WM_INSET: ControlSpec = ControlSpec { decimals: 1, ..spec("export.watermarkInset", "Inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; +const WM_INSET_X: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetX", "Horizontal inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; +const WM_INSET_Y: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetY", "Vertical inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; /// A themed slider row editing `v`; true when it changed. fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { @@ -1602,9 +1608,15 @@ mod watermark_position_tests { #[test] fn export_dialog_offers_every_rendered_anchor_exactly_once() { let anchors = [ - Anchor::TopLeft, Anchor::Top, Anchor::TopRight, - Anchor::Left, Anchor::Center, Anchor::Right, - Anchor::BottomLeft, Anchor::Bottom, Anchor::BottomRight, + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, ]; assert_eq!(WM_POSITIONS.len(), anchors.len()); for anchor in anchors { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..8ff325740 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -391,7 +391,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) | +| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | diff --git a/docs/vertical-watermarks.md b/docs/vertical-watermarks.md index bd10516c1..c242b5633 100644 --- a/docs/vertical-watermarks.md +++ b/docs/vertical-watermarks.md @@ -1,6 +1,6 @@ # Vertical text watermarks -Set `watermark.vertical` to `true` in export options, or choose Vertical in the export dialog. Text runs down each column; a newline starts the next column to its left. The size, nine anchors, inset, opacity, colour and shadow use the same controls as horizontal watermarks. Existing presets without `vertical` remain horizontal. +Set `watermark.vertical` to `true` in export options, or choose Vertical in the export dialog. Text runs down each column; a newline starts the next column to its left. The size, nine anchors, horizontal and vertical insets, opacity, colour and shadow use the same controls as horizontal watermarks. Existing presets without `vertical` remain horizontal. ## Character cells