mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
- Engine: slide_overlay (OverlaySettings in prefs.json, the new slideshow.overlays
command): identity plate (text, or a PNG/JPEG file with its transparency; size,
colour, opacity; render behind image), rating stars (own vector stars), up to 16
text overlays with tokens ({caption}, {exposure}, {file}, {date}, {focal}… plus
every rename token; unknown tags show as typed and are reported) and a drop
shadow, each at one of nine anchors of the slide or photo plus an offset, kept on
the slide; the export watermark on the photo. Sizes relative to the slide's
shorter side; validated and clamped.
- Drawn by the engine as a second pass over lightcraft_slideshow::compose_with
(new behind hook for "render behind image"); the watermark's text renderer moved
to engine text.rs (TextLayout, Inter + craft-fonts CJK) and is shared; glyphs
off the image are skipped so huge text stays cheap.
- rename::expand_tokens_with lets overlays add tokens without changing rename.
- Playback composes slides when overlays are on; slideshow.preview returns each
overlay's box. Settings > Slideshow > Overlays section with live preview.
- Docs: parity row (partial), whats-new, control/MCP docs, nine locale catalogs.
- Tests: tokens, nine anchors, star count, plate image opacity and missing file,
text shadow, resolution independence, hostile input, big-slide cost, CJK with
craft-fonts, prefs persistence, preview and headless playback pixel checks.
542 lines
25 KiB
Rust
542 lines
25 KiB
Rust
//! The slide composer: [`layout`] (where the image cell, the photo, its frame and shadow go) and
|
||
//! [`compose`] (the slide's pixels, on the CPU).
|
||
//!
|
||
//! Drawing order: backdrop colour → background image (at its opacity, filling the slide) → colour
|
||
//! wash → cast shadow → stroke border → photo. The stroke is drawn around the outside of the
|
||
//! photo (the photo keeps its size); the shadow falls from the photo with its stroke.
|
||
|
||
use lightcraft_raster::blur::gaussian;
|
||
use lightcraft_raster::resample::{Filter, resize};
|
||
use lightcraft_raster::{Image, Plane, Rgba8, par_rows};
|
||
|
||
use crate::settings::{MAX_SIDE, SlideSettings};
|
||
|
||
/// A rectangle in slide pixels (`x0 ≤ x1`, `y0 ≤ y1`).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Rect {
|
||
pub x0: f64,
|
||
pub y0: f64,
|
||
pub x1: f64,
|
||
pub y1: f64,
|
||
}
|
||
|
||
impl Rect {
|
||
pub fn width(&self) -> f64 {
|
||
self.x1 - self.x0
|
||
}
|
||
pub fn height(&self) -> f64 {
|
||
self.y1 - self.y0
|
||
}
|
||
fn intersect(&self, o: &Rect) -> Rect {
|
||
let (x0, y0) = (self.x0.max(o.x0), self.y0.max(o.y0));
|
||
Rect { x0, y0, x1: self.x1.min(o.x1).max(x0), y1: self.y1.min(o.y1).max(y0) }
|
||
}
|
||
fn expand(&self, d: f64) -> Rect {
|
||
Rect { x0: self.x0 - d, y0: self.y0 - d, x1: self.x1 + d, y1: self.y1 + d }
|
||
}
|
||
fn shift(&self, dx: f64, dy: f64) -> Rect {
|
||
Rect { x0: self.x0 + dx, y0: self.y0 + dy, x1: self.x1 + dx, y1: self.y1 + dy }
|
||
}
|
||
/// Whole pixel bounds clipped to `w × h`: (x0, y0, x1, y1).
|
||
fn pixels(&self, 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(self.x0, w), c(self.y0, h));
|
||
(x0, y0, c(self.x1, w).max(x0), c(self.y1, h).max(y0))
|
||
}
|
||
}
|
||
|
||
/// Where a slide's parts go, in slide pixels.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Layout {
|
||
/// The slide.
|
||
pub slide: Rect,
|
||
/// The image cell: the slide inside its margins.
|
||
pub cell: Rect,
|
||
/// The whole photo, scaled (with zoom to fill it extends past the cell).
|
||
pub photo: Rect,
|
||
/// The part of the photo that shows (the photo inside the cell).
|
||
pub visible: Rect,
|
||
/// The visible photo with its stroke border (the visible photo when there is none).
|
||
pub frame: Rect,
|
||
/// The rectangle that casts the shadow (the frame, offset), and the blur's sigma in pixels.
|
||
pub shadow: Option<(Rect, f64)>,
|
||
}
|
||
|
||
/// A usable aspect: a non-finite, zero or absurd one takes `fallback`.
|
||
fn sane_aspect(a: f64, fallback: f64) -> f64 {
|
||
if a.is_finite() && a > 1e-3 && a < 1e3 { a } else { fallback }
|
||
}
|
||
|
||
/// The slide size `out_w × out_h` limited to [`MAX_SIDE`] (proportionally) and at least 1 × 1.
|
||
fn slide_size(out_w: usize, out_h: usize) -> (usize, usize) {
|
||
let (w, h) = (out_w.max(1), out_h.max(1));
|
||
let long = w.max(h);
|
||
if long <= MAX_SIDE {
|
||
return (w, h);
|
||
}
|
||
let k = MAX_SIDE as f64 / long as f64;
|
||
(((w as f64 * k).round() as usize).clamp(1, MAX_SIDE), ((h as f64 * k).round() as usize).clamp(1, MAX_SIDE))
|
||
}
|
||
|
||
/// The layout of a `out_w × out_h` slide showing a photo of aspect `photo_aspect` (width /
|
||
/// height; a bad one fills the cell).
|
||
pub fn layout(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> Layout {
|
||
let s = s.clone().sanitized();
|
||
let (w, h) = slide_size(out_w, out_h);
|
||
let (wf, hf) = (w as f64, h as f64);
|
||
let short = wf.min(hf);
|
||
// margins in pixels; when they leave no room (a tiny slide), they shrink to leave one pixel
|
||
let fit = |a: f64, b: f64, room: f64| {
|
||
let (a, b) = ((a * short).round(), (b * short).round());
|
||
if a + b <= room - 1.0 {
|
||
(a, b)
|
||
} else {
|
||
let k = ((room - 1.0).max(0.0)) / (a + b).max(1e-9);
|
||
((a * k).floor(), (b * k).floor())
|
||
}
|
||
};
|
||
let (ml, mr) = fit(s.margin_left, s.margin_right, wf);
|
||
let (mt, mb) = fit(s.margin_top, s.margin_bottom, hf);
|
||
let slide = Rect { x0: 0.0, y0: 0.0, x1: wf, y1: hf };
|
||
let cell = Rect { x0: ml, y0: mt, x1: wf - mr, y1: hf - mb };
|
||
let (cw, ch) = (cell.width().max(1.0), cell.height().max(1.0));
|
||
let a = sane_aspect(photo_aspect, cw / ch);
|
||
let wider = a > cw / ch;
|
||
// fit: the photo's longer side (relative to the cell) meets the cell; fill: the shorter
|
||
let (dw, dh) = if wider != s.zoom_to_fill { (cw, cw / a) } else { (ch * a, ch) };
|
||
let (dw, dh) = (dw.round().max(1.0), dh.round().max(1.0));
|
||
let x0 = (cell.x0 + (cw - dw) / 2.0).round();
|
||
let y0 = (cell.y0 + (ch - dh) / 2.0).round();
|
||
let photo = Rect { x0, y0, x1: x0 + dw, y1: y0 + dh };
|
||
let visible = photo.intersect(&cell);
|
||
let stroke = if s.stroke_border && s.stroke_width > 0.0 { (s.stroke_width * short).round().max(1.0) } else { 0.0 };
|
||
let frame = visible.expand(stroke).intersect(&slide);
|
||
let shadow = (s.cast_shadow && s.shadow_opacity > 0.0).then(|| {
|
||
let (d, t) = (s.shadow_offset * short, s.shadow_angle.to_radians());
|
||
// counter-clockwise from the right, y down on screen
|
||
(frame.shift(d * t.cos(), -d * t.sin()), s.shadow_radius * short / 2.0)
|
||
});
|
||
Layout { slide, cell, photo, visible, frame, shadow }
|
||
}
|
||
|
||
/// The size to render a photo of aspect `photo_aspect` at for a `out_w × out_h` slide: the
|
||
/// photo's whole size in the slide (with zoom to fill, larger than its cell), at most 4× the
|
||
/// slide's long side.
|
||
pub fn photo_size(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> (usize, usize) {
|
||
let l = layout(s, photo_aspect, out_w, out_h);
|
||
let (w, h) = (l.photo.width().max(1.0), l.photo.height().max(1.0));
|
||
let cap = 4.0 * l.slide.width().max(l.slide.height());
|
||
let k = (cap / w.max(h)).min(1.0);
|
||
(((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1))
|
||
}
|
||
|
||
fn mix(a: u8, b: u8, t: f32) -> u8 {
|
||
(a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8
|
||
}
|
||
|
||
fn mix_px(dst: &mut [u8; 4], src: [u8; 3], t: f32) {
|
||
for (d, s) in dst.iter_mut().zip(src) {
|
||
*d = mix(*d, s, t);
|
||
}
|
||
dst[3] = 255;
|
||
}
|
||
|
||
/// Draw `src` scaled to `dest` (slide pixels), only where it meets `clip`, at `opacity`.
|
||
/// Only the part of `src` that shows is resampled (a zoomed-to-fill panorama stays cheap).
|
||
fn draw_image(out: &mut Rgba8, src: &Rgba8, dest: &Rect, clip: &Rect, opacity: f32) {
|
||
if src.width == 0 || src.height == 0 || src.data.len() != src.width * src.height || opacity <= 0.0 {
|
||
return;
|
||
}
|
||
let (w, h) = (out.width, out.height);
|
||
let (vx0, vy0, vx1, vy1) = dest.intersect(clip).pixels(w, h);
|
||
let (vw, vh) = (vx1 - vx0, vy1 - vy0);
|
||
if vw == 0 || vh == 0 || dest.width() <= 0.0 || dest.height() <= 0.0 {
|
||
return;
|
||
}
|
||
// the source pixels behind the visible part
|
||
let (sx, sy) = (src.width as f64 / dest.width(), src.height as f64 / dest.height());
|
||
let to_src = |v: f64, origin: f64, k: f64, max: usize| ((v - origin) * k).clamp(0.0, max as f64);
|
||
let (s0x, s1x) = (to_src(vx0 as f64, dest.x0, sx, src.width).floor(), to_src(vx1 as f64, dest.x0, sx, src.width).ceil());
|
||
let (s0y, s1y) = (to_src(vy0 as f64, dest.y0, sy, src.height).floor(), to_src(vy1 as f64, dest.y0, sy, src.height).ceil());
|
||
let (cx, cy) = (s0x as usize, s0y as usize);
|
||
let (cw, ch) = (((s1x - s0x) as usize).max(1), ((s1y - s0y) as usize).max(1));
|
||
let part = if (cx, cy, cw, ch) == (0, 0, src.width, src.height) { None } else { Some(src.crop(cx, cy, cw, ch)) };
|
||
let part = part.as_ref().unwrap_or(src);
|
||
let scaled: Rgba8 = if (part.width, part.height) == (vw, vh) {
|
||
part.clone()
|
||
} else {
|
||
let f = part.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32]);
|
||
let filter = if vw < part.width { Filter::Lanczos3 } else { Filter::Mitchell };
|
||
resize(&f, vw, vh, filter).map(|p| [0, 1, 2, 3].map(|i| p[i].round().clamp(0.0, 255.0) as u8))
|
||
};
|
||
for (row, y) in (vy0..vy1).enumerate() {
|
||
let (Some(dst), Some(s)) = (out.data.get_mut(y * w + vx0..y * w + vx1), scaled.data.get(row * vw..(row + 1) * vw)) else {
|
||
continue;
|
||
};
|
||
for (d, p) in dst.iter_mut().zip(s) {
|
||
let a = opacity * p[3] as f32 / 255.0;
|
||
mix_px(d, [p[0], p[1], p[2]], a);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Fill `r` with `c`.
|
||
fn fill(out: &mut Rgba8, r: &Rect, c: [u8; 3]) {
|
||
let w = out.width;
|
||
let (x0, y0, x1, y1) = r.pixels(out.width, out.height);
|
||
for y in y0..y1 {
|
||
if let Some(row) = out.data.get_mut(y * w + x0..y * w + x1) {
|
||
row.fill([c[0], c[1], c[2], 255]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A 1-D blurred step: 1 inside `[a, b)`, blurred with `sigma` (pixels), over `0..n`.
|
||
fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec<f32> {
|
||
let inside = Plane::from_fn(n, 1, |x, _| {
|
||
let c = x as f64 + 0.5;
|
||
if c >= a && c < b { 1.0 } else { 0.0 }
|
||
});
|
||
gaussian(&inside, sigma as f32).data
|
||
}
|
||
|
||
/// The slide: `photo` (the developed photo, any size: it is scaled into its cell) and the
|
||
/// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels
|
||
/// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone.
|
||
pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 {
|
||
compose_with(photo, backdrop_image, s, out_w, out_h, &mut |_, _| {}).0
|
||
}
|
||
|
||
/// [`compose`], calling `behind(slide, layout)` once the backdrop is drawn and before the shadow,
|
||
/// the stroke and the photo (so it draws behind the photo: a slideshow identity plate with
|
||
/// "render behind image"), and returning the layout it drew with (for overlays drawn on top).
|
||
pub fn compose_with(
|
||
photo: &Rgba8,
|
||
backdrop_image: Option<&Rgba8>,
|
||
s: &SlideSettings,
|
||
out_w: usize,
|
||
out_h: usize,
|
||
behind: &mut dyn FnMut(&mut Rgba8, &Layout),
|
||
) -> (Rgba8, Layout) {
|
||
let s = s.clone().sanitized();
|
||
let (w, h) = slide_size(out_w, out_h);
|
||
let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height;
|
||
let aspect = if has_photo { photo.width as f64 / photo.height as f64 } else { f64::NAN };
|
||
let l = layout(&s, aspect, w, h);
|
||
let c = s.backdrop_color;
|
||
let mut out = Image::filled(w, h, [c[0], c[1], c[2], 255]);
|
||
// background image: the photo filling the slide, at its opacity
|
||
if let Some(bg) = backdrop_image.filter(|_| s.background_image.is_some() && s.background_opacity > 0.0)
|
||
&& bg.width > 0
|
||
&& bg.height > 0
|
||
{
|
||
let a = bg.width as f64 / bg.height as f64;
|
||
let fill_all = SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..s.clone() };
|
||
let bl = layout(&fill_all, a, w, h);
|
||
draw_image(&mut out, bg, &bl.photo, &bl.slide, s.background_opacity as f32);
|
||
}
|
||
// colour wash: full wash colour at the side the angle points to, none at the opposite side
|
||
if s.color_wash && s.wash_opacity > 0.0 {
|
||
let t = s.wash_angle.to_radians();
|
||
let (dx, dy) = (t.cos(), -t.sin());
|
||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||
let extent = (cx * dx.abs() + cy * dy.abs()).max(1e-9);
|
||
let (wc, op) = (s.wash_color, s.wash_opacity);
|
||
par_rows(&mut out.data, w, |y, row| {
|
||
let py = y as f64 + 0.5 - cy;
|
||
for (x, p) in row.iter_mut().enumerate() {
|
||
let proj = (x as f64 + 0.5 - cx) * dx + py * dy;
|
||
let k = ((proj / extent + 1.0) / 2.0).clamp(0.0, 1.0);
|
||
mix_px(p, wc, (k * op) as f32);
|
||
}
|
||
});
|
||
}
|
||
behind(&mut out, &l);
|
||
// cast shadow: a blurred rectangle (separable: the product of two blurred steps)
|
||
if let Some((r, sigma)) = l.shadow {
|
||
let op = s.shadow_opacity as f32;
|
||
let fx = blurred_step(w, r.x0, r.x1, sigma);
|
||
let fy = blurred_step(h, r.y0, r.y1, sigma);
|
||
par_rows(&mut out.data, w, |y, row| {
|
||
let ky = fy.get(y).copied().unwrap_or(0.0);
|
||
if ky <= 1e-4 {
|
||
return;
|
||
}
|
||
for (p, kx) in row.iter_mut().zip(&fx) {
|
||
let a = op * ky * kx;
|
||
if a > 1e-4 {
|
||
mix_px(p, [0, 0, 0], a);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
if l.frame != l.visible {
|
||
fill(&mut out, &l.frame, s.stroke_color);
|
||
}
|
||
if has_photo {
|
||
draw_image(&mut out, photo, &l.photo, &l.cell, 1.0);
|
||
}
|
||
(out, l)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::settings::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX};
|
||
use serde_json::json;
|
||
|
||
fn solid(w: usize, h: usize, c: [u8; 3]) -> Rgba8 {
|
||
Image::filled(w, h, [c[0], c[1], c[2], 255])
|
||
}
|
||
|
||
fn with(p: serde_json::Value) -> SlideSettings {
|
||
SlideSettings::default().merged(&p).unwrap()
|
||
}
|
||
|
||
fn px(img: &Rgba8, x: usize, y: usize) -> [u8; 3] {
|
||
let p = img.get(x, y);
|
||
[p[0], p[1], p[2]]
|
||
}
|
||
|
||
const RED: [u8; 3] = [255, 0, 0];
|
||
const GREEN: [u8; 3] = [0, 200, 0];
|
||
|
||
#[test]
|
||
fn margins_make_the_cell_and_the_photo_fits_inside_it() {
|
||
// 1000 × 500 slide, 10 % margins (of the 500 px short side = 50 px)
|
||
let s = with(json!({"margins": 0.1}));
|
||
let l = layout(&s, 1.0, 1000, 500);
|
||
assert_eq!(l.cell, Rect { x0: 50.0, y0: 50.0, x1: 950.0, y1: 450.0 });
|
||
// a square photo fits the 900 × 400 cell's height, centred
|
||
assert_eq!(l.photo, Rect { x0: 300.0, y0: 50.0, x1: 700.0, y1: 450.0 });
|
||
assert_eq!(l.visible, l.photo);
|
||
// independent margins
|
||
let s = with(json!({"linkMargins": false, "marginLeft": 0.2, "marginTop": 0.0, "marginRight": 0.0, "marginBottom": 0.4}));
|
||
let l = layout(&s, 3.0, 1000, 500);
|
||
assert_eq!(l.cell, Rect { x0: 100.0, y0: 0.0, x1: 1000.0, y1: 300.0 });
|
||
assert_eq!((l.photo.width(), l.photo.height()), (900.0, 300.0), "3:1 fits 900 × 300 exactly");
|
||
|
||
let img = compose(&solid(64, 64, GREEN), None, &with(json!({"margins": 0.1, "backdropColor": "#ff0000"})), 1000, 500);
|
||
assert_eq!((img.width, img.height), (1000, 500));
|
||
assert_eq!(px(&img, 10, 10), RED, "margin: backdrop");
|
||
assert_eq!(px(&img, 500, 250), GREEN, "cell centre: photo");
|
||
assert_eq!(px(&img, 290, 250), RED, "beside a fitted square photo: backdrop");
|
||
assert_eq!(px(&img, 310, 60), GREEN);
|
||
}
|
||
|
||
#[test]
|
||
fn zoom_to_fill_covers_the_cell_and_crops() {
|
||
let s = with(json!({"margins": 0.1, "zoomToFill": true}));
|
||
let l = layout(&s, 1.0, 1000, 500);
|
||
assert_eq!((l.photo.width(), l.photo.height()), (900.0, 900.0), "the square photo covers the 900 × 400 cell");
|
||
assert_eq!(l.visible, l.cell, "and shows only inside it");
|
||
assert_eq!(photo_size(&s, 1.0, 1000, 500), (900, 900), "rendered whole at the covering size");
|
||
// a gradient photo: zoomed to fill, its middle rows show, its top and bottom are cut
|
||
let photo = Rgba8::from_fn(100, 100, |_, y| [(y * 2) as u8, 0, 0, 255]);
|
||
let img = compose(&photo, None, &s, 1000, 500);
|
||
assert_eq!(px(&img, 500, 10), [0, 0, 0], "margin stays the backdrop");
|
||
assert_eq!(px(&img, 60, 250), px(&img, 940, 250), "covers the cell edge to edge");
|
||
let top = img.get(500, 51)[0];
|
||
assert!(top > 40, "the photo's top rows are cropped away ({top})");
|
||
// fit draws the whole photo (its first rows near black)
|
||
let img = compose(&photo, None, &with(json!({"margins": 0.1})), 1000, 500);
|
||
assert!(img.get(500, 51)[0] < 6);
|
||
}
|
||
|
||
#[test]
|
||
fn aspect_preview_ratios() {
|
||
use crate::settings::Aspect;
|
||
let s = with(json!({"aspect": "4:3"}));
|
||
let r = s.aspect.ratio(2.0);
|
||
assert!((r - 4.0 / 3.0).abs() < 1e-12);
|
||
assert_eq!(with(json!({"aspect": "screen"})).aspect.ratio(2.0), 2.0);
|
||
assert_eq!(Aspect::parse("16:9"), Some(Aspect::Wide));
|
||
// a 4:3 slide of a 3:2 photo letterboxes top and bottom
|
||
let l = layout(&s, 1.5, 800, 600);
|
||
assert_eq!((l.photo.width(), l.photo.height(), l.photo.y0), (800.0, 533.0, 34.0));
|
||
}
|
||
|
||
#[test]
|
||
fn stroke_surrounds_the_photo_and_shadow_falls_at_its_angle() {
|
||
let s = with(json!({"margins": 0.2, "strokeBorder": true, "strokeWidth": 0.02, "strokeColor": "#ffffff"}));
|
||
let l = layout(&s, 1.0, 1000, 1000);
|
||
assert_eq!(l.visible, Rect { x0: 200.0, y0: 200.0, x1: 800.0, y1: 800.0 });
|
||
assert_eq!(l.frame, Rect { x0: 180.0, y0: 180.0, x1: 820.0, y1: 820.0 }, "20 px outside the photo");
|
||
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
|
||
assert_eq!(px(&img, 190, 500), [255, 255, 255], "stroke left");
|
||
assert_eq!(px(&img, 810, 810), [255, 255, 255], "stroke corner");
|
||
assert_eq!(px(&img, 175, 500), [0, 0, 0], "outside the stroke");
|
||
assert_eq!(px(&img, 205, 500), GREEN, "the photo keeps its size");
|
||
|
||
// shadow at −45° (down-right) by 5 %, hard edged (radius 0), opaque
|
||
let s = with(
|
||
json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": -45, "shadowRadius": 0, "shadowOpacity": 1, "backdropColor": "#ffffff"}),
|
||
);
|
||
let l = layout(&s, 1.0, 1000, 1000);
|
||
let (r, _) = l.shadow.unwrap();
|
||
let d = 50.0 / 2f64.sqrt();
|
||
assert!((r.x0 - (200.0 + d)).abs() < 1e-9 && (r.y0 - (200.0 + d)).abs() < 1e-9, "{r:?}");
|
||
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
|
||
assert_eq!(px(&img, 820, 820), [0, 0, 0], "shadow below right");
|
||
assert_eq!(px(&img, 180, 180), [255, 255, 255], "no shadow above left");
|
||
assert_eq!(px(&img, 850, 500), [255, 255, 255], "shadow ends at its offset");
|
||
// at 90° the shadow goes straight up; a soft radius spreads it but fades out
|
||
let s = with(
|
||
json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": 90, "shadowRadius": 0.04, "shadowOpacity": 0.8, "backdropColor": "#ffffff"}),
|
||
);
|
||
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
|
||
assert!(img.get(500, 170)[0] < 200, "above: in shadow");
|
||
assert_eq!(px(&img, 500, 950), [255, 255, 255], "below: none");
|
||
assert_eq!(px(&img, 500, 50), [255, 255, 255], "beyond the blur: none");
|
||
let mid = img.get(500, 151)[0];
|
||
assert!(mid > 60 && mid < 250, "a soft edge ({mid})");
|
||
}
|
||
|
||
#[test]
|
||
fn colour_wash_follows_its_angle() {
|
||
let wash = |angle: f64| {
|
||
compose(
|
||
&Rgba8::new(0, 0),
|
||
None,
|
||
&with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 1, "washAngle": angle})),
|
||
200,
|
||
100,
|
||
)
|
||
};
|
||
// 0°: white at the right, black at the left
|
||
let img = wash(0.0);
|
||
assert!(img.get(199, 50)[0] > 250 && img.get(0, 50)[0] < 5);
|
||
assert!((img.get(100, 50)[0] as i32 - 128).abs() < 3, "half way in the middle");
|
||
assert_eq!(img.get(150, 0), img.get(150, 99), "constant along the other axis");
|
||
// 90°: white at the top
|
||
let img = wash(90.0);
|
||
assert!(img.get(100, 0)[0] > 245 && img.get(100, 99)[0] < 10);
|
||
// 180°: white at the left
|
||
assert!(wash(180.0).get(0, 50)[0] > 250);
|
||
// −45°: bottom-right is the brightest corner
|
||
let img = wash(-45.0);
|
||
assert!(img.get(199, 99)[0] > 250 && img.get(0, 0)[0] < 5);
|
||
// opacity scales it
|
||
let img =
|
||
compose(&Rgba8::new(0, 0), None, &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 0.5, "washAngle": 0})), 200, 100);
|
||
assert!((img.get(199, 50)[0] as i32 - 127).abs() < 3);
|
||
}
|
||
|
||
#[test]
|
||
fn background_image_fills_the_slide_at_its_opacity() {
|
||
let bg = solid(30, 10, [200, 100, 0]);
|
||
let s = with(json!({"backgroundImage": 5, "backgroundOpacity": 0.5, "backdropColor": "#000000", "margins": 0.25}));
|
||
let img = compose(&solid(4, 4, GREEN), Some(&bg), &s, 400, 200);
|
||
assert_eq!(px(&img, 2, 2), [100, 50, 0], "half the image over black");
|
||
assert_eq!(px(&img, 398, 198), [100, 50, 0], "fills the whole slide");
|
||
assert_eq!(px(&img, 200, 100), GREEN, "the photo on top");
|
||
// off, or no image given: the backdrop colour
|
||
let off = SlideSettings { background_image: None, ..s.clone() };
|
||
assert_eq!(px(&compose(&solid(4, 4, GREEN), Some(&bg), &off, 400, 200), 2, 2), [0, 0, 0]);
|
||
assert_eq!(px(&compose(&solid(4, 4, GREEN), None, &s, 400, 200), 2, 2), [0, 0, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn layouts_are_resolution_independent() {
|
||
let s = with(
|
||
json!({"linkMargins": false, "marginLeft": 0.1, "marginTop": 0.05, "marginRight": 0.2, "marginBottom": 0.08, "strokeBorder": true, "strokeWidth": 0.01, "castShadow": true, "shadowOffset": 0.02}),
|
||
);
|
||
let a = layout(&s, 1.5, 800, 450);
|
||
let b = layout(&s, 1.5, 1600, 900);
|
||
for (ra, rb) in [(a.cell, b.cell), (a.photo, b.photo), (a.frame, b.frame), (a.shadow.unwrap().0, b.shadow.unwrap().0)] {
|
||
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() <= 2.0, "{ra:?} vs {rb:?}");
|
||
}
|
||
}
|
||
assert!((a.shadow.unwrap().1 * 2.0 - b.shadow.unwrap().1).abs() < 1e-9);
|
||
// and the pixels agree: the big slide, halved, is the small one (away from edges)
|
||
let photo = Rgba8::from_fn(60, 40, |x, y| [(x * 4) as u8, (y * 6) as u8, 90, 255]);
|
||
let small = compose(&photo, None, &s, 800, 450);
|
||
let big = compose(&photo, None, &s, 1600, 900);
|
||
for (x, y) in [(10, 10), (400, 225), (300, 100), (700, 400)] {
|
||
let (p, q) = (small.get(x, y), big.get(x * 2, y * 2));
|
||
assert!((0..3).all(|i| (p[i] as i32 - q[i] as i32).abs() < 24), "({x}, {y}): {p:?} vs {q:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_default_slide_is_the_photo_fitted_on_black() {
|
||
let photo = Rgba8::from_fn(300, 200, |x, y| [x as u8, y as u8, 7, 255]);
|
||
let s = SlideSettings::default();
|
||
assert!(s.is_plain());
|
||
// a 3:2 photo on a 16:10 screen: the photo at its own pixels, black bars beside it
|
||
assert_eq!(photo_size(&s, 1.5, 320, 200), (300, 200));
|
||
let img = compose(&photo, None, &s, 320, 200);
|
||
assert_eq!(px(&img, 5, 100), [0, 0, 0]);
|
||
assert_eq!(px(&img, 314, 100), [0, 0, 0]);
|
||
for (x, y) in [(0, 0), (150, 100), (299, 199)] {
|
||
assert_eq!(img.get(x + 10, y), photo.get(x, y), "unchanged pixels at ({x}, {y})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn behind_draws_under_the_photo_and_the_layout_comes_back() {
|
||
let s = with(json!({"margins": 0.2}));
|
||
let mut seen = None;
|
||
let (img, l) = compose_with(&solid(10, 10, GREEN), None, &s, 200, 100, &mut |out, l| {
|
||
seen = Some(*l);
|
||
out.data.fill([255, 0, 0, 255]);
|
||
});
|
||
assert_eq!(seen, Some(l), "the hook gets the layout the slide is drawn with");
|
||
assert_eq!(px(&img, 2, 2), RED, "the hook's paint stays on the backdrop");
|
||
assert_eq!(px(&img, 100, 50), GREEN, "and the photo covers it");
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_input_never_panics() {
|
||
let wild = SlideSettings {
|
||
margin_left: f64::NAN,
|
||
margin_right: 1e300,
|
||
margin_top: -5.0,
|
||
margin_bottom: f64::INFINITY,
|
||
stroke_border: true,
|
||
stroke_width: 1e9,
|
||
cast_shadow: true,
|
||
shadow_opacity: f64::NAN,
|
||
shadow_offset: -1.0,
|
||
shadow_radius: f64::INFINITY,
|
||
shadow_angle: f64::NAN,
|
||
color_wash: true,
|
||
wash_opacity: 9.0,
|
||
wash_angle: f64::INFINITY,
|
||
background_image: Some(u64::MAX),
|
||
background_opacity: f64::NEG_INFINITY,
|
||
zoom_to_fill: true,
|
||
..SlideSettings::default()
|
||
};
|
||
let photos = [Rgba8::new(0, 0), Rgba8::new(1, 1), Rgba8::new(5000, 1), Rgba8::new(1, 3000), Rgba8 { width: 4, height: 4, data: vec![] }];
|
||
for photo in &photos {
|
||
for (w, h) in [(0, 0), (1, 1), (3, 2), (1, 400), (100_000, 2), (64, 48)] {
|
||
let img = compose(photo, Some(&Rgba8::new(0, 5)), &wild, w, h);
|
||
assert!(img.width >= 1 && img.height >= 1 && img.width <= MAX_SIDE && img.height <= MAX_SIDE);
|
||
assert_eq!(img.data.len(), img.width * img.height);
|
||
let (pw, ph) = photo_size(&wild, photo.width as f64 / photo.height.max(1) as f64, w, h);
|
||
assert!(pw >= 1 && ph >= 1 && pw <= 4 * MAX_SIDE && ph <= 4 * MAX_SIDE);
|
||
}
|
||
}
|
||
for a in [f64::NAN, 0.0, -1.0, f64::INFINITY, 1e-300, 1e300] {
|
||
let l = layout(&wild, a, 640, 480);
|
||
assert!(l.photo.width() >= 1.0 && l.photo.width().is_finite());
|
||
}
|
||
// the clamps: biggest margins still leave a cell, biggest stroke and shadow stay bounded
|
||
let s = SlideSettings {
|
||
margin_left: MARGIN_MAX,
|
||
margin_right: MARGIN_MAX,
|
||
stroke_border: true,
|
||
stroke_width: STROKE_MAX,
|
||
cast_shadow: true,
|
||
shadow_offset: SHADOW_OFFSET_MAX,
|
||
shadow_radius: SHADOW_RADIUS_MAX,
|
||
..SlideSettings::default()
|
||
};
|
||
let l = layout(&s, 1.0, 100, 100);
|
||
assert!(l.cell.width() >= 1.0, "{l:?}");
|
||
assert!(l.frame.x0 >= 0.0 && l.frame.x1 <= 100.0, "the stroke stays on the slide");
|
||
}
|
||
}
|