//! 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 and Noto //! Color Emoji for what Inter lacks, 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. 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 DONE: &str = "M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5ZM16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.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 PULL_CLOSED: &str = "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"; pub const MERGE: &str = "M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"; } 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"); /// 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] { 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 mark, `size` across, and the name beside it, its left at `x`. fn brand(out: &mut String, x: f32, baseline: f32, size: f32) { let top = baseline - size * 0.84; write!( out, r##""##, size / 24.0 ) .unwrap(); write!( out, r#"knotless"#, x + size * 1.3, style(size * 0.78, Weight::Semibold, FG) ) .unwrap(); } /// How wide `brand` draws at `size`. fn brand_width(size: f32) -> f32 { size * 1.3 + measure("knotless", size * 0.78, Weight::Semibold) } /// 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`. fn avatar(out: &mut String, name: &str, cx: f32, cy: f32, r: f32) { write!( out, r#"{}"#, hue(name), cy + r * 0.36, style(r, Weight::Semibold, "#fff"), esc(&initial(name).to_uppercase().to_string()) ) .unwrap(); } /// The color of a pull's or an issue's state, as the pages draw it. fn state_color(pull: bool, state: &str) -> &'static str { match (pull, state) { (_, "open") => "#1a7f37", (_, "merged") => "#7048b5", (true, _) => "#cf222e", (false, _) => "#636363", } } 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, name: String, description: Option, /// Open pulls and issues. pulls: usize, issues: usize, }, /// A pull or an issue. Item { owner: String, name: String, pull: bool, state: String, title: String, author: String, comments: usize, }, } impl Card { 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, 88.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, name, description, pulls, issues, } => { top(&mut out, owner, 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::ISSUE, plural(*issues, "open issue", "open issues")), (icon::PULL, plural(*pulls, "open pull", "open pulls")), ] { 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, name, pull, state, title, author, comments, } => { top(&mut out, owner, Some(name)); let head = wrap(title, 72.0, Weight::Semibold, wide, 3); lines( &mut out, &head, PAD, 262.0, 86.0, (72.0, Weight::Semibold, FG), ); let color = state_color(*pull, state); let label = { let mut c = state.chars(); c.next() .map(|f| f.to_uppercase().chain(c).collect::()) .unwrap_or_default() }; let path = match (*pull, state.as_str()) { (true, "merged") => icon::MERGE, (true, "open") => icon::PULL, (true, _) => icon::PULL_CLOSED, (false, "open") => icon::ISSUE, _ => icon::DONE, }; let pill = 70.0 + measure(&label, 30.0, Weight::Semibold) + 28.0; write!( out, r#""#, FOOT - 41.0, ) .unwrap(); let label = [label]; lines( &mut out, &label, PAD + 70.0, FOOT, 0.0, (30.0, Weight::Semibold, "#fff"), ); icon(&mut out, path, PAD + 26.0, FOOT - 27.0, 32.0, "#fff"); let mark = brand_width(44.0); let x = PAD + pill + 28.0; let said = plural(*comments, "comment", "comments"); let room = WIDTH as f32 - PAD - mark - 48.0 - x; let by = wrap( &format!("{author} · {said}"), 32.0, Weight::Regular, room, 1, ); lines(&mut out, &by, x, FOOT, 0.0, (32.0, Weight::Regular, MUTE)); brand(&mut out, WIDTH as f32 - PAD - mark, FOOT, 44.0); color.to_string() } }; write!( out, r#""#, HEIGHT - 14 ) .unwrap(); out } } /// The first row: the owner's mark and name, and the repository's after. fn top(out: &mut String, owner: &str, name: Option<&str>) { avatar(out, owner, 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 SVG drawn. pub fn raster(svg: &str) -> tiny_skia::Pixmap { let options = usvg::Options { font_family: "Inter Display".into(), fontdb: FONTS.clone(), ..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().chunks_exact(4).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. pub fn render(svg: &str) -> Vec { encode(&raster(svg)) } /// 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 { drawn: Mutex, cap: usize, drawing: tokio::sync::Semaphore, } #[derive(Default)] struct Drawn { by_version: HashMap>, /// Oldest first. order: VecDeque, } impl Cache { pub fn new(cap: usize) -> Cache { let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); Cache { drawn: Mutex::default(), cap, drawing: tokio::sync::Semaphore::new(cores), } } /// The PNG of `svg`, whose version is `version`. pub async fn png(&self, version: &str, svg: String) -> Arc<[u8]> { if let Some(png) = self.drawn.lock().unwrap().by_version.get(version) { return png.clone(); } let _turn = self.drawing.acquire().await.expect("never closed"); let png: Arc<[u8]> = tokio::task::spawn_blocking(move || render(&svg)) .await .expect("drawing a card does not panic") .into(); let mut drawn = self.drawn.lock().unwrap(); let Drawn { by_version, order } = &mut *drawn; if by_version .insert(version.to_string(), png.clone()) .is_none() { order.push_back(version.to_string()); } while order.len() > self.cap { let oldest = order.pop_front().expect("longer than the cap"); by_version.remove(&oldest); } png } pub fn len(&self) -> usize { self.drawn.lock().unwrap().order.len() } pub fn is_empty(&self) -> bool { self.len() == 0 } } #[cfg(test)] mod tests { use super::*; #[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!(state_color(true, "closed"), "#cf222e"); assert_eq!(state_color(false, "closed"), "#636363"); // 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); assert_eq!(faces, [(family(), 400), (family(), 600), emoji]); 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(), name: "party".into(), description: Some(description.into()), pulls: 0, issues: 0, } .svg(), ) }; // 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(state: &str, pull: bool) -> Card { Card::Item { owner: "alice.test".into(), name: "tracked".into(), pull, state: state.into(), title: "Broken & more".into(), author: "bob.test".into(), comments: 1, } } #[test] fn every_card_draws() { let cards = [ Card::Home, Card::Repo { owner: "alice.test".into(), name: "a-repository-name-long-enough-to-wrap-onto-two-lines".into(), description: Some("Tracks .".into()), pulls: 1, issues: 2, }, Card::Repo { owner: "alice.test".into(), name: "bare".into(), description: None, pulls: 0, issues: 0, }, item("open", true), item("merged", true), item("closed", true), item("open", false), item("closed", false), item("", false), ]; 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); 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().chunks_exact_mut(4).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())[25], 3); } #[tokio::test] async fn the_cache_keeps_the_newest() { let cache = Cache::new(2); assert!(cache.is_empty()); let svg = Card::Home.svg(); let first = cache.png("a", svg.clone()).await; let again = cache.png("a", svg.clone()).await; assert!(Arc::ptr_eq(&first, &again)); cache.png("b", svg.clone()).await; cache.png("c", svg.clone()).await; assert_eq!(cache.len(), 2); let redrawn = cache.png("a", svg).await; assert!(!Arc::ptr_eq(&first, &redrawn)); } }