Media

Captions on images

Text drawn on pictures with a built-in font: captions at the top, middle or bottom, wrapped to fit, with an outline or a band behind them, meme-style top and bottom lines, and text measured before drawing.

Version 1.0.0 · 3 functions · 4 crates

#images#photos#fonts#social

Functions

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

caption_image(path: str, out_path: str, text: str, options: any) → anyor an error

text drawn on the picture at path, saved to out_path (PNG, JPEG or WebP by its extension; folders made). options: position ("top", "center", "bottom"), size (pixels; a twelfth of the height by default), color and outline (#rrggbb or #rrggbbaa; "" for no outline), outline_width, background (a band behind the text, "#00000099"), font ("bold", "sans", "condensed", "serif", "serif-bold", "mono"), align, margin, max_width (a share of the width, 0.9), uppercase. { path, width, height, lines }, lines the text as wrapped.

caption_meme(path: str, out_path: str, top: str, bottom: str) → stror an error

A meme: top and bottom lines in bold white capitals with a black outline, either may be "". The path written.

caption_measure(text: str, size: float, font: str) → anyor an error

How big text is set at size pixels in font: { width, height } in pixels, one line, for laying out before drawing.

Get it

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

Or with curl:

$ curl -O https://quillolang.org/plugins/files/caption.qwer$ curl -O https://quillolang.org/plugins/files/caption.qwe$ curl -O https://quillolang.org/plugins/files/caption_sample.png

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

main.qwe
use caption.qweruse caption_image, caption_meme from caption      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }ab_glyph = "0.2"dejavu = "=2.37.0"serde_json = { version = "1", features = ["preserve_order"] }

Example

caption.qwe
// A photo club's site: captions with the photographer's name on each picture, a "SOLD" band, and a meme generator for the club's chat.////   quillo run caption.qwe      then open http://localhost:8080/captioned.pnguse "caption.qwer"let PHOTO = "caption_sample.png"route GET /captioned.png {    caption_image(PHOTO, "data/caption/captioned.png", "Summer on the hills · photo by Zoë Ångström", { position: "bottom", size: 18.0, background: "#00000088", outline: "", font: "sans" })?    file("data/caption/captioned.png")}route GET /meme.png {    caption_meme(PHOTO, "data/caption/meme.jpg", "When the build passes", "on the first try")?    file("data/caption/meme.jpg")}route GET /info {    {        long: caption_image(PHOTO, "data/caption/long.png", "A caption long enough that it has to be wrapped over several lines to fit the picture", { size: 28.0, position: "center" })?,        sold: caption_image(PHOTO, "data/caption/sold.webp", "SOLD", { position: "top", color: "#ff3030", size: 40.0 })?.path,        measure: caption_measure("Hello, world", 32.0, "sans")?,        bad_font: caption_measure("x", 12.0, "comic").error,    }}
$ quillo run caption.qwe$ curl 'localhost:8080/info'$ curl 'localhost:8080/captioned.png'$ curl 'localhost:8080/meme.png'

Source

caption.qwer
//! Text drawn on pictures with a built-in font (DejaVu: Latin, Greek, Cyrillic and more): captions at the top, middle or bottom, wrapped to fit, with an outline or a band behind them for any background; meme-style top and bottom lines; and text measured.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }//! ab_glyph = "0.2"//! dejavu = "=2.37.0"//! serde_json = { version = "1", features = ["preserve_order"] }use ab_glyph::{point, Font, FontRef, PxScale, ScaleFont};use image::{Rgba, RgbaImage};use serde_json::{json, Value};fn font(name: &str) -> Result<FontRef<'static>, String> {    let data = match name.trim().to_lowercase().as_str() {        "" | "sans" => dejavu::sans::regular(),        "bold" | "sans-bold" => dejavu::sans::bold(),        "condensed" => dejavu::sans_condensed::bold(),        "serif" => dejavu::serif::regular(),        "serif-bold" => dejavu::serif::bold(),        "mono" => dejavu::sans_mono::regular(),        f => return Err(format!("`{}` is not a font here: sans, bold, condensed, serif, serif-bold, mono", f)),    };    FontRef::try_from_slice(data).map_err(|e| e.to_string())}fn color(c: &str) -> Result<Option<Rgba<u8>>, String> {    let h = c.trim().trim_start_matches('#');    if h.is_empty() || h == "none" { return Ok(None) }    let v = u32::from_str_radix(h, 16).map_err(|_| format!("`{}` is not a colour: #rrggbb or #rrggbbaa", c))?;    match h.len() {        6 => Ok(Some(Rgba([(v >> 16) as u8, (v >> 8) as u8, v as u8, 255]))),        8 => Ok(Some(Rgba([(v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8, v as u8]))),        _ => Err(format!("`{}` is not a colour: #rrggbb or #rrggbbaa", c)),    }}fn line_width(f: &FontRef, scale: PxScale, s: &str) -> f32 {    let sf = f.as_scaled(scale);    let mut w = 0.0;    let mut prev = None;    for c in s.chars() {        let id = f.glyph_id(c);        if let Some(p) = prev { w += sf.kern(p, id) }        w += sf.h_advance(id);        prev = Some(id);    }    w}fn wrap(f: &FontRef, scale: PxScale, text: &str, max: f32) -> Vec<String> {    let mut lines = Vec::new();    for para in text.split('\n') {        let mut line = String::new();        for word in para.split_whitespace() {            let candidate = if line.is_empty() { word.to_string() } else { format!("{} {}", line, word) };            if line_width(f, scale, &candidate) <= max || line.is_empty() { line = candidate } else { lines.push(std::mem::take(&mut line)); line = word.to_string() }        }        lines.push(line);    }    lines}fn blend(img: &mut RgbaImage, x: i64, y: i64, c: Rgba<u8>, cover: f32) {    if x < 0 || y < 0 || x >= img.width() as i64 || y >= img.height() as i64 { return }    let a = cover.clamp(0.0, 1.0) * c[3] as f32 / 255.0;    let p = img.get_pixel_mut(x as u32, y as u32);    for i in 0..3 { p[i] = (p[i] as f32 * (1.0 - a) + c[i] as f32 * a).round() as u8 }    p[3] = (p[3] as f32 + (255.0 - p[3] as f32) * a).round() as u8;}struct Opts { position: String, size: f32, color: Rgba<u8>, outline: Option<Rgba<u8>>, outline_width: f32, background: Option<Rgba<u8>>, font: String, align: String, margin: f32, max_width: f32, upper: bool }fn opts(o: &Value, w: u32, h: u32) -> Result<Opts, String> {    let s = |k: &str, d: &str| o.get(k).and_then(|v| v.as_str()).unwrap_or(d).to_string();    let n = |k: &str| o.get(k).and_then(|v| v.as_f64());    let size = n("size").unwrap_or((h.min(w * 2) as f64 / 12.0).max(10.0)) as f32;    Ok(Opts {        position: s("position", "bottom").to_lowercase(),        size,        color: color(&s("color", "#ffffff"))?.unwrap_or(Rgba([255, 255, 255, 255])),        outline: color(&s("outline", "#000000"))?,        outline_width: n("outline_width").map(|x| x as f32).unwrap_or((size / 14.0).max(1.0)),        background: color(&s("background", ""))?,        font: s("font", "bold"),        align: s("align", "center").to_lowercase(),        margin: n("margin").map(|x| x as f32).unwrap_or(size * 0.5),        max_width: n("max_width").unwrap_or(0.9) as f32,        upper: o.get("uppercase").and_then(|v| v.as_bool()).unwrap_or(false),    })}// Draws `text` on `img`; the lines drawn.fn draw(img: &mut RgbaImage, text: &str, o: &Opts) -> Result<Vec<String>, String> {    let f = font(&o.font)?;    let (w, h) = (img.width() as f32, img.height() as f32);    let scale = PxScale::from(o.size);    let sf = f.as_scaled(scale);    let text = if o.upper { text.to_uppercase() } else { text.to_string() };    let lines = wrap(&f, scale, &text, w * o.max_width.clamp(0.1, 1.0));    let line_h = (sf.ascent() - sf.descent() + sf.line_gap()).ceil();    let block_h = line_h * lines.len() as f32;    let top = match o.position.as_str() {        "top" => o.margin,        "center" | "middle" => (h - block_h) / 2.0,        "bottom" => h - o.margin - block_h,        p => return Err(format!("position is top, center or bottom, not `{}`", p)),    };    // The coverage of every glyph, in one mask the size of the picture.    let (iw, ih) = (img.width() as usize, img.height() as usize);    let mut mask = vec![0f32; iw * ih];    let mut widest: f32 = 0.0;    let mut lefts = Vec::new();    for (i, line) in lines.iter().enumerate() {        let lw = line_width(&f, scale, line);        widest = widest.max(lw);        let x0 = match o.align.as_str() { "left" => o.margin, "right" => w - o.margin - lw, _ => (w - lw) / 2.0 };        lefts.push((x0, lw));        let baseline = top + i as f32 * line_h + sf.ascent();        let mut x = x0;        let mut prev = None;        for c in line.chars() {            let id = f.glyph_id(c);            if let Some(p) = prev { x += sf.kern(p, id) }            let g = id.with_scale_and_position(scale, point(x, baseline));            if let Some(og) = f.outline_glyph(g) {                let b = og.px_bounds();                og.draw(|gx, gy, cov| {                    let (px, py) = (b.min.x as i64 + gx as i64, b.min.y as i64 + gy as i64);                    if px >= 0 && py >= 0 && (px as usize) < iw && (py as usize) < ih { let m = &mut mask[py as usize * iw + px as usize]; *m = m.max(cov) }                });            }            x += sf.h_advance(id);            prev = Some(id);        }    }    if let Some(bg) = o.background {        let pad = o.size * 0.3;        let (y0, y1) = ((top - pad).max(0.0) as i64, (top + block_h + pad).min(h) as i64);        let (x0, x1) = match o.align.as_str() { "left" | "right" => (lefts.iter().map(|l| l.0).fold(f32::MAX, f32::min) - pad, lefts.iter().map(|l| l.0 + l.1).fold(0.0, f32::max) + pad), _ => ((w - widest) / 2.0 - pad, (w + widest) / 2.0 + pad) };        for y in y0..y1 { for x in (x0.max(0.0) as i64)..(x1.min(w) as i64) { blend(img, x, y, bg, 1.0) } }    }    if let Some(oc) = o.outline {        let r = o.outline_width.ceil() as i64;        let rr = o.outline_width * o.outline_width;        let mut grown = vec![0f32; iw * ih];        for y in 0..ih as i64 {            for x in 0..iw as i64 {                let m = mask[y as usize * iw + x as usize];                if m <= 0.0 { continue }                for dy in -r..=r { for dx in -r..=r {                    if (dx * dx + dy * dy) as f32 > rr + 0.5 { continue }                    let (nx, ny) = (x + dx, y + dy);                    if nx >= 0 && ny >= 0 && (nx as usize) < iw && (ny as usize) < ih { let g = &mut grown[ny as usize * iw + nx as usize]; *g = g.max(m) }                } }            }        }        for y in 0..ih { for x in 0..iw { let g = grown[y * iw + x]; if g > 0.0 { blend(img, x as i64, y as i64, oc, g) } } }    }    for y in 0..ih { for x in 0..iw { let m = mask[y * iw + x]; if m > 0.0 { blend(img, x as i64, y as i64, o.color, m) } } }    Ok(lines)}fn save(img: RgbaImage, out_path: &str) -> Result<(), String> {    if let Some(d) = std::path::Path::new(out_path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }    let lower = out_path.to_lowercase();    if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {        image::DynamicImage::ImageRgba8(img).to_rgb8().save(out_path).map_err(|e| format!("{}: {}", out_path, e))    } else {        img.save(out_path).map_err(|e| format!("{}: {}", out_path, e))    }}/// `text` drawn on the picture at `path`, saved to `out_path` (PNG, JPEG or WebP by its extension; folders made). `options`: `position` (`"top"`, `"center"`, `"bottom"`), `size` (pixels; a twelfth of the height by default), `color` and `outline` (`#rrggbb` or `#rrggbbaa`; `""` for no outline), `outline_width`, `background` (a band behind the text, `"#00000099"`), `font` (`"bold"`, `"sans"`, `"condensed"`, `"serif"`, `"serif-bold"`, `"mono"`), `align`, `margin`, `max_width` (a share of the width, 0.9), `uppercase`./// `{ path, width, height, lines }`, `lines` the text as wrapped.pub fn caption_image(path: &str, out_path: &str, text: &str, options: serde_json::Value) -> Result<Value, String> {    let mut img = image::open(path).map_err(|e| format!("{}: {}", path, e))?.to_rgba8();    let o = opts(&options, img.width(), img.height())?;    let lines = draw(&mut img, text, &o)?;    let (w, h) = img.dimensions();    save(img, out_path)?;    Ok(json!({ "path": out_path, "width": w, "height": h, "lines": lines }))}/// A meme: `top` and `bottom` lines in bold white capitals with a black outline, either may be `""`. The path written.pub fn caption_meme(path: &str, out_path: &str, top: &str, bottom: &str) -> Result<String, String> {    let mut img = image::open(path).map_err(|e| format!("{}: {}", path, e))?.to_rgba8();    for (text, pos) in [(top, "top"), (bottom, "bottom")] {        if text.trim().is_empty() { continue }        let o = opts(&json!({ "position": pos, "uppercase": true, "font": "condensed", "size": (img.height() as f64 / 9.0).max(12.0) }), img.width(), img.height())?;        draw(&mut img, text, &o)?;    }    save(img, out_path)?;    Ok(out_path.to_string())}/// How big `text` is set at `size` pixels in `font`: `{ width, height }` in pixels, one line, for laying out before drawing.pub fn caption_measure(text: &str, size: f64, font: &str) -> Result<Value, String> {    let f = self::font(font)?;    let scale = PxScale::from(size as f32);    let sf = f.as_scaled(scale);    Ok(json!({ "width": line_width(&f, scale, text).ceil() as i64, "height": (sf.ascent() - sf.descent()).ceil() as i64 }))}
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On Captions on images: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.