Link preview images
Link-preview pictures (1200 × 630 PNG) with a title and subtitle, wrapped and sized to fit, in your colours, with a font built in.
Version 1.0.0 · 2 functions · 4 crates
Functions
What use og_image.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use og_image_svg, og_image from og_image (Quillo 0.6).
og_image_svg(title: str, subtitle: str, options: any) → stror an errorThe link preview's SVG: title (wrapped to at most three lines, from 76 px down to 44 px to fit, then cut with "…"), subtitle (two lines at most), and options (or null): background, color, muted (the subtitle's colour), accent as "#rrggbb", site (a name at the bottom) and logo (a PNG or JPEG path).
og_image(out: str, title: str, subtitle: str, options: any) → anyor an errorThe link preview as a 1200 × 630 PNG at out (folders made), for <meta property="og:image">: { width, height, path }.
Get it
$ quillo install og_image # og_image.qwer into this folder$ quillo install og_image --example # and its example, og_image.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/og_image.qwer$ curl -O https://quillolang.org/plugins/files/og_image.qwe$ curl -O https://quillolang.org/plugins/files/og_image_sample.pngThen, in your program, all of it or only what you call:
use og_image.qweruse og_image_svg, og_image from og_image // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }ttf-parser = "0.25"dejavu = "2.37"base64 = "0.22"Example
// Each blog post gets its own link-preview picture, made once and served to social sites.//// quillo run og_image.qwe then open http://localhost:8080/posts/1use "og_image.qwer"let posts = [ { id: 1, title: "How we roast our beans: a long story about heat, time and patience", summary: "Notes from ten years of roasting in Lyon, with the mistakes left in." }, { id: 2, title: "Spring menu", summary: "Three new dishes from 1 April." },]fn preview(p) = og_image(`data/og/post-${p.id}.png`, p.title, p.summary, { site: "quillo.cafe", logo: "og_image_sample.png" })route GET /og/:id(int)/preview.png { let p = posts | find((x) => x.id == id) ?? reply 404 "No such post" preview(p)? file(`data/og/post-${id}.png`)}route GET /posts/:id(int) { let p = posts | find((x) => x.id == id) ?? reply 404 "No such post" <html> <head> <title>{p.title}</title> <meta property="og:title" content={p.title}/> <meta property="og:image" content={`https://quillo.cafe/og/${id}/preview.png`}/> </head> <body><h1>{p.title}</h1><img src={`/og/${id}/preview.png`} width="600" alt=""/></body> </html>}route GET /made = { image: preview(posts[0])?, svg_lines: len(re.find_all(og_image_svg(posts[0].title, "", null)?, "<text ")) }$ quillo run og_image.qwe$ curl 'localhost:8080/made'$ curl 'localhost:8080/og/2/preview.png'$ curl 'localhost:8080/posts/1'Source
//! Link-preview pictures (Open Graph, 1200 × 630 PNG) with a title and a subtitle, wrapped and sized to fit, in your colours: a font is built in, so it works on any server.//! version: 1.0.0//!//! [dependencies]//! resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }//! ttf-parser = "0.25"//! dejavu = "2.37"//! base64 = "0.22"use base64::Engine;use resvg::{tiny_skia, usvg};use serde_json::{json, Value};use std::sync::{Arc, OnceLock};const W: f64 = 1200.0;const H: f64 = 630.0;const PAD: f64 = 80.0;fn fonts() -> Arc<usvg::fontdb::Database> { static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new(); DB.get_or_init(|| { let mut db = usvg::fontdb::Database::new(); db.load_font_data(dejavu::sans::regular().to_vec()); db.load_font_data(dejavu::sans::bold().to_vec()); db.set_sans_serif_family("DejaVu Sans"); Arc::new(db) }) .clone()}// How wide `s` is in DejaVu Sans (bold or not) at `size` pixels.fn width(s: &str, size: f64, bold: bool) -> f64 { let data = if bold { dejavu::sans::bold() } else { dejavu::sans::regular() }; let Ok(f) = ttf_parser::Face::parse(data, 0) else { return s.chars().count() as f64 * size * 0.6 }; let upem = f.units_per_em() as f64; s.chars().map(|c| f.glyph_index(c).and_then(|g| f.glyph_hor_advance(g)).unwrap_or((upem * 0.6) as u16) as f64).sum::<f64>() / upem * size}// Words into lines no wider than `max`; a word too long for a line is cut. At most `lines` lines, the last ending in "…" when text is left over.fn wrap(text: &str, size: f64, bold: bool, max: f64, lines: usize) -> (Vec<String>, bool) { let mut out: Vec<String> = vec![]; let mut cur = String::new(); for word in text.split_whitespace() { let tryit = if cur.is_empty() { word.to_string() } else { format!("{} {}", cur, word) }; if width(&tryit, size, bold) <= max { cur = tryit; continue; } if !cur.is_empty() { out.push(std::mem::take(&mut cur)); } let mut w = String::new(); for ch in word.chars() { if width(&format!("{}{}", w, ch), size, bold) > max && !w.is_empty() { out.push(std::mem::take(&mut w)); } w.push(ch); } cur = w; } if !cur.is_empty() { out.push(cur); } let over = out.len() > lines; if over { out.truncate(lines); let last = out.last_mut().unwrap(); while !last.is_empty() && width(&format!("{}…", last), size, bold) > max { last.pop(); } *last = format!("{}…", last.trim_end()); } (out, over)}fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)}fn colour(o: &Value, k: &str, d: &str) -> String { match o.get(k).and_then(Value::as_str) { Some(c) if c.starts_with('#') && [4, 7].contains(&c.len()) && c[1..].chars().all(|x| x.is_ascii_hexdigit()) => c.to_string(), _ => d.to_string(), }}fn svg(title: &str, subtitle: &str, o: &Value) -> Result<String, String> { let bg = colour(o, "background", "#0f172a"); let fg = colour(o, "color", "#f8fafc"); let muted = colour(o, "muted", "#cbd5e1"); let accent = colour(o, "accent", "#3987e5"); let site = o.get("site").and_then(Value::as_str).unwrap_or(""); let max = W - 2.0 * PAD; // The largest title size, from 76 down to 44 px, that fits in three lines. let mut size = 76.0; let (mut lines, mut over) = wrap(title, size, true, max, 3); while over && size > 44.0 { size -= 4.0; (lines, over) = wrap(title, size, true, max, 3); } let (sub, _) = wrap(subtitle, 34.0, false, max, 2); let lh = size * 1.18; let block = lines.len() as f64 * lh + if sub.is_empty() { 0.0 } else { 24.0 + sub.len() as f64 * 44.0 }; let mut y = ((H - block) / 2.0).max(PAD) + size * 0.8 - 20.0; let mut out = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><rect width="{W}" height="{H}" fill="{bg}"/><rect x="{PAD}" y="{}" width="96" height="8" rx="4" fill="{accent}"/>"#, PAD - 20.0); for l in &lines { out += &format!(r#"<text x="{PAD}" y="{y:.0}" font-family="DejaVu Sans" font-weight="bold" font-size="{size}" fill="{fg}">{}</text>"#, esc(l)); y += lh; } y += 24.0 - lh + 44.0; for l in &sub { out += &format!(r#"<text x="{PAD}" y="{y:.0}" font-family="DejaVu Sans" font-size="34" fill="{muted}">{}</text>"#, esc(l)); y += 44.0; } let mut foot_x = PAD; if let Some(logo) = o.get("logo").and_then(Value::as_str).filter(|l| !l.is_empty()) { let bytes = std::fs::read(logo).map_err(|e| format!("{}: {}", logo, e))?; let mime = if bytes.starts_with(&[0x89, b'P']) { "image/png" } else if bytes.starts_with(&[0xFF, 0xD8]) { "image/jpeg" } else { return Err(format!("{}: the logo is a PNG or a JPEG", logo)) }; let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); out += &format!(r#"<image x="{PAD}" y="{}" width="56" height="56" href="data:{mime};base64,{b64}"/>"#, H - PAD - 40.0); foot_x += 72.0; } if !site.is_empty() { out += &format!(r#"<text x="{foot_x}" y="{}" font-family="DejaVu Sans" font-weight="bold" font-size="28" fill="{muted}">{}</text>"#, H - PAD + 4.0, esc(site)); } out += "</svg>"; Ok(out)}/// The link preview's SVG: `title` (wrapped to at most three lines, from 76 px down to 44 px to fit, then cut with "…"), `subtitle` (two lines at most),/// and `options` (or null): `background`, `color`, `muted` (the subtitle's colour), `accent` as "#rrggbb", `site` (a name at the bottom) and `logo` (a PNG or JPEG path).pub fn og_image_svg(title: &str, subtitle: &str, options: serde_json::Value) -> Result<String, String> { svg(title, subtitle, &options)}/// The link preview as a 1200 × 630 PNG at `out` (folders made), for `<meta property="og:image">`: `{ width, height, path }`.pub fn og_image(out: &str, title: &str, subtitle: &str, options: serde_json::Value) -> Result<Value, String> { let s = svg(title, subtitle, &options)?; let mut opt = usvg::Options::default(); opt.fontdb = fonts(); opt.font_family = "DejaVu Sans".into(); opt.resources_dir = None; let tree = usvg::Tree::from_str(&s, &opt).map_err(|e| e.to_string())?; let mut pixmap = tiny_skia::Pixmap::new(W as u32, H as u32).ok_or("cannot make the picture")?; resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); if let Some(dir) = std::path::Path::new(out).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?; } pixmap.save_png(out).map_err(|e| format!("{}: {}", out, e))?; Ok(json!({ "width": W as i64, "height": H as i64, "path": out }))}Related
caption.qwer · 1.0.0Text 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.7 views · 7 downloadsCollage 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 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 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 downloadsAnimated GIF gif.qwer · 1.0.0Animated GIFs made from a list of pictures with the delay and size you choose, and GIFs read back: frames, size and length.6 views · 7 downloadsASCII art ascii_art.qwer · 1.0.0Pictures turned into ASCII art: characters from light to dark, plain for terminals and emails, or coloured as HTML.6 views · 10 downloadsThread 0 posts
On Link preview images: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.