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
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 errortext 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 errorA 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 errorHow 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.qweOr 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.pngThen, in your program, all of it or only what you call:
use caption.qweruse caption_image, caption_meme from caption // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
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
// 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
//! 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 }))}Related
collage.qwer · 1.0.0Several pictures made into one: a grid of equal cells with gaps, or justified rows that keep each picture's shape, on your background.7 views · 7 downloadsSmart crop smart_crop.qwer · 1.0.0Thumbnails cropped to the interesting part of a picture, as smartcrop.js does: edges, skin tones and strong colours scored, the best window of the shape you ask found, and the crop resized to the exact size.7 views · 8 downloadsEXIF exif.qwer · 1.0.0A photo's EXIF: camera and lens, when it was taken, where (GPS), exposure and orientation, from JPEG, HEIC, PNG and WebP.7 views · 5 downloadsEXIF strip exif_strip.qwer · 1.0.0Photos made safe to publish: GPS, camera and other EXIF, XMP, IPTC and comments taken out of JPEG, PNG and WebP, without re-encoding.6 views · 7 downloadsImage edits image_ops.qwer · 1.0.0Crop, rotate, flip, grayscale, blur, and phone photos turned upright by their EXIF orientation, saved as JPEG, PNG, WebP or GIF.7 views · 7 downloadsImage tiles image_tiles.qwer · 1.0.0Pictures cut into tiles for zoomable viewers, puzzles or a grid of posts, and many pictures made into one contact sheet of thumbnails.8 views · 8 downloadsThread 0 posts
On Captions on images: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.