mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 16:31:32 -06:00
Merge PR #845
This commit is contained in:
@@ -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"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "表示",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Видео",
|
||||
|
||||
@@ -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": "Віньєтування",
|
||||
|
||||
@@ -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": "视频",
|
||||
|
||||
@@ -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": "影片",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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