From ffd737ceab2fac72de88470124274cf1ce5559e6 Mon Sep 17 00:00:00 2001 From: john01px2022 <121329615+john01px2022@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:11:03 -0700 Subject: [PATCH] LR-EXP-WATERMARK: separate horizontal and vertical watermark insets (#827) Watermark.inset becomes inset_x (from the left or right edge) and inset_y (from the top or bottom edge), like Lightroom Classic's watermark editor. The watermark keys are insetX and insetY; the old inset key still reads as both, so saved presets and existing MCP calls keep working. The Export dialog's Inset slider becomes Horizontal inset and Vertical inset, each greyed out when the position is centred along its axis. cargo fmt reflows WM_POSITIONS and its test from the previous commits. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/contact_sheet.rs | 9 +- crates/engine/src/export.rs | 133 ++++++++++++++++++++------- crates/mcp/src/headless.rs | 2 +- crates/mcp/src/tools.rs | 2 +- crates/ui-egui/locales/de.json | 2 + crates/ui-egui/locales/es.json | 2 + crates/ui-egui/locales/fr.json | 2 + crates/ui-egui/locales/ja.json | 2 + crates/ui-egui/locales/pt-br.json | 2 + crates/ui-egui/locales/ru.json | 2 + crates/ui-egui/locales/uk.json | 2 + crates/ui-egui/locales/zh-hans.json | 2 + crates/ui-egui/locales/zh-hant.json | 2 + crates/ui-egui/src/panels/dialogs.rs | 36 +++++--- docs/parity-checklist.md | 2 +- docs/vertical-watermarks.md | 2 +- 16 files changed, 154 insertions(+), 50 deletions(-) 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