1use 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
25pub const MAX_SIDE: u32 = 16_384;
27pub const MAX_PIXELS: u64 = 100_000_000;
30const PLAN_PASSES: usize = 24;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Layout {
37 Horizontal,
39 Vertical,
41 Grid(u32),
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Options {
48 pub layout: Layout,
49 pub gap: u32,
51 pub background: Option<[u8; 3]>,
53}
54
55#[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#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Plan {
67 pub width: u32,
68 pub height: u32,
69 pub cells: Vec<Cell>,
71 pub scaled_down: bool,
73}
74
75#[derive(Debug)]
77pub struct Combined {
78 pub staged: Staged,
79 pub width: u32,
80 pub height: u32,
81 pub scaled_down: bool,
82}
83
84pub 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
95fn 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
100fn 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
145pub 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 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
171pub 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
189fn 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
216pub 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
234pub 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 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 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 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}