Files
lightcraft/crates/engine/src/slide_overlay.rs
T
skylar taylor-barrickandClaude e6587c3f82 chore: clear the rustc 1.96 clippy lints that fail cargo xtask ci
Unrelated to the #2135 security fix in the commit before this one: both lines
predate this branch and are rustc/clippy 1.96 lint churn. `cargo clippy
--workspace --all-targets` only warns about them, but `cargo xtask ci` runs it
with `-D warnings` (xtask/src/main.rs), so the gate fails before any test runs.

- `clippy::nonminimal_bool` in `slide_overlay::shadow` (reported twice, two
  overlapping suggestions on the one expression): `!(A && B) || !C || !D` is
  `!(A && B && C && D)` by De Morgan, which is what the line now says. The
  operands are pure float comparisons and `is_finite` guards with no side
  effects, so this is an exact rewrite — a NaN or infinite `mx0`/`my0` still
  makes `shadow` return without drawing, as before. (Clippy's second suggestion
  drops the `my0.is_finite()` term and is *not* equivalent; it is not used.)
- `clippy::drop_non_drop` on `drop(write)` in
  `same_folder_as_original_refuses_photos_without_a_folder`. The line is not
  dead: `write` is a closure holding a mutable borrow of `written`, and the
  `drop` ended that borrow so the next assertion could read it. The closure now
  lives in a block of its own instead, which ends the borrow at the brace
  rather than relying on `drop` of a non-`Drop` type. Every assertion of the
  test is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 10:33:47 -06:00

1345 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Slideshow overlays (Lightroom Classic's Slideshow ▸ Overlays panel): the identity plate (text
//! or an image file), the photo's rating as stars, text overlays with tokens (file name, title,
//! caption, capture date, exposure, camera, lens, rating…) and the export watermark.
//! [`OverlaySettings`] is a library preference (`prefs.json`), edited with the
//! `slideshow.overlays` command; [`SlideOverlays`] is those settings resolved for one photo (its
//! texts expanded, its rating) and drawn onto the composed slide.
//!
//! **Where the drawing lives:** text needs the fonts (Inter plus the craft-fonts CJK fallbacks,
//! [`crate::text`]) and tokens need the catalog's photo, both in the engine, so overlays are a
//! second pass in the engine over `lightcraft_slideshow::compose_with`'s slide (its `behind` hook
//! draws an identity plate set to "render behind image" before the photo). The composer stays a
//! layout-and-pixels crate with no fonts and no catalog.
//!
//! **Resolution independence:** sizes and offsets are fractions of the slide's shorter side (also
//! for overlays anchored to the photo), so a slide at any size has the same proportions. An
//! overlay is placed at one of nine anchors of the slide or of the photo, moved by its offset
//! (x right, y down) and kept on the slide.
//!
//! **Never crash:** settings are validated by [`OverlaySettings::merged`] and clamped by
//! [`OverlaySettings::sanitized`]; text is cut at [`TEXT_MAX_CHARS`]; an unreadable identity
//! plate image draws nothing (and `slideshow.overlays` reports it); unknown tokens stay as typed
//! (as in rename and export file names) and are reported.
use std::sync::Arc;
use lightcraft_catalog::Photo;
use lightcraft_raster::{Image, Plane, Rgba8};
use lightcraft_slideshow::{Layout, Rect, hex, parse_color};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::export::{Anchor, Watermark};
/// Smallest text, plate and star size (fraction of the slide's shorter side).
pub const SIZE_MIN: f64 = 0.005;
/// Largest text line height.
pub const TEXT_SIZE_MAX: f64 = 0.3;
/// Largest identity plate height.
pub const PLATE_SIZE_MAX: f64 = 0.5;
/// Largest star.
pub const STAR_SIZE_MAX: f64 = 0.15;
/// Largest offset either way (fraction of the shorter side).
pub const OFFSET_MAX: f64 = 1.0;
/// Longest shadow offset and radius (fractions of the shorter side).
pub const SHADOW_OFFSET_MAX: f64 = 0.05;
pub const SHADOW_RADIUS_MAX: f64 = 0.05;
/// Most text overlays.
pub const MAX_TEXTS: usize = 16;
/// Longest overlay text or template (characters; longer is cut).
pub const TEXT_MAX_CHARS: usize = 1000;
/// What an overlay is anchored to.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RelativeTo {
#[default]
Slide,
/// The visible photo.
Photo,
}
impl RelativeTo {
fn id(self) -> &'static str {
match self {
RelativeTo::Slide => "slide",
RelativeTo::Photo => "photo",
}
}
}
/// The nine anchors, in reading order (for pickers and tests).
pub const ANCHORS: [Anchor; 9] = [
Anchor::TopLeft,
Anchor::Top,
Anchor::TopRight,
Anchor::Left,
Anchor::Center,
Anchor::Right,
Anchor::BottomLeft,
Anchor::Bottom,
Anchor::BottomRight,
];
/// An anchor's id (`topLeft`…).
pub fn anchor_id(a: Anchor) -> &'static str {
match a {
Anchor::TopLeft => "topLeft",
Anchor::Top => "top",
Anchor::TopRight => "topRight",
Anchor::Left => "left",
Anchor::Center => "center",
Anchor::Right => "right",
Anchor::BottomLeft => "bottomLeft",
Anchor::Bottom => "bottom",
Anchor::BottomRight => "bottomRight",
}
}
/// Where an overlay goes: an anchor of the slide or the photo, and an offset from it.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Placement {
pub anchor: Anchor,
/// Fractions of the slide's shorter side, x to the right, y down.
pub offset_x: f64,
pub offset_y: f64,
pub relative_to: RelativeTo,
}
impl Default for Placement {
fn default() -> Self {
Placement { anchor: Anchor::BottomRight, offset_x: -0.03, offset_y: -0.03, relative_to: RelativeTo::Slide }
}
}
impl Placement {
fn sanitized(mut self) -> Self {
self.offset_x = clamp_or(self.offset_x, -OFFSET_MAX, OFFSET_MAX, 0.0);
self.offset_y = clamp_or(self.offset_y, -OFFSET_MAX, OFFSET_MAX, 0.0);
self
}
fn json(&self, o: &mut Map<String, Value>) {
o.insert("anchor".into(), json!(anchor_id(self.anchor)));
o.insert("offsetX".into(), json!(self.offset_x));
o.insert("offsetY".into(), json!(self.offset_y));
o.insert("relativeTo".into(), json!(self.relative_to.id()));
}
/// Apply a placement key; `false` when `k` isn't one.
fn merge(&mut self, ctx: &str, k: &str, v: &Value) -> Result<bool, String> {
match k {
"anchor" => {
self.anchor = serde_json::from_value(v.clone()).map_err(|_| {
format!("`{ctx}.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, not {v}")
})?
}
"offsetX" => self.offset_x = number(ctx, k, v)?,
"offsetY" => self.offset_y = number(ctx, k, v)?,
"relativeTo" => {
self.relative_to = match v.as_str() {
Some("slide") => RelativeTo::Slide,
Some("photo") => RelativeTo::Photo,
_ => return Err(format!("`{ctx}.relativeTo` must be \"slide\" or \"photo\", not {v}")),
}
}
_ => return Ok(false),
}
Ok(true)
}
/// The box of a `w × h` overlay on the slide `l`: at the anchor of the slide or the visible
/// photo, moved by the offset, then kept on the slide (when it fits).
pub fn place(&self, l: &Layout, w: f64, h: f64) -> Rect {
let c = match self.relative_to {
RelativeTo::Slide => l.slide,
RelativeTo::Photo => l.visible,
};
let short = l.slide.width().min(l.slide.height());
use Anchor::*;
let x = match self.anchor {
TopLeft | Left | BottomLeft => c.x0,
Top | Center | Bottom => (c.x0 + c.x1 - w) / 2.0,
TopRight | Right | BottomRight => c.x1 - w,
} + self.offset_x * short;
let y = match self.anchor {
TopLeft | Top | TopRight => c.y0,
Left | Center | Right => (c.y0 + c.y1 - h) / 2.0,
BottomLeft | Bottom | BottomRight => c.y1 - h,
} + self.offset_y * short;
let s = l.slide;
let fit = |v: f64, lo: f64, hi: f64, len: f64| if v.is_finite() { v.min(hi - len).max(lo) } else { lo };
let (x, y) = (fit(x, s.x0, s.x1, w), fit(y, s.y0, s.y1, h));
Rect { x0: x, y0: y, x1: x + w, y1: y + h }
}
}
/// A soft drop shadow under text.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextShadow {
pub on: bool,
pub opacity: f64,
/// Fraction of the slide's shorter side.
pub offset: f64,
/// Blur radius, fraction of the slide's shorter side.
pub radius: f64,
/// Degrees counter-clockwise from the right (−45 = down right).
pub angle: f64,
}
impl Default for TextShadow {
fn default() -> Self {
TextShadow { on: false, opacity: 0.6, offset: 0.004, radius: 0.004, angle: -45.0 }
}
}
/// The identity plate: a line of text, or an image file (PNG / JPEG…, its transparency kept).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct IdentityPlate {
pub enabled: bool,
pub text: String,
/// An image file drawn instead of the text (empty = the text).
pub image: String,
/// Height: the text's line height or the image's height, fraction of the shorter side.
pub size: f64,
pub color: [u8; 3],
pub opacity: f64,
pub place: Placement,
/// Draw it before the photo, so the photo covers it where they meet.
pub behind: bool,
}
impl Default for IdentityPlate {
fn default() -> Self {
IdentityPlate {
enabled: false,
text: "LightCraft".into(),
image: String::new(),
size: 0.05,
color: [255, 255, 255],
opacity: 0.8,
place: Placement { anchor: Anchor::TopLeft, offset_x: 0.03, offset_y: 0.03, relative_to: RelativeTo::Slide },
behind: false,
}
}
}
/// The photo's star rating, drawn as stars.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RatingStars {
pub enabled: bool,
pub color: [u8; 3],
/// A star's height, fraction of the shorter side.
pub size: f64,
pub place: Placement,
}
impl Default for RatingStars {
fn default() -> Self {
RatingStars {
enabled: false,
color: [235, 235, 235],
size: 0.03,
place: Placement { anchor: Anchor::TopRight, offset_x: -0.015, offset_y: 0.015, relative_to: RelativeTo::Photo },
}
}
}
/// A text overlay: a template with tokens (`{title}`, `{caption}`, `{exposure}`…; see [`TOKENS`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextOverlay {
pub text: String,
/// Line height, fraction of the shorter side.
pub size: f64,
pub color: [u8; 3],
pub opacity: f64,
pub place: Placement,
pub shadow: TextShadow,
}
impl Default for TextOverlay {
fn default() -> Self {
TextOverlay {
text: "{file}".into(),
size: 0.03,
color: [255, 255, 255],
opacity: 0.9,
place: Placement { anchor: Anchor::Bottom, offset_x: 0.0, offset_y: -0.02, relative_to: RelativeTo::Slide },
shadow: TextShadow::default(),
}
}
}
/// Every overlay of a slide.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OverlaySettings {
pub identity_plate: IdentityPlate,
pub rating_stars: RatingStars,
pub texts: Vec<TextOverlay>,
/// Draw [`Self::watermark`] on the photo.
pub show_watermark: bool,
/// The export watermark's settings (sizes relative to the photo, as in export).
pub watermark: Watermark,
}
/// Tokens text overlays add to the rename tokens ([`crate::rename::TOKENS`]): tag, meaning.
pub const TOKENS: &[(&str, &str)] = &[
("{file}", "File name with its extension"),
("{caption}", "Caption metadata"),
("{copyright}", "Copyright metadata"),
("{exposure}", "Shutter speed and aperture (1/250 s at f/8)"),
("{shutter}", "Shutter speed"),
("{aperture}", "Aperture (f/8)"),
("{focal}", "Focal length (50 mm)"),
("{date}", "Capture date as YYYY-MM-DD (or {date:%d.%m.%Y} …)"),
];
fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 {
if v.is_finite() { v.clamp(lo, hi) } else { fallback }
}
fn wrap_angle(a: f64, fallback: f64) -> f64 {
if !a.is_finite() {
return fallback;
}
let w = (a + 180.0).rem_euclid(360.0) - 180.0;
if w <= -180.0 { 180.0 } else { w }
}
/// `s` cut at [`TEXT_MAX_CHARS`] characters.
fn cut(s: &mut String) {
if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) {
s.truncate(i);
}
}
fn number(ctx: &str, k: &str, v: &Value) -> Result<f64, String> {
match v.as_f64() {
Some(x) if x.is_finite() => Ok(x),
_ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")),
}
}
fn boolean(ctx: &str, k: &str, v: &Value) -> Result<bool, String> {
v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}"))
}
fn string(ctx: &str, k: &str, v: &Value) -> Result<String, String> {
let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.{k}` must be text, not {v}"))?.to_string();
cut(&mut s);
Ok(s)
}
fn colour(ctx: &str, k: &str, v: &Value) -> Result<[u8; 3], String> {
parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}"))
}
fn object<'a>(ctx: &str, v: &'a Value) -> Result<&'a Map<String, Value>, String> {
v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}"))
}
fn unknown(ctx: &str, k: &str, keys: &str) -> String {
format!("unknown key `{ctx}.{k}` ({keys})")
}
impl IdentityPlate {
fn sanitized(mut self) -> Self {
let d = IdentityPlate::default();
cut(&mut self.text);
cut(&mut self.image);
self.size = clamp_or(self.size, SIZE_MIN, PLATE_SIZE_MAX, d.size);
self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity);
self.place = self.place.sanitized();
self
}
fn to_json(&self) -> Value {
let mut o = Map::new();
o.insert("enabled".into(), json!(self.enabled));
o.insert("text".into(), json!(self.text));
o.insert("image".into(), json!(self.image));
o.insert("size".into(), json!(self.size));
o.insert("color".into(), json!(hex(self.color)));
o.insert("opacity".into(), json!(self.opacity));
self.place.json(&mut o);
o.insert("behind".into(), json!(self.behind));
Value::Object(o)
}
fn merge(&mut self, v: &Value) -> Result<(), String> {
const C: &str = "identityPlate";
for (k, v) in object(C, v)? {
if self.place.merge(C, k, v)? {
continue;
}
match k.as_str() {
"enabled" => self.enabled = boolean(C, k, v)?,
"text" => self.text = string(C, k, v)?,
"image" => self.image = string(C, k, v)?.trim().to_string(),
"size" => self.size = number(C, k, v)?,
"color" => self.color = colour(C, k, v)?,
"opacity" => self.opacity = number(C, k, v)?,
"behind" => self.behind = boolean(C, k, v)?,
_ => return Err(unknown(C, k, "enabled, text, image, size, color, opacity, anchor, offsetX, offsetY, relativeTo, behind")),
}
}
Ok(())
}
}
impl RatingStars {
fn sanitized(mut self) -> Self {
self.size = clamp_or(self.size, SIZE_MIN, STAR_SIZE_MAX, RatingStars::default().size);
self.place = self.place.sanitized();
self
}
fn to_json(&self) -> Value {
let mut o = Map::new();
o.insert("enabled".into(), json!(self.enabled));
o.insert("color".into(), json!(hex(self.color)));
o.insert("size".into(), json!(self.size));
self.place.json(&mut o);
Value::Object(o)
}
fn merge(&mut self, v: &Value) -> Result<(), String> {
const C: &str = "ratingStars";
for (k, v) in object(C, v)? {
if self.place.merge(C, k, v)? {
continue;
}
match k.as_str() {
"enabled" => self.enabled = boolean(C, k, v)?,
"color" => self.color = colour(C, k, v)?,
"size" => self.size = number(C, k, v)?,
_ => return Err(unknown(C, k, "enabled, color, size, anchor, offsetX, offsetY, relativeTo")),
}
}
Ok(())
}
}
impl TextOverlay {
fn sanitized(mut self) -> Self {
let d = TextOverlay::default();
cut(&mut self.text);
self.size = clamp_or(self.size, SIZE_MIN, TEXT_SIZE_MAX, d.size);
self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity);
self.place = self.place.sanitized();
let ds = TextShadow::default();
let sh = &mut self.shadow;
sh.opacity = clamp_or(sh.opacity, 0.0, 1.0, ds.opacity);
sh.offset = clamp_or(sh.offset, 0.0, SHADOW_OFFSET_MAX, ds.offset);
sh.radius = clamp_or(sh.radius, 0.0, SHADOW_RADIUS_MAX, ds.radius);
sh.angle = wrap_angle(sh.angle, ds.angle);
self
}
fn to_json(&self) -> Value {
let mut o = Map::new();
o.insert("text".into(), json!(self.text));
o.insert("size".into(), json!(self.size));
o.insert("color".into(), json!(hex(self.color)));
o.insert("opacity".into(), json!(self.opacity));
self.place.json(&mut o);
o.insert("shadow".into(), json!(self.shadow.on));
o.insert("shadowOpacity".into(), json!(self.shadow.opacity));
o.insert("shadowOffset".into(), json!(self.shadow.offset));
o.insert("shadowRadius".into(), json!(self.shadow.radius));
o.insert("shadowAngle".into(), json!(self.shadow.angle));
Value::Object(o)
}
fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> {
for (k, v) in object(ctx, v)? {
if self.place.merge(ctx, k, v)? {
continue;
}
match k.as_str() {
"text" => self.text = string(ctx, k, v)?,
"size" => self.size = number(ctx, k, v)?,
"color" => self.color = colour(ctx, k, v)?,
"opacity" => self.opacity = number(ctx, k, v)?,
"shadow" => self.shadow.on = boolean(ctx, k, v)?,
"shadowOpacity" => self.shadow.opacity = number(ctx, k, v)?,
"shadowOffset" => self.shadow.offset = number(ctx, k, v)?,
"shadowRadius" => self.shadow.radius = number(ctx, k, v)?,
"shadowAngle" => self.shadow.angle = number(ctx, k, v)?,
_ => {
return Err(unknown(
ctx,
k,
"text, size, color, opacity, anchor, offsetX, offsetY, relativeTo, shadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle",
));
}
}
}
Ok(())
}
}
/// The keys [`OverlaySettings::merged`] takes.
const KEYS: &str = "identityPlate, ratingStars, texts, addText, removeText, showWatermark, watermark";
impl OverlaySettings {
/// Every value in range (non-finite numbers take the defaults), at most [`MAX_TEXTS`] texts.
pub fn sanitized(mut self) -> Self {
self.identity_plate = self.identity_plate.sanitized();
self.rating_stars = self.rating_stars.sanitized();
self.texts.truncate(MAX_TEXTS);
self.texts = self.texts.into_iter().map(TextOverlay::sanitized).collect();
let d = Watermark::default();
let w = &mut self.watermark;
let f = |v: f32, lo: f32, hi: f32, fb: f32| if v.is_finite() { v.clamp(lo, hi) } else { fb };
w.size = f(w.size, 0.005, 0.5, d.size);
w.opacity = f(w.opacity, 0.0, 1.0, d.opacity);
w.inset_x = f(w.inset_x, 0.0, 0.4, d.inset_x);
w.inset_y = f(w.inset_y, 0.0, 0.4, d.inset_y);
if !matches!(w.rotation, 0 | 90 | 180 | 270) {
w.rotation = 0;
}
w.image_width = f(w.image_width, 0.01, 1.0, d.image_width);
cut(&mut w.text);
w.target = None;
self
}
/// Does any overlay draw (on some photo)?
pub fn any(&self) -> bool {
let p = &self.identity_plate;
(p.enabled && p.opacity > 0.0 && (!p.text.trim().is_empty() || !p.image.trim().is_empty()))
|| self.rating_stars.enabled
|| self.texts.iter().any(|t| t.opacity > 0.0 && !t.text.trim().is_empty())
|| (self.show_watermark && (!self.watermark.text.trim().is_empty() || !self.watermark.image.trim().is_empty()))
}
pub fn to_json(&self) -> Value {
let wm = serde_json::to_value(&self.watermark).unwrap_or(Value::Null);
json!({
"identityPlate": self.identity_plate.to_json(),
"ratingStars": self.rating_stars.to_json(),
"texts": self.texts.iter().map(TextOverlay::to_json).collect::<Vec<_>>(),
"showWatermark": self.show_watermark,
"watermark": wm,
})
}
/// These settings with the keys `p` names changed, validated and clamped; a wrong type or an
/// unknown key is an error and nothing changes. `texts` replaces the list (each entry merged
/// onto the one at its index, or a new default text); `addText` appends one (an object, `{}`
/// for the default); `removeText` removes the one at an index; `watermark` takes the export
/// watermark's keys (or text alone) merged onto the current one.
pub fn merged(&self, p: &Value) -> Result<Self, String> {
let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) };
let mut s = self.clone();
for (k, v) in obj {
match k.as_str() {
"identityPlate" => s.identity_plate.merge(v)?,
"ratingStars" => s.rating_stars.merge(v)?,
"texts" => {
let list = v.as_array().ok_or_else(|| format!("`texts` must be a list of text overlays, not {v}"))?;
if list.len() > MAX_TEXTS {
return Err(format!("at most {MAX_TEXTS} text overlays, not {}", list.len()));
}
let mut next = Vec::with_capacity(list.len());
for (i, item) in list.iter().enumerate() {
let mut t = s.texts.get(i).cloned().unwrap_or_default();
t.merge(&format!("texts[{i}]"), item)?;
next.push(t);
}
s.texts = next;
}
"addText" => {
if s.texts.len() >= MAX_TEXTS {
return Err(format!("at most {MAX_TEXTS} text overlays"));
}
let mut t = TextOverlay::default();
t.merge("addText", v)?;
s.texts.push(t);
}
"removeText" => {
let i = v.as_u64().ok_or_else(|| format!("`removeText` must be the index of a text overlay, not {v}"))?;
let i = usize::try_from(i).ok().filter(|i| *i < s.texts.len()).ok_or_else(|| format!("no text overlay {i}"))?;
s.texts.remove(i);
}
"showWatermark" => s.show_watermark = boolean("", k, v).map_err(|_| format!("`showWatermark` must be true or false, not {v}"))?,
"watermark" => {
crate::export::ExportOptions::validate_watermark("slideshow.overlays", v).map_err(|e| e.to_string())?;
s.watermark = match v {
Value::String(t) => Watermark { text: t.clone(), ..s.watermark.clone() },
_ => {
let mut cur = serde_json::to_value(&s.watermark).map_err(|e| e.to_string())?;
if let (Some(c), Some(o)) = (cur.as_object_mut(), v.as_object()) {
for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) {
if k == "inset" {
// the legacy single margin sets whichever of the two the patch leaves out
for axis in ["insetX", "insetY"] {
if !o.contains_key(axis) {
c.insert(axis.into(), v.clone());
}
}
} else {
c.insert(k.clone(), v.clone());
}
}
}
serde_json::from_value(cur).map_err(|e| format!("`watermark`: {e}"))?
}
};
}
other => return Err(format!("unknown setting `{other}` ({KEYS})")),
}
}
Ok(s.sanitized())
}
/// A hash of the settings (for render keys). Stable within a run.
pub fn pixel_key(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.clone().sanitized().to_json().to_string().hash(&mut h);
h.finish()
}
/// Problems worth telling the user about: an identity plate image that can't be read, tags in
/// text overlays that aren't tokens (they show as typed).
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
let p = &self.identity_plate;
if p.enabled && !p.image.trim().is_empty() && crate::export::watermark_logo(p.image.trim()).is_none() {
out.push(format!("identity plate image {} can't be read; nothing is drawn", p.image.trim()));
}
for (i, t) in self.texts.iter().enumerate() {
for tag in unknown_tokens(&t.text) {
out.push(format!("text overlay {}: {tag} isn't a token and shows as typed", i + 1));
}
}
out
}
}
/// An exposure as "1/250 s at f/8" (either part alone when the other is unknown).
fn exposure(p: &Photo) -> String {
let parts: Vec<String> = [shutter(p), aperture(p)].into_iter().filter(|s| !s.is_empty()).collect();
parts.join(" at ")
}
fn shutter(p: &Photo) -> String {
let s = p.meta.shutter.trim();
if s.is_empty() || s.ends_with('s') || s.ends_with('"') { s.to_string() } else { format!("{s} s") }
}
fn aperture(p: &Photo) -> String {
p.meta.aperture.filter(|a| a.is_finite() && *a > 0.0).map(|a| format!("f/{a:.1}").replace(".0", "")).unwrap_or_default()
}
/// The overlay tokens beyond the rename ones; `{seq}` has no meaning on a slide and stays as typed.
fn overlay_token(p: &Photo, name: &str, arg: Option<&str>, tag: &str) -> Option<String> {
Some(match name {
"file" => p.file_name.clone(),
"caption" => p.meta.caption.clone(),
"copyright" => p.meta.copyright.clone(),
"exposure" => exposure(p),
"shutter" => shutter(p),
"aperture" => aperture(p),
"focal" => p.meta.focal_mm.filter(|f| f.is_finite() && *f > 0.0).map(|f| format!("{f:.0} mm")).unwrap_or_default(),
"date" if arg.is_none() => crate::rename::expand_tokens("{date:%Y-%m-%d}", p, 1, 1),
"seq" | "n" => format!("{{{tag}}}"),
_ => return None,
})
}
/// `template`'s tokens replaced for `p`: the rename tokens ([`crate::rename::expand_tokens`]) and
/// [`TOKENS`]. Unknown tags stay as typed.
pub fn expand(template: &str, p: &Photo) -> String {
crate::rename::expand_tokens_with(template, p, 1, 1, &|name, arg, tag| overlay_token(p, name, arg, tag))
}
/// The `{…}` tags in `template` that aren't tokens.
pub fn unknown_tokens(template: &str) -> Vec<String> {
let p = crate::rename::sample_photo();
let mut out = Vec::new();
let mut rest = template;
while let Some(i) = rest.find('{') {
let Some(after) = rest.get(i..) else { break };
let Some(j) = after.find('}') else { break };
let Some(tag) = after.get(..j + 1) else { break };
if expand(tag, &p) == tag && !out.iter().any(|t| t == tag) {
out.push(tag.to_string());
}
rest = after.get(j + 1..).unwrap_or("");
}
out
}
/// What an overlay box is, for [`SlideOverlays::boxes`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
IdentityPlate,
RatingStars,
/// The text overlay at this index.
Text(usize),
Watermark,
}
impl Kind {
pub fn id(&self) -> String {
match self {
Kind::IdentityPlate => "identityPlate".into(),
Kind::RatingStars => "ratingStars".into(),
Kind::Text(i) => format!("text{i}"),
Kind::Watermark => "watermark".into(),
}
}
}
/// The overlays of one photo's slide: the settings, its texts expanded, its rating.
#[derive(Clone, Debug, PartialEq)]
pub struct SlideOverlays {
pub settings: OverlaySettings,
/// One per text overlay, tokens replaced.
pub texts: Vec<String>,
pub rating: u8,
}
/// Gap between stars, as a share of a star's size.
const STAR_GAP: f64 = 0.2;
impl SlideOverlays {
/// `settings` resolved for `photo` (`None`: no photo, so no tokens and no rating).
pub fn resolve(settings: &OverlaySettings, photo: Option<&Photo>) -> SlideOverlays {
let settings = settings.clone().sanitized();
let texts = settings.texts.iter().map(|t| photo.map(|p| expand(&t.text, p)).unwrap_or_else(|| t.text.clone())).collect();
SlideOverlays { settings, texts, rating: photo.map_or(0, |p| p.rating.min(5)) }
}
/// What makes the drawn overlays differ (for render keys).
pub fn key(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.settings.pixel_key().hash(&mut h);
self.texts.hash(&mut h);
self.rating.hash(&mut h);
h.finish()
}
fn short(l: &Layout) -> f64 {
l.slide.width().min(l.slide.height()).max(1.0)
}
/// The identity plate's text layout, or its image scaled to its size.
fn plate(&self, l: &Layout) -> Option<PlateContent> {
let p = &self.settings.identity_plate;
if !p.enabled || p.opacity <= 0.0 {
return None;
}
let short = Self::short(l);
let px = (p.size * short).max(1.0);
if !p.image.trim().is_empty() {
let img = crate::export::watermark_logo(p.image.trim())?;
if img.width == 0 || img.height == 0 {
return None;
}
let h = px.round().clamp(1.0, l.slide.height().max(1.0));
let w = (h * img.width as f64 / img.height as f64).round().clamp(1.0, l.slide.width().max(1.0));
return Some(PlateContent::Image(img, w as usize, h as usize));
}
let text = p.text.trim();
crate::text::TextLayout::new(text, px as f32, false, crate::fonts::CRAFT_FONTS).map(PlateContent::Text)
}
fn text_layout(&self, i: usize, l: &Layout) -> Option<crate::text::TextLayout> {
let t = self.settings.texts.get(i)?;
let text = self.texts.get(i)?.trim();
if t.opacity <= 0.0 {
return None;
}
crate::text::TextLayout::new(text, (t.size * Self::short(l)).max(1.0) as f32, false, crate::fonts::CRAFT_FONTS)
}
fn stars_size(&self, l: &Layout) -> Option<(usize, f64)> {
let r = &self.settings.rating_stars;
let n = usize::from(self.rating.min(5));
(r.enabled && n > 0).then(|| (n, (r.size * Self::short(l)).max(2.0)))
}
/// Where each overlay goes on the slide `l` (the ones that draw something).
pub fn boxes(&self, l: &Layout) -> Vec<(Kind, Rect)> {
let mut out = Vec::new();
let s = &self.settings;
if let Some(c) = self.plate(l) {
let (w, h) = c.size();
out.push((Kind::IdentityPlate, s.identity_plate.place.place(l, w, h)));
}
if let Some((n, size)) = self.stars_size(l) {
let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP;
out.push((Kind::RatingStars, s.rating_stars.place.place(l, w, size)));
}
for (i, t) in s.texts.iter().enumerate() {
if let Some(tl) = self.text_layout(i, l) {
out.push((Kind::Text(i), t.place.place(l, tl.width as f64, tl.height as f64)));
}
}
if s.show_watermark && (!s.watermark.text.trim().is_empty() || !s.watermark.image.trim().is_empty()) {
out.push((Kind::Watermark, l.visible));
}
out
}
/// Draw the overlays drawn behind the photo (an identity plate with "render behind image").
pub fn draw_behind(&self, img: &mut Rgba8, l: &Layout) {
if self.settings.identity_plate.behind {
self.draw_plate(img, l);
}
}
/// Draw the overlays over the composed slide: the watermark on the photo, then the identity
/// plate (unless it is behind), the rating stars and the text overlays.
pub fn draw_front(&self, img: &mut Rgba8, l: &Layout) {
let s = &self.settings;
if s.show_watermark {
let (x0, y0, x1, y1) = pixels(&l.visible, img.width, img.height);
if x1 > x0 && y1 > y0 {
crate::export::draw_watermark_in(img, &s.watermark, x0, y0, x1 - x0, y1 - y0);
}
}
if !s.identity_plate.behind {
self.draw_plate(img, l);
}
if let Some((n, size)) = self.stars_size(l) {
let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP;
let b = s.rating_stars.place.place(l, w, size);
for i in 0..n {
let x = b.x0 + i as f64 * size * (1.0 + STAR_GAP);
star(img, x, b.y0, size, s.rating_stars.color);
}
}
for (i, t) in s.texts.iter().enumerate() {
let Some(tl) = self.text_layout(i, l) else { continue };
let b = t.place.place(l, tl.width as f64, tl.height as f64);
let short = Self::short(l);
if t.shadow.on && t.shadow.opacity > 0.0 {
let (d, a) = (t.shadow.offset * short, t.shadow.angle.to_radians());
let (dx, dy) = (d * a.cos(), -d * a.sin());
shadow(img, &tl, b.x0 + dx, b.y0 + dy, t.shadow.radius * short / 2.0, (t.shadow.opacity * t.opacity) as f32);
}
draw_text(img, &tl, b.x0, b.y0, t.color, t.opacity as f32);
}
}
fn draw_plate(&self, img: &mut Rgba8, l: &Layout) {
let p = &self.settings.identity_plate;
let Some(c) = self.plate(l) else { return };
let (w, h) = c.size();
let b = p.place.place(l, w, h);
match c {
PlateContent::Text(tl) => draw_text(img, &tl, b.x0, b.y0, p.color, p.opacity as f32),
PlateContent::Image(src, w, h) => {
if let Some(scaled) = scaled_graphic(p.image.trim(), &src, w, h) {
draw_graphic(img, &scaled, b.x0.round() as i64, b.y0.round() as i64, p.opacity as f32);
}
}
}
}
}
enum PlateContent {
Text(crate::text::TextLayout),
Image(Arc<Rgba8>, usize, usize),
}
impl PlateContent {
fn size(&self) -> (f64, f64) {
match self {
PlateContent::Text(t) => (t.width as f64, t.height as f64),
PlateContent::Image(_, w, h) => (*w as f64, *h as f64),
}
}
}
/// Whole pixel bounds of `r` clipped to `w × h`.
fn pixels(r: &Rect, w: usize, h: usize) -> (usize, usize, usize, usize) {
let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 };
let (x0, y0) = (c(r.x0, w), c(r.y0, h));
(x0, y0, c(r.x1, w).max(x0), c(r.y1, h).max(y0))
}
/// Blend `c` into pixel (`x`, `y`) at `k` (0–1); outside the image does nothing.
fn blend(img: &mut Rgba8, x: i64, y: i64, k: f32, c: [u8; 3]) {
if x < 0 || y < 0 || k <= 0.0 {
return;
}
let (x, y) = (x as usize, y as usize);
if x >= img.width || y >= img.height {
return;
}
let w = img.width;
if let Some(p) = img.data.get_mut(y * w + x) {
let k = k.min(1.0);
for i in 0..3 {
p[i] = (p[i] as f32 + (c[i] as f32 - p[i] as f32) * k).round().clamp(0.0, 255.0) as u8;
}
}
}
fn draw_text(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, c: [u8; 3], opacity: f32) {
let (w, h) = (img.width as f32, img.height as f32);
tl.draw_in(x as f32, y as f32, w, h, |gx, gy, cov| blend(img, i64::from(gx), i64::from(gy), cov * opacity, c));
}
/// A soft black shadow of the text at (`x`, `y`): its coverage blurred by `sigma` pixels. Only
/// the part that can reach the image is rasterised.
fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: f64, opacity: f32) {
let pad = (sigma * 3.0).ceil().clamp(0.0, 512.0);
let (iw, ih) = (img.width as f64, img.height as f64);
let mx0 = (x - pad).floor().max(-pad);
let my0 = (y - pad).floor().max(-pad);
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
return;
}
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
let mut mask = Plane::new(w, h);
tl.draw_in((x - mx0) as f32, (y - my0) as f32, w as f32, h as f32, |gx, gy, cov| {
if gx >= 0
&& gy >= 0
&& (gx as usize) < w
&& (gy as usize) < h
&& let Some(m) = mask.data.get_mut(gy as usize * w + gx as usize)
{
*m = (*m + cov).min(1.0);
}
});
let mask = if sigma > 0.3 { lightcraft_raster::blur::gaussian(&mask, sigma as f32) } else { mask };
let (ox, oy) = (mx0 as i64, my0 as i64);
for (i, k) in mask.data.iter().enumerate() {
if *k > 1e-3 {
blend(img, ox + (i % w) as i64, oy + (i / w) as i64, k * opacity, [0, 0, 0]);
}
}
}
/// The identity plate image scaled to its size, premultiplied: (file, its decoded image, w, h) →
/// pixels, so the slides of a slideshow don't each scale a large logo again.
type ScaledCache = std::sync::Mutex<Option<(String, usize, usize, usize, Arc<Image<[f32; 4]>>)>>;
static SCALED: ScaledCache = std::sync::Mutex::new(None);
/// `src` (decoded from `path`) scaled to `w × h` (premultiplied, so transparent edges stay clean).
fn scaled_graphic(path: &str, src: &Arc<Rgba8>, w: usize, h: usize) -> Option<Arc<Image<[f32; 4]>>> {
if src.width == 0 || src.height == 0 || w == 0 || h == 0 || src.data.len() != src.width * src.height {
return None;
}
let id = Arc::as_ptr(src) as usize;
let mut cache = SCALED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((p, i, cw, ch, img)) = cache.as_ref()
&& (p.as_str(), *i, *cw, *ch) == (path, id, w, h)
{
return Some(img.clone());
}
let pre: Image<[f32; 4]> = Image::from_fn(src.width, src.height, |x, y| {
let p = src.get(x, y);
let a = p[3] as f32 / 255.0;
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
});
let scaled = Arc::new(lightcraft_raster::resample::resize(&pre, w, h, lightcraft_raster::resample::Filter::Mitchell));
*cache = Some((path.to_string(), id, w, h, scaled.clone()));
Some(scaled)
}
/// The scaled graphic at (`x`, `y`), blended with its alpha × `opacity`.
fn draw_graphic(img: &mut Rgba8, scaled: &Image<[f32; 4]>, x: i64, y: i64, opacity: f32) {
for yy in 0..scaled.height {
for xx in 0..scaled.width {
let [r, g, b, a] = scaled.get(xx, yy);
let a = a.clamp(0.0, 1.0);
if a <= 1e-4 {
continue;
}
let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8);
blend(img, x + xx as i64, y + yy as i64, a * opacity, c);
}
}
}
/// The ten points of a five-pointed star in a `size` box at (`x`, `y`), point up.
fn star_points(x: f64, y: f64, size: f64) -> [(f64, f64); 10] {
let r = size / 2.0;
// the star's height is r (1 + cos 36°); centre it vertically in the box
let cy = y + size / 2.0 + r * (1.0 - (36f64).to_radians().cos()) / 2.0;
let cx = x + size / 2.0;
let inner = r * 0.382;
let mut pts = [(0.0, 0.0); 10];
for (i, p) in pts.iter_mut().enumerate() {
let a = (90.0 + 36.0 * i as f64).to_radians();
let rr = if i % 2 == 0 { r } else { inner };
*p = (cx + rr * a.cos(), cy - rr * a.sin());
}
pts
}
/// A filled star: even-odd scanlines, four a pixel row, with exact horizontal coverage.
fn star(img: &mut Rgba8, x: f64, y: f64, size: f64, c: [u8; 3]) {
if !(x.is_finite() && y.is_finite() && size.is_finite() && size > 0.0) {
return;
}
let pts = star_points(x, y, size);
let (x0, y0) = (x.floor() as i64, y.floor() as i64);
let n = size.ceil() as usize + 2;
let mut row = vec![0f32; n];
let mut xs: Vec<f64> = Vec::with_capacity(pts.len());
for py in y0..y0 + n as i64 {
row.fill(0.0);
for s in 0..4 {
let sy = py as f64 + (s as f64 + 0.5) / 4.0;
xs.clear();
for (i, &(xi, yi)) in pts.iter().enumerate() {
let Some(&(xj, yj)) = pts.get((i + pts.len() - 1) % pts.len()) else { continue };
if (yi > sy) != (yj > sy) {
xs.push((xj - xi) * (sy - yi) / (yj - yi) + xi);
}
}
xs.sort_by(f64::total_cmp);
for &[a, b] in xs.as_chunks::<2>().0 {
let (a, b) = (a - x0 as f64, b - x0 as f64);
let first = a.floor().max(0.0) as usize;
let last = (b.ceil().max(0.0) as usize).min(n);
for (i, v) in row.iter_mut().enumerate().take(last).skip(first) {
let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)).max(0.0);
*v += cover as f32 / 4.0;
}
}
}
for (i, k) in row.iter().enumerate() {
if *k > 0.0 {
blend(img, x0 + i as i64, py, k.min(1.0), c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn layout(w: usize, h: usize) -> Layout {
lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h)
}
fn photo() -> Photo {
let mut p = crate::rename::sample_photo();
p.rating = 3;
p.meta.caption = "Morning fog".into();
p.meta.shutter = "1/250".into();
p.meta.aperture = Some(8.0);
p.meta.focal_mm = Some(50.0);
p
}
#[test]
fn tokens_expand_like_the_rename_tokens_plus_slide_ones() {
let p = photo();
assert_eq!(expand("{caption}", &p), "Morning fog");
assert_eq!(expand("{exposure}", &p), "1/250 s at f/8");
assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default()));
assert_eq!(expand("{file}", &p), p.file_name);
assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work");
assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1));
assert_eq!(expand("{rating}", &p), "3");
// unknown and slide-less tags show as typed, and are reported
assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {");
assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]);
// a photo without metadata: empty values, no panic
let mut bare = p.clone();
bare.meta = Default::default();
assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]");
}
#[test]
fn nine_anchors_place_inside_the_slide_with_their_offsets() {
let l = layout(1000, 600);
let short = 600.0;
for anchor in ANCHORS {
for relative_to in [RelativeTo::Slide, RelativeTo::Photo] {
let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0);
let b = at(0.0, 0.0);
assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}");
assert_eq!((b.width(), b.height()), (100.0, 40.0));
let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible };
let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0);
let id = anchor_id(anchor);
let want_x = if id.ends_with("eft") {
c.x0 + 50.0
} else if id.ends_with("ight") {
c.x1 - 50.0
} else {
(c.x0 + c.x1) / 2.0
};
let want_y = if id.starts_with("top") {
c.y0 + 20.0
} else if id.starts_with("bottom") {
c.y1 - 20.0
} else {
(c.y0 + c.y1) / 2.0
};
assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}");
// an inward offset moves it by that share of the shorter side
let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 });
let m = at(sx, sy);
assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}");
}
}
// pushed off the slide: kept on it
let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0);
assert_eq!((b.x1, b.y1), (1000.0, 600.0));
}
fn blank(w: usize, h: usize) -> Rgba8 {
Image::filled(w, h, [0, 0, 0, 255])
}
fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize {
let (x0, y0, x1, y1) = pixels(r, img.width, img.height);
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count()
}
#[test]
fn rating_stars_count_matches_the_rating() {
let l = layout(800, 500);
let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap();
let mut p = photo();
let mut widths = Vec::new();
for rating in 0..=5u8 {
p.rating = rating;
let o = SlideOverlays::resolve(&s, Some(&p));
let boxes = o.boxes(&l);
let mut img = blank(800, 500);
o.draw_front(&mut img, &l);
let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else {
assert_eq!(rating, 0, "no stars only for an unrated photo");
assert_eq!(lit(&img, &l.slide, 1), 0);
continue;
};
widths.push(b.width());
// count the stars: separate runs of lit columns along the box's middle row
let y = ((b.y0 + b.y1) / 2.0) as usize;
let mut runs = 0;
let mut was = false;
for x in 0..800 {
let on = img.get(x, y)[1] > 128;
runs += usize::from(on && !was);
was = on;
}
assert_eq!(runs, usize::from(rating), "{rating} stars");
}
assert!(widths.windows(2).all(|w| w[1] > w[0]));
}
#[test]
fn text_with_a_shadow_draws_the_shadow_at_its_angle() {
let l = layout(800, 500);
let s = OverlaySettings::default()
.merged(
&json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0,
"shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}),
)
.unwrap();
let o = SlideOverlays::resolve(&s, Some(&photo()));
let b = o.boxes(&l)[0].1;
let mut grey = Image::filled(800, 500, [128, 128, 128, 255]);
o.draw_front(&mut grey, &l);
// straight down by 10 px: dark pixels below the text box's glyphs, none above
let dark = |r: Rect| {
let (x0, y0, x1, y1) = pixels(&r, 800, 500);
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count()
};
assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below");
assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above");
// without the shadow: no dark pixels at all
let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap();
let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]);
SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l);
assert!(grey2.data.iter().all(|p| p[0] >= 128));
}
#[test]
fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() {
let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plate.png");
let red = Image::filled(40, 20, [255u8, 0, 0, 255]);
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(&path, png).unwrap();
let l = layout(800, 500);
let s = OverlaySettings::default()
.merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}}))
.unwrap();
assert!(s.warnings().is_empty());
let o = SlideOverlays::resolve(&s, None);
let b = o.boxes(&l)[0].1;
assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept");
let mut img = blank(800, 500);
o.draw_front(&mut img, &l);
let p = img.get(50, 25);
assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}");
assert_eq!(img.get(150, 25)[0], 0, "only inside its box");
// behind the image: the photo covers it
let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap();
let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]);
let ob = SlideOverlays::resolve(&behind, None);
let (img, l2) =
lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| {
ob.draw_behind(i, l)
});
let _ = l2;
assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top");
// a missing file: reported, nothing drawn, no panic
let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap();
assert_eq!(gone.warnings().len(), 1);
let mut img = blank(800, 500);
SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l);
assert!(img.data.iter().all(|p| p[0] == 0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn overlays_are_resolution_independent() {
let s = OverlaySettings::default()
.merged(&json!({
"identityPlate": {"enabled": true, "text": "Studio", "size": 0.06},
"ratingStars": {"enabled": true, "size": 0.04},
"texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}],
}))
.unwrap();
let o = SlideOverlays::resolve(&s, Some(&photo()));
let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000)));
assert_eq!(a.len(), 3);
for ((ka, ra), (kb, rb)) in a.iter().zip(&b) {
assert_eq!(ka, kb);
for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] {
assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}");
}
}
}
#[test]
fn watermark_takes_the_export_insets_and_rotation() {
let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap();
assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90));
let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap();
assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2));
assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err());
}
#[test]
fn hostile_settings_are_refused_or_clamped_and_never_panic() {
let d = OverlaySettings::default();
for bad in [
json!({"identityPlate": {"size": "big"}}),
json!({"identityPlate": {"anchor": "middle"}}),
json!({"identityPlate": 3}),
json!({"ratingStars": {"color": "#zzzzzz"}}),
json!({"texts": {"text": "a"}}),
json!({"texts": [{"relativeTo": "page"}]}),
json!({"texts": [{"sparkle": 1}]}),
json!({"texts": vec![json!({}); MAX_TEXTS + 1]}),
json!({"removeText": 0}),
json!({"removeText": -1}),
json!({"watermark": {"size": 9}}),
json!({"watermark": 5}),
json!({"showWatermark": "yes"}),
json!({"glitter": true}),
json!([1]),
] {
assert!(d.merged(&bad).is_err(), "{bad}");
}
let s = d
.merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}}))
.unwrap();
assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX));
assert_eq!(s.rating_stars.size, SIZE_MIN);
assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX));
assert!(d.merged(&json!({"removeText": 0})).is_err());
assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty());
// round trips (command JSON and prefs.json)
assert_eq!(d.merged(&s.to_json()).unwrap(), s);
let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
// NaN / ∞ from a hand-edited prefs file
let mut wild = s.clone();
wild.identity_plate.size = f64::NAN;
wild.texts[0].place.offset_y = f64::INFINITY;
wild.watermark.size = f32::NAN;
let w = wild.sanitized();
assert_eq!(
(w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size),
(IdentityPlate::default().size, 0.0, Watermark::default().size)
);
// drawing hostile text at every size: empty, huge, CJK, RTL, control characters
let huge = "W".repeat(50_000);
for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"]
{
let s = d
.merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text}))
.unwrap();
assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS);
let mut bare = photo();
bare.meta = Default::default();
for (w, h) in [(1, 1), (17, 9), (320, 200)] {
let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h);
let o = SlideOverlays::resolve(&s, Some(&bare));
let mut img = blank(l.slide.width() as usize, l.slide.height() as usize);
o.draw_behind(&mut img, &l);
o.draw_front(&mut img, &l);
for (_, b) in o.boxes(&l) {
assert!(b.x0.is_finite() && b.y1.is_finite());
}
}
}
}
/// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised.
#[test]
fn huge_overlays_on_a_big_slide_stay_cheap() {
let long = "Wide ".repeat(400);
let s = OverlaySettings::default()
.merged(&json!({
"ratingStars": {"enabled": true, "size": STAR_SIZE_MAX},
"addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0},
}))
.unwrap();
let mut p = photo();
p.rating = 5;
let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500);
let mut img = blank(4000, 2500);
let t = std::time::Instant::now();
SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l);
assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed());
assert!(img.get(100, 300)[0] > 0, "the text's start shows");
}
/// CJK text draws with the craft-fonts faces (skips without them).
#[test]
fn cjk_text_uses_the_craft_fonts() {
if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() {
eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)");
return;
}
let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap();
let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap();
let count = |t: &crate::text::TextLayout| {
let mut n = 0usize;
t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5));
n
};
assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes");
}
}