Media

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

#images#social#seo#blogs

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 error

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).

og_image(out: str, title: str, subtitle: str, options: any) → anyor an error

The 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.qwe

Or 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.png

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

main.qwe
use og_image.qweruse og_image_svg, og_image from og_image      // Quillo 0.6: only these

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

[dependencies]
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }ttf-parser = "0.25"dejavu = "2.37"base64 = "0.22"

Example

og_image.qwe
// 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

og_image.qwer
//! 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")}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 }))}
Thread
79Useful? Like it, or add to its thread below.

Thread 0 posts

On Link preview images: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.