mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
ba7df9c7fe
commit
ffd737ceab
16 files changed
+154
-50
No files matched your search
@@ -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()
|
||||
|
||||
+102
-31
@@ -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<i64> {
|
||||
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<Watermark> {
|
||||
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::<Anchor>(v.clone()).map_err(|_| {
|
||||
@@ -880,7 +901,7 @@ impl ExportOptions {
|
||||
_ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))),
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<Watermark>(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]"));
|
||||
|
||||
@@ -131,7 +131,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = 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))
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"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"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "表示",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Видео",
|
||||
|
||||
@@ -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": "Віньєтування",
|
||||
|
||||
@@ -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": "视频",
|
||||
|
||||
@@ -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": "影片",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user