Skip to main content

falcon_library/
combine.rs

1//! Combine Images: several pictures laid out on one canvas and saved as a
2//! new PNG. In Eagle this is a plugin (`combine-images`); this is falcon's
3//! own small version, not a copy of it.
4//!
5//! [`plan`] is the layout maths: a row scales every picture to the tallest
6//! one's height, a column to the widest one's width, and a grid fits each
7//! inside one cell (the largest width by the largest height), centred. The
8//! canvas is kept to [`MAX_SIDE`] and [`MAX_PIXELS`]: past them everything,
9//! gaps included, is scaled down together.
10//!
11//! [`stage`] does the work without touching the library, so it can run on
12//! another thread: one picture is decoded at a time, in sRGB and turned by
13//! its EXIF orientation ([`thumbnail::decode_for_display`]), an animation
14//! gives its first frame, and the result is 8 bits per channel. The sources
15//! are only read. The caller commits the [`Staged`] import.
16
17use std::path::Path;
18
19use image::metadata::Orientation;
20use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Rgba, RgbaImage, imageops};
21
22use crate::import::{ImportOptions, Staged};
23use crate::thumbnail;
24
25/// The longest side of a result. Past it the picture is scaled down.
26pub const MAX_SIDE: u32 = 16_384;
27/// The most pixels in a result: 400 MB in memory, which `image`'s default
28/// 512 MB limit can still read back for the thumbnail and the viewer.
29pub const MAX_PIXELS: u64 = 100_000_000;
30/// Scaling passes before a layout counts as impossible. Each one lands just
31/// inside the limits unless rounding (no picture under 1 px) keeps it over.
32const PLAN_PASSES: usize = 24;
33
34/// How the pictures are arranged.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Layout {
37    /// One row, every picture the same height.
38    Horizontal,
39    /// One column, every picture the same width.
40    Vertical,
41    /// Rows of this many equal cells; the last row may be short.
42    Grid(u32),
43}
44
45/// What the user chose.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Options {
48    pub layout: Layout,
49    /// Pixels between pictures, before any scaling down.
50    pub gap: u32,
51    /// The canvas colour; `None` is transparent.
52    pub background: Option<[u8; 3]>,
53}
54
55/// Where one picture goes on the canvas.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct Cell {
58    pub x: u32,
59    pub y: u32,
60    pub width: u32,
61    pub height: u32,
62}
63
64/// A worked-out layout.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Plan {
67    pub width: u32,
68    pub height: u32,
69    /// One per source, in order.
70    pub cells: Vec<Cell>,
71    /// The limits made it smaller than the sources' own size.
72    pub scaled_down: bool,
73}
74
75/// A finished combine, ready for [`crate::Library::commit_import`].
76#[derive(Debug)]
77pub struct Combined {
78    pub staged: Staged,
79    pub width: u32,
80    pub height: u32,
81    pub scaled_down: bool,
82}
83
84/// Formats [`stage`] can read (lower-case, no dot): what the `image` crate
85/// decodes among falcon's picture formats. SVG, RAW and QuickLook formats
86/// aren't here.
87pub fn can_combine(ext: &str) -> bool {
88    matches!(ext.to_lowercase().as_str(), "png" | "jpg" | "jpeg" | "jfif" | "jpe" | "insp" | "webp" | "gif" | "bmp" | "tif" | "tiff")
89}
90
91fn scale_side(side: u32, scale: f64) -> u64 {
92    ((side as f64 * scale).round() as u64).max(1)
93}
94
95/// `side` times `to / from`, at least 1 px.
96fn ratio(side: u32, to: u64, from: u32) -> u64 {
97    ((side as f64 * to as f64 / from as f64).round() as u64).max(1)
98}
99
100/// The canvas size and each picture's box (x, y, width, height) at `scale`.
101fn arrange(sizes: &[(u32, u32)], layout: Layout, gap: u32, scale: f64) -> (u64, u64, Vec<[u64; 4]>) {
102    let gap = (gap as f64 * scale).round() as u64;
103    let max_w = sizes.iter().map(|s| s.0).max().unwrap_or(1);
104    let max_h = sizes.iter().map(|s| s.1).max().unwrap_or(1);
105    let mut cells = Vec::with_capacity(sizes.len());
106    match layout {
107        Layout::Horizontal => {
108            let height = scale_side(max_h, scale);
109            let mut x = 0;
110            for &(w, h) in sizes {
111                let width = ratio(w, height, h);
112                cells.push([x, 0, width, height]);
113                x += width + gap;
114            }
115            (x.saturating_sub(gap), height, cells)
116        }
117        Layout::Vertical => {
118            let width = scale_side(max_w, scale);
119            let mut y = 0;
120            for &(w, h) in sizes {
121                let height = ratio(h, width, w);
122                cells.push([0, y, width, height]);
123                y += height + gap;
124            }
125            (width, y.saturating_sub(gap), cells)
126        }
127        Layout::Grid(columns) => {
128            let columns = (columns.max(1) as usize).min(sizes.len().max(1)) as u64;
129            let rows = (sizes.len() as u64).div_ceil(columns);
130            let (cell_w, cell_h) = (scale_side(max_w, scale), scale_side(max_h, scale));
131            for (n, &(w, h)) in sizes.iter().enumerate() {
132                let (column, row) = (n as u64 % columns, n as u64 / columns);
133                let fit = (cell_w as f64 / w as f64).min(cell_h as f64 / h as f64);
134                let width = ((w as f64 * fit).round() as u64).clamp(1, cell_w);
135                let height = ((h as f64 * fit).round() as u64).clamp(1, cell_h);
136                let x = column * (cell_w + gap) + (cell_w - width) / 2;
137                let y = row * (cell_h + gap) + (cell_h - height) / 2;
138                cells.push([x, y, width, height]);
139            }
140            (columns * cell_w + (columns - 1) * gap, rows * cell_h + (rows - 1) * gap, cells)
141        }
142    }
143}
144
145/// Lays out pictures of these sizes (width, height), keeping the canvas
146/// within [`MAX_SIDE`] and [`MAX_PIXELS`]. Fails when there are no pictures,
147/// one has no pixels, or they can't fit even at 1 px each.
148pub fn plan(sizes: &[(u32, u32)], layout: Layout, gap: u32) -> Result<Plan, String> {
149    if sizes.is_empty() {
150        return Err("there are no images to combine".into());
151    }
152    if sizes.iter().any(|&(w, h)| w == 0 || h == 0) {
153        return Err("an image has no pixels".into());
154    }
155    let mut scale = 1.0_f64;
156    for _ in 0..PLAN_PASSES {
157        let (width, height, cells) = arrange(sizes, layout, gap, scale);
158        let side = width.max(height) as f64 / MAX_SIDE as f64;
159        let area = (width as f64 * height as f64 / MAX_PIXELS as f64).sqrt();
160        let over = side.max(area);
161        if over <= 1.0 {
162            let cells = cells.into_iter().map(|[x, y, width, height]| Cell { x: x as u32, y: y as u32, width: width as u32, height: height as u32 }).collect();
163            return Ok(Plan { width: width as u32, height: height as u32, cells, scaled_down: scale < 1.0 });
164        }
165        // A little under, so rounding up doesn't need another pass.
166        scale = scale / over * 0.999;
167    }
168    Err(format!("{} images can't fit in a picture of {MAX_SIDE} px a side", sizes.len()))
169}
170
171/// A picture's size as it will be shown, from its header alone: an EXIF
172/// quarter turn swaps the sides.
173pub fn display_size(path: &Path) -> Result<(u32, u32), String> {
174    let name = file_name(path);
175    let fail = |e: &dyn std::fmt::Display| format!("{name}: {e}");
176    let reader = ImageReader::open(path).map_err(|e| fail(&e))?.with_guessed_format().map_err(|e| fail(&e))?;
177    let mut decoder = reader.into_decoder().map_err(|e| fail(&e))?;
178    let (w, h) = decoder.dimensions();
179    Ok(match decoder.orientation().map_err(|e| fail(&e))? {
180        Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH => (h, w),
181        _ => (w, h),
182    })
183}
184
185fn file_name(path: &Path) -> String {
186    path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned())
187}
188
189/// `img` at exactly this size. Transparent pixels' colours are hidden while
190/// it is resized, so they don't bleed into the edges.
191fn fit(img: DynamicImage, width: u32, height: u32) -> RgbaImage {
192    let mut rgba = img.into_rgba8();
193    if rgba.dimensions() == (width, height) {
194        return rgba;
195    }
196    let filter = imageops::FilterType::Lanczos3;
197    if rgba.pixels().all(|p| p[3] == 255) {
198        return imageops::resize(&rgba, width, height, filter);
199    }
200    for p in rgba.pixels_mut() {
201        let a = p[3] as u32;
202        for c in 0..3 {
203            p[c] = ((p[c] as u32 * a + 127) / 255) as u8;
204        }
205    }
206    let mut out = imageops::resize(&rgba, width, height, filter);
207    for p in out.pixels_mut() {
208        let a = p[3] as u32;
209        for c in 0..3 {
210            p[c] = (p[c] as u32 * 255 + a / 2).checked_div(a).map_or(0, |v| v.min(255)) as u8;
211        }
212    }
213    out
214}
215
216/// Draws the pictures at `paths` on one canvas. `keep_going` hears of each
217/// one before it is read, and returning false stops with `Ok(None)`.
218pub fn compose(paths: &[impl AsRef<Path>], options: Options, keep_going: &mut impl FnMut(usize, &Path) -> bool) -> Result<Option<(RgbaImage, bool)>, String> {
219    let sizes = paths.iter().map(|p| display_size(p.as_ref())).collect::<Result<Vec<_>, _>>()?;
220    let plan = plan(&sizes, options.layout, options.gap)?;
221    let background = options.background.map_or(Rgba([0, 0, 0, 0]), |[r, g, b]| Rgba([r, g, b, 255]));
222    let mut canvas = RgbaImage::from_pixel(plan.width, plan.height, background);
223    for (n, (path, cell)) in paths.iter().zip(&plan.cells).enumerate() {
224        let path = path.as_ref();
225        if !keep_going(n, path) {
226            return Ok(None);
227        }
228        let img = thumbnail::decode_for_display(path).map_err(|e| format!("{}: {e}", file_name(path)))?;
229        imageops::overlay(&mut canvas, &fit(img, cell.width, cell.height), cell.x as i64, cell.y as i64);
230    }
231    Ok(Some((canvas, plan.scaled_down)))
232}
233
234/// Combines the pictures and stages the PNG for import with `import`. See
235/// [`compose`] for `keep_going`; it also hears of the save, as step
236/// `paths.len()`.
237pub fn stage(paths: &[impl AsRef<Path>], options: Options, import: &ImportOptions, mut keep_going: impl FnMut(usize, &Path) -> bool) -> Result<Option<Combined>, String> {
238    let Some((canvas, scaled_down)) = compose(paths, options, &mut keep_going)? else { return Ok(None) };
239    let dir = tempfile::Builder::new().prefix("falcon-combine").tempdir().map_err(|e| e.to_string())?;
240    let file = dir.path().join("combined.png");
241    if !keep_going(paths.len(), &file) {
242        return Ok(None);
243    }
244    canvas.save_with_format(&file, ImageFormat::Png).map_err(|e| e.to_string())?;
245    let (width, height) = canvas.dimensions();
246    drop(canvas);
247    let staged = crate::import::stage(&file, import).map_err(|e| e.to_string())?;
248    Ok(Some(Combined { staged, width, height, scaled_down }))
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn cell(x: u32, y: u32, width: u32, height: u32) -> Cell {
256        Cell { x, y, width, height }
257    }
258
259    #[test]
260    fn a_row_scales_to_the_tallest_height_with_gaps_between() {
261        let p = plan(&[(100, 50), (40, 100), (200, 100)], Layout::Horizontal, 10).unwrap();
262        assert_eq!(p.cells, [cell(0, 0, 200, 100), cell(210, 0, 40, 100), cell(260, 0, 200, 100)]);
263        assert_eq!((p.width, p.height, p.scaled_down), (460, 100, false));
264    }
265
266    #[test]
267    fn a_column_scales_to_the_widest_width() {
268        let p = plan(&[(100, 50), (50, 100)], Layout::Vertical, 4).unwrap();
269        assert_eq!(p.cells, [cell(0, 0, 100, 50), cell(0, 54, 100, 200)]);
270        assert_eq!((p.width, p.height), (100, 254));
271    }
272
273    #[test]
274    fn a_grid_centres_each_picture_in_equal_cells() {
275        let p = plan(&[(100, 50), (50, 100), (100, 100)], Layout::Grid(2), 10).unwrap();
276        assert_eq!(p.cells, [cell(0, 25, 100, 50), cell(135, 0, 50, 100), cell(0, 110, 100, 100)]);
277        assert_eq!((p.width, p.height), (210, 210));
278    }
279
280    #[test]
281    fn a_grid_has_no_empty_columns_and_at_least_one() {
282        let sizes = [(10, 10), (10, 10)];
283        assert_eq!(plan(&sizes, Layout::Grid(5), 2).unwrap().width, 22);
284        let one = plan(&sizes, Layout::Grid(0), 2).unwrap();
285        assert_eq!((one.width, one.height), (10, 22));
286    }
287
288    #[test]
289    fn past_the_side_limit_everything_scales_together() {
290        let p = plan(&[(10_000, 1000), (10_000, 1000)], Layout::Horizontal, 1000).unwrap();
291        assert!(p.scaled_down);
292        assert!(p.width <= MAX_SIDE && p.width > MAX_SIDE - 100, "{}", p.width);
293        // The gap shrinks with the pictures.
294        let gap = p.cells[1].x - p.cells[0].width;
295        assert!((775..=785).contains(&gap), "{gap}");
296        assert_eq!(p.cells[0].width, p.cells[1].width);
297        assert!(p.cells[1].x + p.cells[1].width <= p.width);
298    }
299
300    #[test]
301    fn past_the_pixel_limit_the_area_comes_down() {
302        let p = plan(&[(16_000, 16_000)], Layout::Horizontal, 0).unwrap();
303        assert!(p.scaled_down);
304        assert!(p.width as u64 * p.height as u64 <= MAX_PIXELS);
305        assert!(p.width > 9_900, "{}", p.width);
306    }
307
308    #[test]
309    fn what_cant_fit_or_has_no_pixels_is_refused() {
310        assert!(plan(&[(1, 1); 20_000], Layout::Horizontal, 0).is_err());
311        assert!(plan(&[], Layout::Vertical, 0).is_err());
312        assert!(plan(&[(10, 0), (10, 10)], Layout::Vertical, 0).is_err());
313        // The limits themselves are allowed.
314        assert!(!plan(&[(MAX_SIDE, 10)], Layout::Horizontal, 0).unwrap().scaled_down);
315    }
316
317    #[test]
318    fn extreme_shapes_keep_a_pixel_and_stay_in_their_cell() {
319        let p = plan(&[(10_000, 1), (1, 10_000)], Layout::Grid(2), 0).unwrap();
320        for c in &p.cells {
321            assert!(c.width >= 1 && c.height >= 1);
322            assert!(c.x + c.width <= p.width && c.y + c.height <= p.height);
323        }
324    }
325
326    #[test]
327    fn only_formats_the_decoder_reads_can_combine() {
328        for ext in ["png", "JPG", "webp", "gif", "bmp", "tiff"] {
329            assert!(can_combine(ext), "{ext}");
330        }
331        for ext in ["svg", "mp4", "heic", "psd", ""] {
332            assert!(!can_combine(ext), "{ext}");
333        }
334    }
335
336    #[test]
337    fn resizing_keeps_transparent_colours_out_of_the_edges() {
338        // Half opaque red, half fully transparent green.
339        let img = RgbaImage::from_fn(8, 8, |x, _| if x < 4 { Rgba([255, 0, 0, 255]) } else { Rgba([0, 255, 0, 0]) });
340        let out = fit(DynamicImage::ImageRgba8(img), 4, 4);
341        for p in out.pixels().filter(|p| p[3] > 8) {
342            assert!(p[1] < 16, "green showed through: {p:?}");
343        }
344    }
345}