//! Status badges: lettering as outlines, and SVG to PNG.
//!
//! Two things OTP can't do. A badge's text has to be paths, because an
//! SVG loaded through `<img>` on another site can't fetch a web font and
//! falls back to whatever monospace the reader has; reading glyph
//! outlines means parsing TrueType. And a PNG of the badge means
//! rasterizing that SVG with antialiasing. `ttf-parser` and `resvg` do
//! both in pure Rust, with no system libraries for either image.
//!
//! The font is B612 Mono (SIL OFL 1.1, `fonts/OFL.txt`), the app's own
//! monospace, compiled in. What it lacks (the emoji a repository's syms
//! are made of, say) comes from Noto Emoji (SIL OFL 1.1,
//! `fonts/OFL-NotoEmoji.txt`), the monochrome one, so it outlines in the
//! badge's own text color like every other character.
use std::fmt::Write;
use std::sync::OnceLock;
use resvg::{tiny_skia, usvg};
use rustler::{Encoder, Env, NifResult, OwnedBinary, Term};
use ttf_parser::{Face, GlyphId, OutlineBuilder};
mod atoms {
rustler::atoms! { ok, error }
}
static REGULAR_TTF: &[u8] = include_bytes!("../fonts/B612Mono-Regular.ttf");
static BOLD_TTF: &[u8] = include_bytes!("../fonts/B612Mono-Bold.ttf");
static EMOJI_TTF: &[u8] = include_bytes!("../fonts/NotoEmoji.ttf");
/// Largest badge SVG we'll rasterize. A badge is a few kilobytes; this
/// only stops a caller from handing the renderer something absurd.
const MAX_SVG_BYTES: usize = 256 * 1024;
fn face(bold: bool) -> &'static Face<'static> {
static REGULAR: OnceLock<Face<'static>> = OnceLock::new();
static BOLD: OnceLock<Face<'static>> = OnceLock::new();
let (cell, bytes) = if bold {
(&BOLD, BOLD_TTF)
} else {
(®ULAR, REGULAR_TTF)
};
cell.get_or_init(|| Face::parse(bytes, 0).expect("the bundled font parses"))
}
/// Writes one glyph's outline into an SVG path, placed and scaled from
/// font units (y up) to badge pixels (y down).
struct Sink<'s> {
d: &'s mut String,
x: f64,
baseline: f64,
k: f64,
}
impl Sink<'_> {
fn point(&mut self, px: f32, py: f32) {
let x = round2(self.x + f64::from(px) * self.k);
let y = round2(self.baseline - f64::from(py) * self.k);
let _ = write!(self.d, "{x} {y}");
}
}
impl OutlineBuilder for Sink<'_> {
fn move_to(&mut self, x: f32, y: f32) {
self.d.push('M');
self.point(x, y);
}
fn line_to(&mut self, x: f32, y: f32) {
self.d.push('L');
self.point(x, y);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.d.push('Q');
self.point(x1, y1);
self.d.push(' ');
self.point(x, y);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.d.push('C');
self.point(x1, y1);
self.d.push(' ');
self.point(x2, y2);
self.d.push(' ');
self.point(x, y);
}
fn close(&mut self) {
self.d.push('Z');
}
}
/// Two decimals is a hundredth of a pixel at 1x: invisible, and it
/// keeps a badge's paths to a few kilobytes.
fn round2(v: f64) -> f64 {
let r = (v * 100.0).round() / 100.0;
if r == 0.0 {
0.0
} else {
r
}
}
fn emoji_face() -> &'static Face<'static> {
static EMOJI: OnceLock<Face<'static>> = OnceLock::new();
EMOJI.get_or_init(|| Face::parse(EMOJI_TTF, 0).expect("the bundled emoji font parses"))
}
fn glyph(face: &Face, c: char) -> GlyphId {
face.glyph_index(c)
.or_else(|| face.glyph_index('?'))
.unwrap_or(GlyphId(0))
}
/// Characters that only modify their neighbour (emoji presentation
/// selectors, the zero-width joiner): drawn as nothing, taking no room.
fn invisible(c: char) -> bool {
matches!(c, '\u{FE0E}' | '\u{FE0F}' | '\u{200D}')
}
/// Each drawn character's face, glyph and scale (pixels per font unit):
/// B612 Mono when it has the character, Noto Emoji when that does, else
/// B612 Mono's `?`.
fn glyphs(
text: &str,
bold: bool,
size: f64,
) -> impl Iterator<Item = (&'static Face<'static>, GlyphId, f64)> + '_ {
let mono = face(bold);
let emoji = emoji_face();
text.chars().filter(|c| !invisible(*c)).map(move |c| {
let (face, id) = match (mono.glyph_index(c), emoji.glyph_index(c)) {
(Some(id), _) => (mono, id),
(None, Some(id)) => (emoji, id),
(None, None) => (mono, glyph(mono, c)),
};
(face, id, size / f64::from(face.units_per_em()))
})
}
fn advance(face: &Face, id: GlyphId, k: f64) -> f64 {
f64::from(face.glyph_hor_advance(id).unwrap_or(0)) * k
}
/// The width of one character, as a fraction of the font size. B612
/// Mono is monospaced, so this one number lays out every badge.
#[rustler::nif]
fn advance_em() -> f64 {
let face = face(false);
let advance = face.glyph_hor_advance(glyph(face, '0')).unwrap_or(0);
f64::from(advance) / f64::from(face.units_per_em())
}
/// How wide `text` draws at `size` pixels.
#[rustler::nif]
fn text_width(text: String, bold: bool, size: f64) -> f64 {
glyphs(&text, bold, size)
.map(|(face, id, k)| advance(face, id, k))
.sum()
}
/// `text` as one SVG path `d`, starting at `x` on `baseline`, at `size`
/// pixels. Characters neither font has draw as `?`.
#[rustler::nif]
fn text_path(text: String, bold: bool, size: f64, x: f64, baseline: f64) -> String {
let mut d = String::new();
let mut pen = x;
for (face, id, k) in glyphs(&text, bold, size) {
let mut sink = Sink {
d: &mut d,
x: pen,
baseline,
k,
};
face.outline_glyph(id, &mut sink);
pen += advance(face, id, k);
}
d
}
/// Render `svg` to PNG bytes at `zoom` times its own size.
#[rustler::nif(schedule = "DirtyCpu")]
fn rasterize<'a>(env: Env<'a>, svg: String, zoom: f64) -> NifResult<Term<'a>> {
match render(&svg, zoom) {
Ok(png) => {
let mut bin = OwnedBinary::new(png.len()).ok_or(rustler::Error::BadArg)?;
bin.as_mut_slice().copy_from_slice(&png);
Ok((atoms::ok(), bin.release(env)).encode(env))
}
Err(reason) => Ok((atoms::error(), reason).encode(env)),
}
}
fn render(svg: &str, zoom: f64) -> Result<Vec<u8>, String> {
if svg.len() > MAX_SVG_BYTES {
return Err(format!(
"svg is {} bytes; the limit is {MAX_SVG_BYTES}",
svg.len()
));
}
if !(0.25..=8.0).contains(&zoom) {
return Err(format!("zoom {zoom} is outside 0.25..=8"));
}
let zoom = zoom as f32;
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).map_err(|e| e.to_string())?;
let size = tree
.size()
.to_int_size()
.scale_by(zoom)
.ok_or("the scaled size is empty")?;
let mut pixmap =
tiny_skia::Pixmap::new(size.width(), size.height()).ok_or("the scaled size is empty")?;
resvg::render(
&tree,
tiny_skia::Transform::from_scale(zoom, zoom),
&mut pixmap.as_mut(),
);
pixmap.encode_png().map_err(|e| e.to_string())
}
rustler::init!("Elixir.GitGud.Badges.Native");
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.0 KiB · text
History
b868907