//! Social cards: the picture a link to a page unfurls into, in a chat or a //! post. Each is an SVG written here, as the pages are, and rasterized in //! process: no browser, and no fonts but those compiled in, Inter, Noto //! Color Emoji for what Inter lacks, and JetBrains Mono for the name, cut to //! its letters, so a card looks the same on every host. //! //! A card's URL carries a hash of its SVG, so the picture can be cached //! forever: when what it shows changes, so does the URL its page names. //! A person's picture is named in the SVG by its address, which changes //! with the picture, and its bytes are handed to the renderer beside it. use crate::languages::Language; use resvg::{tiny_skia, usvg}; use sha2::{Digest, Sha256}; use skrifa::MetadataProvider; use std::collections::{HashMap, VecDeque}; use std::fmt::Write; use std::sync::{Arc, LazyLock, Mutex}; /// Icons, each a 16-unit path: GitHub's Octicons, as the pages draw them. mod icon { pub const ISSUE: &str = "M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"; pub const PULL: &str = "M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"; pub const COMMENTS: &str = "M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"; pub const REVIEW: &str = "M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 13H8.061l-2.574 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25v-8.5C0 1.784.784 1 1.75 1ZM1.5 2.75v8.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Zm5.28 1.72a.75.75 0 0 1 0 1.06L5.31 7l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.75.75 0 0 1 1.06 0Zm2.44 0a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L10.69 7 9.22 5.53a.75.75 0 0 1 0-1.06Z"; pub const FILES: &str = "M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"; } fn esc(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), c => out.push(c), } } out } pub const WIDTH: u32 = 1200; pub const HEIGHT: u32 = 630; const PAD: f32 = 80.0; /// Where the text of the last row sits. const FOOT: f32 = 550.0; const FG: &str = "#1c1d20"; const MUTE: &str = "#6a6d75"; const ACCENT: &str = "#2f6af5"; static REGULAR: &[u8] = include_bytes!("../fonts/InterDisplay-Regular.ttf"); static SEMIBOLD: &[u8] = include_bytes!("../fonts/InterDisplay-SemiBold.ttf"); static EMOJI: &[u8] = include_bytes!("../fonts/NotoColorEmoji-COLRv1.ttf"); /// Only what `~ knotless` needs, as the site's header sets it. static MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-SemiBold.ttf"); /// Read where they are compiled in, not copied. static FONTS: LazyLock> = LazyLock::new(|| { let mut db = usvg::fontdb::Database::new(); for data in [REGULAR, SEMIBOLD, EMOJI, MONO] { db.load_font_source(usvg::fontdb::Source::Binary(Arc::new(data))); } Arc::new(db) }); fn emoji() -> skrifa::FontRef<'static> { skrifa::FontRef::new(EMOJI).expect("the fonts compiled in parse") } #[derive(Clone, Copy)] enum Weight { Regular, Semibold, } impl Weight { fn font(self) -> skrifa::FontRef<'static> { let data = match self { Weight::Regular => REGULAR, Weight::Semibold => SEMIBOLD, }; skrifa::FontRef::new(data).expect("the fonts compiled in parse") } fn css(self) -> u16 { match self { Weight::Regular => 400, Weight::Semibold => 600, } } } /// Whether `c` draws as part of the character before it, `prev`, rather /// than on its own: the joiners, selectors, skin tones and tags of an emoji /// sequence, a keycap, what a joiner joins, and the second letter of a flag /// (`flag` being whether `prev` is a flag's first). fn joins(prev: Option, c: char, flag: bool) -> bool { let Some(prev) = prev else { return false; }; matches!( c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{20E3}' | '\u{1F3FB}'..='\u{1F3FF}' | '\u{E0020}'..='\u{E007F}' ) || prev == '\u{200D}' || (flag && is_flag_letter(c)) } fn is_flag_letter(c: char) -> bool { ('\u{1F1E6}'..='\u{1F1FF}').contains(&c) } /// `text` split where a line may break inside a word: between the /// characters drawn apart, each with the sequence it heads. fn clusters(text: &str) -> Vec<&str> { let mut out = Vec::new(); let mut start = 0; let mut prev = None; let mut flag = false; for (i, c) in text.char_indices() { if joins(prev, c, flag) { flag = false; } else { if i > start { out.push(&text[start..i]); } start = i; flag = is_flag_letter(c); } prev = Some(c); } if start < text.len() { out.push(&text[start..]); } out } /// How wide `text` is set at `size`: each cluster as wide as its first /// character, in Inter, or the emoji if Inter lacks it. Kerning is left /// out, which errs wide. fn measure(text: &str, size: f32, weight: Weight) -> f32 { let size = skrifa::instance::Size::new(size); let at = skrifa::instance::LocationRef::default(); let (text_font, emoji) = (weight.font(), emoji()); let (text_map, emoji_map) = (text_font.charmap(), emoji.charmap()); let text_metrics = text_font.glyph_metrics(size, at); let emoji_metrics = emoji.glyph_metrics(size, at); clusters(text) .into_iter() .map(|cluster| { let c = cluster.chars().next().expect("a cluster is not empty"); let advance = match (text_map.map(c), emoji_map.map(c)) { (None, Some(glyph)) => emoji_metrics.advance_width(glyph), // What Inter lacks and the emoji too is drawn as Inter's box. (glyph, _) => text_metrics.advance_width(glyph.unwrap_or_default()), }; advance.unwrap_or_default() }) .sum() } /// `text` broken into at most `max` lines no wider than `width`, the last /// ending in an ellipsis if it had to be cut. fn wrap(text: &str, size: f32, weight: Weight, width: f32, max: usize) -> Vec { let fits = |s: &str| measure(s, size, weight) <= width; let mut lines: Vec = Vec::new(); let mut line = String::new(); for word in text.split_whitespace() { if lines.len() > max { break; } let joined = format!("{line} {word}"); let joined = joined.trim_start(); if fits(joined) { line = joined.to_string(); continue; } if !line.is_empty() { lines.push(std::mem::take(&mut line)); } // A word wider than a line is broken where it overflows, between // what is drawn apart. for cluster in clusters(word) { let alone = line.is_empty(); line.push_str(cluster); if !alone && !fits(&line) { line.truncate(line.len() - cluster.len()); lines.push(std::mem::replace(&mut line, cluster.to_string())); } } } if !line.is_empty() { lines.push(line); } if lines.len() > max { lines.truncate(max); let last = lines.last_mut().expect("max is at least one"); while !fits(&format!("{last}…")) && !last.is_empty() { let end = clusters(last).last().expect("not empty").len(); last.truncate(last.len() - end); } let kept = last.trim_end().len(); last.truncate(kept); last.push('…'); } lines } /// Whether `c` is drawn from the emoji, as Inter lacks it. fn is_emoji(c: char) -> bool { Weight::Regular.font().charmap().map(c).is_none() && emoji().charmap().map(c).is_some() } /// `text`, left on `x`, escaped: in Inter, and each run of what Inter /// lacks in the emoji. Each run is placed on its own, so it is shaped in /// its font alone: the renderer, given runs in two fonts to shape together, /// drops sequences of emoji, or fails on them. fn spans(text: &str, x: f32, size: f32, weight: Weight) -> String { // Inter has all of ASCII. if text.is_ascii() { return esc(text); } let mut runs: Vec<(bool, String)> = Vec::new(); for cluster in clusters(text) { let emoji = is_emoji(cluster.chars().next().expect("a cluster is not empty")); match runs.last_mut() { Some((was, run)) if *was == emoji => run.push_str(cluster), _ => runs.push((emoji, cluster.to_string())), } } if runs.iter().all(|(emoji, _)| !emoji) { return esc(text); } let mut out = String::new(); let mut x = x; for (emoji, run) in runs { let family = if emoji { r#" font-family="Noto Color Emoji""# } else { "" }; write!(out, r#"{}"#, esc(&run)).unwrap(); x += measure(&run, size, weight); } out } /// Lines of text, `leading` apart, the first on `y`. fn lines( out: &mut String, lines: &[String], x: f32, y: f32, leading: f32, (size, weight, fill): (f32, Weight, &str), ) { for (i, line) in lines.iter().enumerate() { write!( out, r#"{}"#, y + leading * i as f32, style(size, weight, fill), spans(line, x, size, weight) ) .unwrap(); } } fn style(size: f32, weight: Weight, fill: &str) -> String { format!( r#"font-size="{size}" font-weight="{}" fill="{fill}""#, weight.css() ) } /// A 16-unit icon, `size` across, its top left at `x`, `y`. fn icon(out: &mut String, path: &str, x: f32, y: f32, size: f32, fill: &str) { write!( out, r#""#, size / 16.0 ) .unwrap(); } /// The site's name as its header sets it, `~ knotless` in a semibold mono, /// `size` high, its left at `x`. fn brand(out: &mut String, x: f32, baseline: f32, size: f32) { write!( out, r#"~ knotless"#, style(size, Weight::Semibold, FG) ) .unwrap(); } /// How wide `brand` draws at `size`. fn brand_width(size: f32) -> f32 { let font = skrifa::FontRef::new(MONO).expect("the fonts compiled in parse"); let metrics = font.glyph_metrics( skrifa::instance::Size::new(size), skrifa::instance::LocationRef::default(), ); let map = font.charmap(); "~ knotless" .chars() .filter_map(|c| metrics.advance_width(map.map(c)?)) .sum() } /// The hue a name's mark is drawn in. pub(crate) fn hue(name: &str) -> u32 { name.bytes() .fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32)) % 360 } /// The letter a name's mark shows. pub(crate) fn initial(name: &str) -> char { name.chars().find(|c| c.is_alphanumeric()).unwrap_or('?') } /// A name's round mark, `r` in radius, centered on `cx`, `cy`: its /// initial, and over it the picture at `picture`, cut to the circle. A /// picture the renderer is not given, or cannot read, leaves the initial. fn avatar(out: &mut String, name: &str, picture: Option<&str>, (cx, cy, r): (f32, f32, f32)) { write!( out, r#"{}"#, hue(name), cy + r * 0.36, style(r, Weight::Semibold, "#fff"), esc(&initial(name).to_uppercase().to_string()) ) .unwrap(); let Some(picture) = picture else { return; }; write!( out, r#""#, esc(picture), cx - r, cy - r, r * 2.0, r * 2.0 ) .unwrap(); } /// The day `at`, a timestamp, falls on in UTC, as `November 1, 2026`; /// none if it is no timestamp. fn date(at: &str) -> Option { let at: jiff::Timestamp = at.parse().ok()?; Some( at.to_zoned(jiff::tz::TimeZone::UTC) .strftime("%B %-d, %Y") .to_string(), ) } fn plural(n: usize, one: &str, many: &str) -> String { format!("{n} {}", if n == 1 { one } else { many }) } /// What a card shows. #[derive(Debug, Clone, PartialEq)] pub enum Card { /// The front page. Home, Repo { owner: String, /// The address of the owner's picture, if it has one. avatar: Option, name: String, description: Option, /// Open pulls and issues. pulls: usize, issues: usize, /// What it is written in, drawn along the foot. languages: Vec, }, /// A pull or an issue. Item { owner: String, /// The address of the owner's picture, if it has one. avatar: Option, name: String, pull: bool, title: String, author: String, /// When it was opened, as its record says. opened: Option, comments: usize, /// A pull's reviews; none for an issue. reviews: usize, /// What a pull changes, if its commits could be read. size: Option, }, } /// What a pull changes, as its card counts it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Size { pub commits: usize, pub files: usize, pub added: usize, pub removed: usize, } impl Size { /// Of five squares, how many are green for lines added and how many /// red for lines removed, as GitHub draws them; the rest are grey. /// Each side that changed anything shows at least one. fn squares(self) -> (usize, usize) { let all = self.added + self.removed; if all == 0 { return (0, 0); } let red = (5 * self.removed).div_ceil(all); let red = red.min(5 - usize::from(self.added > 0)); (5 - red, red) } } const ADDED: &str = "#1a7f37"; const REMOVED: &str = "#cf222e"; const UNCHANGED: &str = "#d0d7de"; impl Card { /// The address of the picture it shows, if any. pub fn picture(&self) -> Option<&str> { match self { Card::Home => None, Card::Repo { avatar, .. } | Card::Item { avatar, .. } => avatar.as_deref(), } } pub fn svg(&self) -> String { let mut out = format!( r##""## ); let wide = WIDTH as f32 - PAD * 2.0; let bar = match self { Card::Home => { brand(&mut out, PAD, 168.0, 68.0); let head = wrap( "Git that lives in your atmosphere account.", 80.0, Weight::Semibold, wide, 2, ); let at = 330.0; lines(&mut out, &head, PAD, at, 92.0, (80.0, Weight::Semibold, FG)); let sub = wrap( "Repositories as records on your own PDS. Clone and push with plain git; agents read, review and merge over MCP.", 36.0, Weight::Regular, wide, 2, ); lines( &mut out, &sub, PAD, at + 92.0 + 80.0, 50.0, (36.0, Weight::Regular, MUTE), ); ACCENT.to_string() } Card::Repo { owner, avatar, name, description, pulls, issues, .. } => { top(&mut out, (owner, avatar.as_deref()), None); let title = wrap(name, 84.0, Weight::Semibold, wide, 2); lines( &mut out, &title, PAD, 268.0, 96.0, (84.0, Weight::Semibold, FG), ); let below = 268.0 + 96.0 * (title.len() - 1) as f32 + 72.0; // As many lines as fit above the last row. let room = ((470.0 - below) / 48.0) as usize + 1; let about = wrap( description.as_deref().unwrap_or_default(), 36.0, Weight::Regular, wide, room, ); lines( &mut out, &about, PAD, below, 48.0, (36.0, Weight::Regular, MUTE), ); brand(&mut out, PAD, FOOT, 44.0); // The counts, from the right edge leftward. let mut right = WIDTH as f32 - PAD; for (path, text) in [ (icon::PULL, plural(*pulls, "open pull", "open pulls")), (icon::ISSUE, plural(*issues, "open issue", "open issues")), ] { let w = measure(&text, 32.0, Weight::Regular); write!( out, r#"{}"#, style(32.0, Weight::Regular, MUTE), text ) .unwrap(); icon(&mut out, path, right - w - 42.0, FOOT - 28.0, 32.0, MUTE); right -= w + 42.0 + 40.0; } format!("hsl({}, 45%, 52%)", hue(owner)) } Card::Item { owner, avatar, name, pull, title, author, opened, comments, reviews, size, } => { top(&mut out, (owner, avatar.as_deref()), Some(name)); // A pull's counts take a row above the last, so a title // three lines long is set smaller to leave it room. let mut head = wrap(title, 72.0, Weight::Semibold, wide, 3); let (mut set, mut at) = ((72.0, 86.0), 262.0); if *pull && head.len() == 3 { head = wrap(title, 58.0, Weight::Semibold, wide, 3); (set, at) = ((58.0, 68.0), 238.0); } lines( &mut out, &head, PAD, at, set.1, (set.0, Weight::Semibold, FG), ); if *pull { counts(&mut out, *comments, *reviews, *size); } let mark = brand_width(34.0); // A pull's comments are counted above; its commits here. let mut said = vec![author.clone()]; said.extend(opened.as_deref().and_then(date)); said.extend(match (*pull, size) { (false, _) => Some(plural(*comments, "comment", "comments")), (true, Some(size)) => Some(plural(size.commits, "commit", "commits")), (true, None) => None, }); let room = WIDTH as f32 - PAD - mark - 48.0 - PAD; let by = wrap(&said.join(" · "), 32.0, Weight::Regular, room, 1); lines(&mut out, &by, PAD, FOOT, 0.0, (32.0, Weight::Regular, MUTE)); brand(&mut out, WIDTH as f32 - PAD - mark, FOOT, 34.0); format!("hsl({}, 45%, 52%)", hue(owner)) } }; write!( out, r#""#, HEIGHT - 14 ) .unwrap(); if let Card::Repo { languages, .. } = self { language_bar(&mut out, languages); } out.push_str(""); out } } /// Where a pull's counts sit, above the last row. const COUNTS: f32 = 456.0; /// A pull's counts, left to right as GitHub's cards draw them: its /// comments, reviews and files, then the lines it adds and removes and /// five squares of their share. fn counts(out: &mut String, comments: usize, reviews: usize, size: Option) { let (text, gap) = (30.0, 36.0); let mut x = PAD; let mut counted = vec![ (icon::COMMENTS, plural(comments, "comment", "comments")), (icon::REVIEW, plural(reviews, "review", "reviews")), ]; if let Some(size) = size { counted.push((icon::FILES, plural(size.files, "file", "files"))); } for (path, said) in counted { icon(out, path, x, COUNTS - 25.0, 30.0, MUTE); x += 30.0 + 10.0; write!( out, r#"{}"#, style(text, Weight::Regular, MUTE), esc(&said) ) .unwrap(); x += measure(&said, text, Weight::Regular) + gap; } let Some(size) = size else { return }; for (said, fill) in [ (format!("+{}", size.added), ADDED), (format!("-{}", size.removed), REMOVED), ] { write!( out, r#"{said}"#, style(text, Weight::Semibold, fill), ) .unwrap(); x += measure(&said, text, Weight::Semibold) + 10.0; } let (green, red) = size.squares(); for i in 0..5 { let fill = match i { i if i < green => ADDED, i if i < green + red => REMOVED, _ => UNCHANGED, }; write!( out, r#""#, x + 4.0 + 24.0 * i as f32, COUNTS - 19.0 ) .unwrap(); } } /// A repository's languages along the foot, over its color, as GitHub's /// cards draw them: each as wide as its share of the bytes, its edges on /// whole pixels, so none shows a seam. fn language_bar(out: &mut String, languages: &[Language]) { let total: u64 = languages.iter().map(|l| l.bytes).sum(); let edge = |bytes: u64| (WIDTH as u64 * bytes + total / 2) / total; let mut before = 0; for l in languages { let (x, end) = (edge(before), edge(before + l.bytes)); write!( out, r#""#, HEIGHT - 14, end - x, esc(&l.color) ) .unwrap(); before += l.bytes; } } /// The first row: the owner's mark, or picture, and name, and the /// repository's after. fn top(out: &mut String, (owner, picture): (&str, Option<&str>), name: Option<&str>) { avatar(out, owner, picture, (PAD + 32.0, 112.0, 32.0)); let x = PAD + 84.0; let room = WIDTH as f32 - PAD - x; let Some(name) = name else { let shown = wrap(owner, 36.0, Weight::Regular, room, 1); lines(out, &shown, x, 125.0, 0.0, (36.0, Weight::Regular, MUTE)); return; }; let owner = wrap(&format!("{owner}/"), 36.0, Weight::Regular, room / 2.0, 1); let after = x + measure(&owner[0], 36.0, Weight::Regular); let name = wrap(name, 36.0, Weight::Semibold, WIDTH as f32 - PAD - after, 1); lines(out, &owner, x, 125.0, 0.0, (36.0, Weight::Regular, MUTE)); lines(out, &name, after, 125.0, 0.0, (36.0, Weight::Semibold, FG)); } /// What names a card's picture: a hash of its SVG. pub fn version(svg: &str) -> String { let mut out = String::with_capacity(16); for b in &Sha256::digest(svg)[..8] { write!(out, "{b:02x}").unwrap(); } out } /// The bytes of a picture as the renderer takes them, if they are of a /// kind it reads: told by their first bytes, as what serves them may not /// say. fn image(bytes: Arc>) -> Option { match bytes.as_slice() { [0x89, b'P', b'N', b'G', ..] => Some(usvg::ImageKind::PNG(bytes)), [0xFF, 0xD8, 0xFF, ..] => Some(usvg::ImageKind::JPEG(bytes)), [b'G', b'I', b'F', b'8', ..] => Some(usvg::ImageKind::GIF(bytes)), [ b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', .., ] => Some(usvg::ImageKind::WEBP(bytes)), _ => None, } } /// The SVG drawn, its picture, if it names one, from `picture`: never /// read from a file or the network here. pub fn raster(svg: &str, picture: Option>>) -> tiny_skia::Pixmap { let resolve = move |_: &str, _: &usvg::Options| picture.clone().and_then(image); let options = usvg::Options { font_family: "Inter Display".into(), fontdb: FONTS.clone(), image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(resolve), }, ..Default::default() }; let tree = usvg::Tree::from_str(svg, &options).expect("a card's SVG parses"); let mut pixmap = tiny_skia::Pixmap::new(WIDTH, HEIGHT).expect("a card has a size"); resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); pixmap } /// The drawing as a PNG. A card is a few flat colors and the edges between /// them, not 256 in all, so it is written exactly with a palette: a third /// of the bytes to compress, and a fast compressor makes it smaller than /// a thorough one makes RGB. With more colors, it is RGB. pub fn encode(pixmap: &tiny_skia::Pixmap) -> Vec { let (w, h) = (pixmap.width(), pixmap.height()); // Opaque, so its premultiplied colors are its colors. let pixels = pixmap .data() .as_chunks::<4>() .0 .iter() .map(|p| [p[0], p[1], p[2]]); let mut palette: Vec = Vec::new(); let mut known: HashMap<[u8; 3], u8> = HashMap::new(); let mut indexed = Vec::with_capacity((w * h) as usize); // Neighbours are mostly the same color: it is looked up once a run. let mut last = None; for rgb in pixels.clone() { let i = match last { Some((seen, i)) if seen == rgb => i, _ => { let i = match known.get(&rgb) { Some(&i) => i, None => { let Ok(i) = u8::try_from(known.len()) else { let rgb: Vec = pixels.flatten().collect(); return png(w, h, png::ColorType::Rgb, None, &rgb); }; known.insert(rgb, i); palette.extend_from_slice(&rgb); i } }; last = Some((rgb, i)); i } }; indexed.push(i); } png(w, h, png::ColorType::Indexed, Some(palette), &indexed) } fn png(w: u32, h: u32, color: png::ColorType, palette: Option>, data: &[u8]) -> Vec { let mut out = Vec::with_capacity(data.len() / 16); let mut encoder = png::Encoder::new(&mut out, w, h); encoder.set_color(color); encoder.set_depth(png::BitDepth::Eight); if let Some(palette) = palette { encoder.set_palette(palette); } encoder.set_compression(png::Compression::Fast); encoder.set_filter(png::Filter::Adaptive); let mut writer = encoder.write_header().expect("writing to memory"); writer.write_image_data(data).expect("writing to memory"); writer.finish().expect("writing to memory"); out } /// The SVG as a PNG, with the bytes of the picture it names. pub fn render(svg: &str, picture: Option>>) -> Vec { encode(&raster(svg, picture)) } /// Values by key, the oldest dropped past a number of them, or past a /// number of bytes when each is weighed. pub struct Newest { kept: Mutex>, cap: usize, weigh: fn(&V) -> usize, } struct Entries { by_key: HashMap, /// Oldest first. order: VecDeque, /// What all of them weigh together. weight: usize, } impl Newest { /// Keeping `cap` of them. pub fn new(cap: usize) -> Newest { Newest::weighed(cap, |_| 1) } /// Keeping as many as `weigh` says weigh `cap` together, and always the /// newest. pub fn weighed(cap: usize, weigh: fn(&V) -> usize) -> Newest { let keys = Entries { by_key: HashMap::new(), order: VecDeque::new(), weight: 0, }; Newest { kept: Mutex::new(keys), cap, weigh, } } pub fn get(&self, key: &str) -> Option { self.kept.lock().unwrap().by_key.get(key).cloned() } pub fn put(&self, key: &str, value: V) { let mut kept = self.kept.lock().unwrap(); let Entries { by_key, order, weight, } = &mut *kept; *weight += (self.weigh)(&value); match by_key.insert(key.to_string(), value) { Some(was) => *weight -= (self.weigh)(&was), None => order.push_back(key.to_string()), } while *weight > self.cap && order.len() > 1 { let oldest = order.pop_front().expect("more than one"); let was = by_key.remove(&oldest).expect("each in order is kept"); *weight -= (self.weigh)(&was); } } pub fn len(&self) -> usize { self.kept.lock().unwrap().order.len() } pub fn is_empty(&self) -> bool { self.len() == 0 } } /// Cards drawn, by version, the oldest dropped past a number of them; and /// a cap on how many are drawn at once, as each takes a core. pub struct Cache { pub drawn: Newest>, drawing: tokio::sync::Semaphore, } impl Cache { pub fn new(cap: usize) -> Cache { let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); Cache { drawn: Newest::new(cap), drawing: tokio::sync::Semaphore::new(cores), } } /// The PNG of `svg`, whose version is `version`, drawn with the bytes /// of the picture it names: kept under its version only if `whole`, /// as a card drawn without a picture it names may yet be drawn with it. pub async fn draw( &self, version: &str, svg: String, picture: Option>>, whole: bool, ) -> Arc<[u8]> { let _turn = self.drawing.acquire().await.expect("never closed"); let png: Arc<[u8]> = tokio::task::spawn_blocking(move || render(&svg, picture)) .await .expect("drawing a card does not panic") .into(); if whole { self.drawn.put(version, png.clone()); } png } } #[cfg(test)] mod tests { use super::*; #[test] fn newest_by_weight() { let kept: Newest> = Newest::weighed(10, Vec::len); kept.put("a", vec![0; 4]); kept.put("b", vec![0; 4]); assert_eq!(kept.len(), 2); // Put again, it weighs what it weighs now, not both. kept.put("b", vec![0; 6]); assert_eq!((kept.len(), kept.get("b").map(|b| b.len())), (2, Some(6))); // Past the cap, the oldest go. kept.put("c", vec![0; 3]); assert!(kept.get("a").is_none()); assert_eq!(kept.len(), 2); // One heavier than the cap is still kept, alone. kept.put("d", vec![0; 20]); assert_eq!((kept.len(), kept.get("d").map(|d| d.len())), (1, Some(20))); } #[test] fn wrapping() { let w = |t: &str, width: f32, max: usize| wrap(t, 40.0, Weight::Regular, width, max); assert_eq!(w("one two three", 2000.0, 1), ["one two three"]); assert_eq!(w("one two three", 170.0, 3), ["one two", "three"]); let cut = w("one two three four five six", 170.0, 2); assert_eq!(cut.len(), 2); assert!(cut[1].ends_with('…'), "{cut:?}"); // A word longer than a line breaks where it overflows. let long = w(&"x".repeat(40), 200.0, 5); assert!(long.len() > 1 && long.concat().len() == 40, "{long:?}"); assert!( long.iter() .all(|l| measure(l, 40.0, Weight::Regular) <= 200.0) ); assert!(w("", 100.0, 2).is_empty()); assert_eq!(w("a b c d e f g h", 1.0, 1), ["…"]); // Narrower than one letter, a line still takes one. assert_eq!(w("ab", 1.0, 3), ["a", "b"]); } #[test] fn marks() { assert_eq!( esc(r#"'&'"#), "<a href="x">'&'</a>" ); assert_eq!(initial("_alice"), 'a'); assert_eq!(initial("…"), '?'); assert!(hue("alice") < 360); assert_eq!( date("2026-11-01T23:59:59Z").as_deref(), Some("November 1, 2026") ); assert_eq!(date("yesterday"), None); // Both weights are one family, so asking for 600 gets the bolder. let faces: Vec<(String, u16)> = FONTS .faces() .map(|f| (f.families[0].0.clone(), f.weight.0)) .collect(); let family = || "Inter Display".to_string(); let emoji = ("Noto Color Emoji".to_string(), 400); let mono = ("JetBrains Mono NL".to_string(), 600); assert_eq!(faces, [(family(), 400), (family(), 600), emoji, mono]); assert!(measure("W", 40.0, Weight::Semibold) > measure("i", 40.0, Weight::Semibold)); // A letter the fonts lack is measured as the box drawn for it. assert!(measure("漢", 40.0, Weight::Regular) > 0.0); } #[test] fn emoji() { let one = measure("😀", 40.0, Weight::Regular); // As wide as Noto draws it, not as Inter's box. assert!(one > measure("W", 40.0, Weight::Regular), "{one}"); assert_ne!(one, measure("漢", 40.0, Weight::Regular)); // A sequence is drawn as one, however many characters it takes. for one_of in [ "👩‍💻", "👋🏽", "❤️", "🇫🇷", "#️⃣", "🏴\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}", "👨‍👩‍👧‍👦", ] { assert_eq!(clusters(one_of), [one_of]); } assert_eq!(measure("👨‍👩‍👧‍👦", 40.0, Weight::Regular), one); // Flags pair off; a letter left over stands alone. assert_eq!(clusters("🇫🇷🇩🇪🇮"), ["🇫🇷", "🇩🇪", "🇮"]); assert_eq!(clusters("a😀b"), ["a", "😀", "b"]); // A selector with nothing before it stands alone. assert_eq!(clusters("\u{FE0F}x"), ["\u{FE0F}", "x"]); // Lines break, and are cut, between sequences, never inside one. let family = "👨‍👩‍👧‍👦".repeat(12); let broken = wrap(&family, 40.0, Weight::Regular, one * 5.0, 2); assert_eq!(broken[0], "👨‍👩‍👧‍👦".repeat(5)); assert!(broken[1].ends_with('…'), "{broken:?}"); assert!(clusters(&broken[1]).iter().all(|c| *c == "👨‍👩‍👧‍👦" || *c == "…")); let repo = |description: &str| { render( &Card::Repo { owner: "alice.test".into(), avatar: None, name: "party".into(), description: Some(description.into()), pulls: 0, issues: 0, languages: Vec::new(), } .svg(), None, ) }; // Drawn in Noto's colors, not as boxes in the text's. let drawn = repo("Flags 🇫🇷🇯🇵, people 👩🏽‍🔬 and hearts ❤️"); assert!(drawn.len() > repo("Flags ab, people c and hearts d").len()); // What no keyboard makes draws too, rather than failing the card. for odd in [ "😀\u{200D}a", "a\u{200D}😀", "\u{200D}\u{FE0F}", "🇫 x", "#\u{FE0F}\u{20E3}x", "😀\u{200D}漢 é 😀", "\u{1F3FD}\u{1F3FD}", "<😀&>", ] { repo(odd); } } fn item(opened: Option<&str>, pull: bool) -> Card { Card::Item { owner: "alice.test".into(), avatar: None, name: "tracked".into(), pull, title: "Broken & more".into(), author: "bob.test".into(), opened: opened.map(str::to_string), comments: 1, reviews: usize::from(pull), size: pull.then_some(Size { commits: 2, files: 3, added: 181, removed: 18, }), } } #[test] fn squares() { let size = |added, removed| Size { added, removed, ..Size::default() }; assert_eq!(size(0, 0).squares(), (0, 0)); assert_eq!(size(181, 18).squares(), (4, 1)); assert_eq!(size(10, 0).squares(), (5, 0)); assert_eq!(size(0, 3).squares(), (0, 5)); assert_eq!(size(1, 1).squares(), (2, 3)); assert_eq!(size(1, 1000).squares(), (1, 4)); assert_eq!(size(1000, 1).squares(), (4, 1)); } #[test] fn every_card_draws() { let cards = [ Card::Home, Card::Repo { owner: "alice.test".into(), avatar: None, name: "a-repository-name-long-enough-to-wrap-onto-two-lines".into(), description: Some("Tracks .".into()), pulls: 1, issues: 2, languages: crate::languages::breakdown([(0, 700), (1, 290), (2, 6), (3, 4)]), }, Card::Repo { owner: "alice.test".into(), avatar: None, name: "bare".into(), description: None, pulls: 0, issues: 0, languages: Vec::new(), }, item(Some("2026-11-01T12:00:00.000Z"), true), item(None, true), item(Some("2026-11-01T12:00:00.000Z"), false), item(None, false), // A long title set smaller, over a pull whose commits were // not read. Card::Item { owner: "alice.test".into(), avatar: None, name: "tracked".into(), pull: true, title: "A title long enough that it wraps onto three lines at the size titles are set on a card".into(), author: "bob.test".into(), opened: Some("not a time".into()), comments: 0, reviews: 0, size: None, }, ]; let mut versions = std::collections::HashSet::new(); for card in &cards { let svg = card.svg(); assert!(!svg.contains(""), "{svg}"); assert!(versions.insert(version(&svg))); let png = render(&svg, None); assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); } assert_eq!(version("x").len(), 16); } #[test] fn more_than_a_palette_holds() { let mut pixmap = tiny_skia::Pixmap::new(32, 32).unwrap(); for (i, p) in pixmap .data_mut() .as_chunks_mut::<4>() .0 .iter_mut() .enumerate() { p.copy_from_slice(&[i as u8, (i >> 8) as u8, 0, 255]); } let rgb = encode(&pixmap); // The header's color type: 2 is RGB, 3 a palette. assert_eq!(rgb[25], 2); assert_eq!(render(&Card::Home.svg(), None)[25], 3); } #[tokio::test] async fn the_cache_keeps_the_newest() { let cache = Cache::new(2); assert!(cache.drawn.is_empty()); let svg = Card::Home.svg(); let first = cache.draw("a", svg.clone(), None, true).await; assert!(Arc::ptr_eq(&first, &cache.drawn.get("a").unwrap())); cache.draw("b", svg.clone(), None, true).await; cache.draw("b", svg.clone(), None, true).await; cache.draw("c", svg.clone(), None, true).await; assert_eq!(cache.drawn.len(), 2); assert!(cache.drawn.get("a").is_none()); // Drawn without the picture it names, a card is not kept. cache.draw("d", svg, None, false).await; assert!(cache.drawn.get("d").is_none()); } /// A PNG of one color. fn plain(rgb: [u8; 3]) -> Arc> { let mut pixmap = tiny_skia::Pixmap::new(8, 8).unwrap(); pixmap.fill(tiny_skia::Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255)); Arc::new(encode(&pixmap)) } #[test] fn pictures() { let kind = |bytes: &[u8]| image(Arc::new(bytes.to_vec())); assert!(matches!( kind(&plain([1, 2, 3])), Some(usvg::ImageKind::PNG(_)) )); assert!(matches!( kind(b"\xFF\xD8\xFF\xE0"), Some(usvg::ImageKind::JPEG(_)) )); assert!(matches!(kind(b"GIF89a"), Some(usvg::ImageKind::GIF(_)))); assert!(matches!( kind(b"RIFF\0\0\0\0WEBPVP8 "), Some(usvg::ImageKind::WEBP(_)) )); assert!(kind(b"").is_none()); let card = |avatar: Option<&str>| Card::Repo { owner: "alice.test".into(), avatar: avatar.map(str::to_string), name: "party".into(), description: None, pulls: 0, issues: 0, languages: Vec::new(), }; assert_eq!(Card::Home.picture(), None); let pictured = card(Some("https://cdn.example/a?b&c")); assert_eq!(pictured.picture(), Some("https://cdn.example/a?b&c")); // Its address is in what the version covers: a new picture is a // new card. let svg = pictured.svg(); assert!( svg.contains(r#"href="https://cdn.example/a?b&c""#), "{svg}" ); assert_ne!( version(&svg), version(&card(Some("https://cdn.example/b")).svg()) ); // Without a picture, the card is as it ever was. assert!(!card(None).svg().contains("