7.0 KiB · text History 0d6bdf0
//! 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 {
        (&REGULAR, 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");