This commit is contained in:
Claude
2026-10-11 11:41:21 +00:00
16 changed files with 154 additions and 50 deletions
+6 -3
View File
@@ -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
View File
@@ -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]"));
+1 -1
View File
@@ -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))
}
+1 -1
View File
@@ -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"}
}),
&[],
),
+2
View File
@@ -660,6 +660,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",
@@ -1570,6 +1571,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",
+2
View File
@@ -627,6 +627,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",
@@ -1509,6 +1510,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",
+2
View File
@@ -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",
+2
View File
@@ -510,6 +510,7 @@
"Has GPS": "GPS情報",
"High Contrast B&W": "ハイコントラスト白黒",
"Home": "ホーム",
"Horizontal inset (% of short edge)": "水平方向の余白(短辺に対する%)",
"Hours": "時間",
"ISO Speed": "ISO感度",
"ISO speed": "ISO感度",
@@ -1429,6 +1430,7 @@
"Versions": "バージョン",
"Versions (Shift+V)": "バージョン(⇧V)",
"Vertical": "垂直",
"Vertical inset (% of short edge)": "垂直方向の余白(短辺に対する%)",
"Vibrance": "自然な彩度",
"Videos": "動画",
"View": "表示",
+2
View File
@@ -633,6 +633,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",
@@ -1520,6 +1521,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",
+2
View File
@@ -522,6 +522,7 @@
"History": "История",
"History & Activity (Y)": "История и активность (Y)",
"Horizontal": "По горизонтали",
"Horizontal inset (% of short edge)": "Отступ по горизонтали (% от короткой стороны)",
"Horizontal text": "Горизонтальный текст",
"How to install the model": "Как установить модель",
"Hue": "Тон",
@@ -1266,6 +1267,7 @@
"Versions": "Версии",
"Versions (Shift+V)": "Версии (Shift+V)",
"Vertical": "По вертикали",
"Vertical inset (% of short edge)": "Отступ по вертикали (% от короткой стороны)",
"Vertical text": "Вертикальный текст",
"Vibrance": "Красочность",
"Videos": "Видео",
+2
View File
@@ -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": "Віньєтування",
+2
View File
@@ -516,6 +516,7 @@
"Has Edits": "有编辑",
"Has GPS": "有 GPS",
"Home": "主文件夹",
"Horizontal inset (% of short edge)": "水平边距 (占短边 %)",
"Hours": "小时",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
@@ -1503,6 +1504,7 @@
"Versions": "版本",
"Versions (Shift+V)": "版本 (Shift+V)",
"Vertical": "垂直",
"Vertical inset (% of short edge)": "垂直边距 (占短边 %)",
"Vertical text": "竖排文字",
"Vibrance": "鲜艳度",
"Videos": "视频",
+2
View File
@@ -516,6 +516,7 @@
"Has Edits": "有編輯",
"Has GPS": "有 GPS",
"Home": "個人資料夾",
"Horizontal inset (% of short edge)": "水平邊距(短邊百分比)",
"Hours": "小時",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
@@ -1503,6 +1504,7 @@
"Versions": "版本",
"Versions (Shift+V)": "版本(Shift+V)",
"Vertical": "垂直",
"Vertical inset (% of short edge)": "垂直邊距(短邊百分比)",
"Vertical text": "垂直文字",
"Vibrance": "自然飽和度",
"Videos": "影片",
+24 -12
View File
@@ -702,9 +702,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"));
@@ -1547,17 +1554,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 {
@@ -1639,9 +1645,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 {
+1 -1
View File
@@ -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 -1
View File
@@ -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