Media

Collage

Several pictures made into one: a grid of equal cells with gaps, or justified rows that keep each picture's shape, on your background.

Version 1.0.0 · 2 functions · 1 crate

#images#photos#social

Functions

What use collage.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use collage_grid, collage_rows from collage (Quillo 0.6).

collage_grid(images: list, out: str, columns: int, width: int, ratio: float, options: any) → anyor an error

A grid collage at out: columns cells to a row (1 to 50), the whole width pixels wide (100 to 8,000), cells ratio wide to 1 high (1 for squares, 1.5 for 3:2). options (or null): gap between cells and around them in pixels (8), background "#rrggbb" (white, or "#rrggbbaa"), fit "cover" (fill each cell, cropping) or "contain" (show all). { width, height, count }.

collage_rows(images: list, out: str, width: int, row_height: int, options: any) → anyor an error

A collage of justified rows at out, as photo sites lay out albums: pictures keep their shape, each row is scaled to fill width exactly, and rows are about row_height pixels high (the last row too, unless it would be stretched). options: gap and background as for collage_grid. { width, height, count, rows }.

Get it

$ quillo install collage               # collage.qwer into this folder$ quillo install collage --example     # and its example, collage.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/collage.qwer$ curl -O https://quillolang.org/plugins/files/collage.qwe$ curl -O https://quillolang.org/plugins/files/collage_sample.jpg

Then, in your program, all of it or only what you call:

main.qwe
use collage.qweruse collage_grid, collage_rows from collage      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }

Example

collage.qwe
// An album page made of a holiday's photos: a square grid for social posts, and justified rows for the website.////   quillo run collage.qwe      then open http://localhost:8080/grid.jpguse "collage.qwer"// Five "photos" of different shapes, cut from one picture with Quillo's own image.convert.fn photos() = for c in [{ x: 0, y: 0, w: 300, h: 200 }, { x: 300, y: 0, w: 300, h: 400 }, { x: 0, y: 200, w: 300, h: 200 }, { x: 100, y: 50, w: 400, h: 200 }, { x: 200, y: 100, w: 200, h: 200 }] => {    let path = `data/album/${c.x}-${c.y}-${c.w}.jpg`    image.convert("collage_sample.jpg", path, { crop: c, format: "jpeg", quality: 85 })?    path}route GET /grid.jpg {    collage_grid(photos(), "data/album/grid.jpg", 3, 900, 1.0, { gap: 10 })?    file("data/album/grid.jpg")}route GET /rows.jpg {    collage_rows(photos(), "data/album/rows.jpg", 900, 220, { gap: 6, background: "#1b1630" })?    file("data/album/rows.jpg")}route GET /made = {    grid: collage_grid(photos(), "data/album/grid.jpg", 3, 900, 1.0, { gap: 10 })?,    contain: collage_grid(photos(), "data/album/contain.png", 2, 600, 1.5, { fit: "contain", background: "#00000000" })?,    rows: collage_rows(photos(), "data/album/rows.jpg", 900, 220, { gap: 6 })?,}
$ quillo run collage.qwe$ curl 'localhost:8080/made'$ curl 'localhost:8080/grid.jpg'

Source

collage.qwer
//! Several pictures put together into one: a grid of equal cells with gaps, or justified rows that keep each picture's shape, on the background you choose.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }use image::{DynamicImage, ImageDecoder, Rgba, RgbaImage};use serde_json::{json, Value};const MAX_IMAGES: usize = 500;fn reader(path: &str) -> Result<image::ImageReader<std::io::BufReader<std::fs::File>>, String> {    let mut r = image::ImageReader::open(path).map_err(|e| format!("{}: {}", path, e))?.with_guessed_format().map_err(|e| format!("{}: {}", path, e))?;    // A small file claiming to be enormous is refused before anything is allocated.    let mut l = image::Limits::default();    l.max_image_width = Some(12_000);    l.max_image_height = Some(12_000);    l.max_alloc = Some(512 * 1024 * 1024);    r.limits(l);    Ok(r)}// The picture turned upright by its EXIF orientation, as phones expect.fn open(path: &str) -> Result<DynamicImage, String> {    let mut dec = reader(path)?.into_decoder().map_err(|e| format!("{}: {}", path, e))?;    let o = dec.orientation().unwrap_or(image::metadata::Orientation::NoTransforms);    let mut img = DynamicImage::from_decoder(dec).map_err(|e| format!("{}: {}", path, e))?;    img.apply_orientation(o);    Ok(img)}// Saved in the format its extension names: .jpg (quality 88), .png, .webp (lossless) or .gif; folders are made.fn save(img: &DynamicImage, path: &str) -> Result<(), String> {    if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;    }    let ext = path.rsplit_once('.').map(|(_, e)| e.to_lowercase()).unwrap_or_default();    let err = |e: image::ImageError| format!("{}: {}", path, e);    match ext.as_str() {        "jpg" | "jpeg" => {            let f = std::fs::File::create(path).map_err(|e| format!("{}: {}", path, e))?;            image::codecs::jpeg::JpegEncoder::new_with_quality(std::io::BufWriter::new(f), 88).encode_image(&img.to_rgb8()).map_err(err)        }        "png" => img.save_with_format(path, image::ImageFormat::Png).map_err(err),        "webp" => DynamicImage::ImageRgba8(img.to_rgba8()).save_with_format(path, image::ImageFormat::WebP).map_err(err),        "gif" => DynamicImage::ImageRgba8(img.to_rgba8()).save_with_format(path, image::ImageFormat::Gif).map_err(err),        _ => Err(format!("{}: save as .jpg, .png, .webp or .gif", path)),    }}fn colour(o: &Value) -> Rgba<u8> {    let h = o.get("background").and_then(Value::as_str).unwrap_or("#ffffff").trim_start_matches('#').to_string();    match (h.len(), u32::from_str_radix(&h, 16)) {        (6, Ok(v)) => Rgba([(v >> 16) as u8, (v >> 8) as u8, v as u8, 255]),        (8, Ok(v)) => Rgba([(v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8, v as u8]),        _ => Rgba([255, 255, 255, 255]),    }}fn load_all(images: &[String]) -> Result<Vec<DynamicImage>, String> {    if images.is_empty() || images.len() > MAX_IMAGES {        return Err(format!("give 1 to {} pictures", MAX_IMAGES));    }    images.iter().map(|p| open(p)).collect()}/// A grid collage at `out`: `columns` cells to a row (1 to 50), the whole `width` pixels wide (100 to 8,000), cells `ratio` wide to 1 high (1 for squares, 1.5 for 3:2)./// `options` (or null): `gap` between cells and around them in pixels (8), `background` "#rrggbb" (white, or "#rrggbbaa"), `fit` "cover" (fill each cell, cropping) or "contain" (show all)./// `{ width, height, count }`.pub fn collage_grid(images: Vec<String>, out: &str, columns: i64, width: i64, ratio: f64, options: serde_json::Value) -> Result<Value, String> {    let imgs = load_all(&images)?;    let cols = columns.clamp(1, 50) as u32;    let gap = options.get("gap").and_then(Value::as_u64).unwrap_or(8).min(200) as u32;    let w = width.clamp(100, 8000) as u32;    let cw = (w.saturating_sub(gap * (cols + 1))) / cols;    let ch = ((cw as f64 / ratio.clamp(0.1, 10.0)).round() as u32).max(1);    if cw == 0 {        return Err("the gaps take the whole width: make it wider or the gaps smaller".into());    }    let rows = (imgs.len() as u32).div_ceil(cols);    let h = rows * ch + gap * (rows + 1);    if h > 16_000 {        return Err("the collage would be over 16,000 pixels high".into());    }    let cover = options.get("fit").and_then(Value::as_str) != Some("contain");    let mut canvas = RgbaImage::from_pixel(w, h, colour(&options));    for (i, img) in imgs.iter().enumerate() {        let (c, r) = (i as u32 % cols, i as u32 / cols);        let (x, y) = (gap + c * (cw + gap), gap + r * (ch + gap));        let cell = if cover { img.resize_to_fill(cw, ch, image::imageops::FilterType::Lanczos3) } else { img.resize(cw, ch, image::imageops::FilterType::Lanczos3) };        let (dx, dy) = ((cw - cell.width()) / 2, (ch - cell.height()) / 2);        image::imageops::overlay(&mut canvas, &cell.to_rgba8(), (x + dx) as i64, (y + dy) as i64);    }    save(&DynamicImage::ImageRgba8(canvas), out)?;    Ok(json!({ "width": w, "height": h, "count": imgs.len() }))}/// A collage of justified rows at `out`, as photo sites lay out albums: pictures keep their shape, each row is scaled to fill `width` exactly,/// and rows are about `row_height` pixels high (the last row too, unless it would be stretched). `options`: `gap` and `background` as for `collage_grid`./// `{ width, height, count, rows }`.pub fn collage_rows(images: Vec<String>, out: &str, width: i64, row_height: i64, options: serde_json::Value) -> Result<Value, String> {    let imgs = load_all(&images)?;    let gap = options.get("gap").and_then(Value::as_u64).unwrap_or(8).min(200) as f64;    let w = width.clamp(100, 8000) as f64;    let target = row_height.clamp(20, 4000) as f64;    let inner = w - 2.0 * gap;    // Fill each row with pictures at the target height until it is full, then scale it to fit.    let mut rows: Vec<(Vec<usize>, f64)> = vec![];    let mut cur: Vec<usize> = vec![];    let mut sum = 0.0;    for (i, img) in imgs.iter().enumerate() {        let aspect = img.width() as f64 / img.height().max(1) as f64;        cur.push(i);        sum += aspect;        let row_w = sum * target + gap * (cur.len() as f64 - 1.0);        if row_w >= inner {            let hgt = (inner - gap * (cur.len() as f64 - 1.0)) / sum;            rows.push((std::mem::take(&mut cur), hgt));            sum = 0.0;        }    }    if !cur.is_empty() {        let hgt = ((inner - gap * (cur.len() as f64 - 1.0)) / sum).min(target);        rows.push((cur, hgt));    }    let h = rows.iter().map(|(_, rh)| rh.round()).sum::<f64>() + gap * (rows.len() as f64 + 1.0);    if h > 16_000.0 {        return Err("the collage would be over 16,000 pixels high".into());    }    let mut canvas = RgbaImage::from_pixel(w as u32, h as u32, colour(&options));    let mut y = gap;    for (idx, rh) in &rows {        let rh = rh.round().max(1.0);        let mut x = gap;        for &i in idx {            let img = &imgs[i];            let iw = (img.width() as f64 * rh / img.height().max(1) as f64).round().max(1.0);            let cell = img.resize_exact(iw as u32, rh as u32, image::imageops::FilterType::Lanczos3);            image::imageops::overlay(&mut canvas, &cell.to_rgba8(), x as i64, y as i64);            x += iw + gap;        }        y += rh + gap;    }    save(&DynamicImage::ImageRgba8(canvas), out)?;    Ok(json!({ "width": w as i64, "height": h as i64, "count": imgs.len(), "rows": rows.len() }))}
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On Collage: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.