johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<Arc<usvg::fontdb::Database>> = 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<char>, 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<String> {
let fits = |s: &str| measure(s, size, weight) <= width;
let mut lines: Vec<String> = 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#"<tspan x="{x}"{family}>{}</tspan>"#, 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#"<text x="{x}" y="{}" {}>{}</text>"#,
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#"<path transform="translate({x} {y}) scale({})" fill="{fill}" d="{path}"/>"#,
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#"<text x="{x}" y="{baseline}" font-family="JetBrains Mono NL" {}>~ knotless</text>"#,
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#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="hsl({}, 45%, 52%)"/><text x="{cx}" y="{}" text-anchor="middle" {}>{}</text>"#,
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#"<clipPath id="avatar"><circle cx="{cx}" cy="{cy}" r="{r}"/></clipPath><image href="{}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatar)"/>"#,
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<String> {
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<String>,
name: String,
description: Option<String>,
/// Open pulls and issues.
pulls: usize,
issues: usize,
/// What it is written in, drawn along the foot.
languages: Vec<Language>,
},
/// A pull or an issue.
Item {
owner: String,
/// The address of the owner's picture, if it has one.
avatar: Option<String>,
name: String,
pull: bool,
title: String,
author: String,
/// When it was opened, as its record says.
opened: Option<String>,
comments: usize,
/// A pull's reviews; none for an issue.
reviews: usize,
/// What a pull changes, if its commits could be read.
size: Option<Size>,
},
}
/// 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##"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}"><rect width="{WIDTH}" height="{HEIGHT}" fill="#fff"/>"##
);
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#"<text x="{right}" y="{FOOT}" text-anchor="end" {}>{}</text>"#,
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#"<rect y="{}" width="{WIDTH}" height="14" fill="{bar}"/>"#,
HEIGHT - 14
)
.unwrap();
if let Card::Repo { languages, .. } = self {
language_bar(&mut out, languages);
}
out.push_str("</svg>");
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<Size>) {
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#"<text x="{x}" y="{COUNTS}" {}>{}</text>"#,
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#"<text x="{x}" y="{COUNTS}" {}>{said}</text>"#,
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#"<rect x="{}" y="{}" width="18" height="18" rx="2" fill="{fill}"/>"#,
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#"<rect x="{x}" y="{}" width="{}" height="14" fill="{}"/>"#,
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<Vec<u8>>) -> Option<usvg::ImageKind> {
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<Arc<Vec<u8>>>) -> 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<u8> {
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<u8> = 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<u8> = 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<Vec<u8>>, data: &[u8]) -> Vec<u8> {
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<Arc<Vec<u8>>>) -> Vec<u8> {
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<V> {
kept: Mutex<Entries<V>>,
cap: usize,
weigh: fn(&V) -> usize,
}
struct Entries<V> {
by_key: HashMap<String, V>,
/// Oldest first.
order: VecDeque<String>,
/// What all of them weigh together.
weight: usize,
}
impl<V: Clone> Newest<V> {
/// Keeping `cap` of them.
pub fn new(cap: usize) -> Newest<V> {
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<V> {
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<V> {
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<Arc<[u8]>>,
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<Arc<Vec<u8>>>,
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<Vec<u8>> = 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>"#),
"<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 <thing> & 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 <things>.".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("<thing>"), "{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<Vec<u8>> {
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"<svg/>").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("<image"));
// The picture, cut to the circle, over the initial; the corner of
// the box it is drawn in is the card's white.
let at = |pixmap: &tiny_skia::Pixmap, x: u32, y: u32| {
let p = pixmap.pixel(x, y).unwrap();
[p.red(), p.green(), p.blue()]
};
let drawn = raster(&svg, Some(plain([200, 10, 20])));
assert_eq!(at(&drawn, 112, 90), [200, 10, 20]);
assert_eq!(at(&drawn, 82, 82), [255, 255, 255]);
// Not given it, or given what is no picture, it leaves the initial.
let initial = at(&raster(&card(None).svg(), None), 112, 90);
assert_ne!(initial, [200, 10, 20]);
assert_eq!(at(&raster(&svg, None), 112, 90), initial);
let junk = Some(Arc::new(b"\x89PNG\r\n\x1a\nmine".to_vec()));
assert_eq!(at(&raster(&svg, junk), 112, 90), initial);
}
}