use std::{convert::TryFrom, sync::LazyLock};
use ab_glyph::{Font, FontArc, Glyph, PxScale, ScaleFont, point};
use base64::display::Base64Display;
use thiserror::Error;
const FONT_DATA: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/DejaVuSans.ttf"
));
const FONT_SIZE: f32 = 11.0;
#[derive(Debug, Error)]
pub enum Error {
#[error("status must not be empty")]
EmptyStatus,
#[error("subject must not be empty")]
EmptySubject,
}
pub struct BadgeOptions {
pub subject: String,
pub status: String,
pub color: String,
}
impl Default for BadgeOptions {
fn default() -> BadgeOptions {
BadgeOptions {
subject: "build".to_owned(),
status: "passing".to_owned(),
color: "#4c1".to_owned(),
}
}
}
pub struct Badge {
options: BadgeOptions,
font: FontArc,
scale: PxScale,
}
impl Badge {
pub fn new(options: BadgeOptions) -> Result<Badge, Error> {
static FONT: LazyLock<FontArc> = LazyLock::new(|| {
FontArc::try_from_slice(FONT_DATA).expect("Failed to parse FONT_DATA")
});
let font = &*FONT;
let scale = PxScale {
x: FONT_SIZE,
y: FONT_SIZE,
};
if options.status.is_empty() {
Err(Error::EmptyStatus)
} else if options.subject.is_empty() {
Err(Error::EmptySubject)
} else {
Ok(Badge {
options,
font: font.clone(),
scale,
})
}
}
#[must_use]
pub fn to_svg_data_uri(&self) -> String {
format!(
"data:image/svg+xml;base64,{}",
Base64Display::new(
self.to_svg().as_bytes(),
&base64::engine::general_purpose::STANDARD
),
)
}
#[must_use]
pub fn to_svg(&self) -> String {
let left_width = self.calculate_width(&self.options.subject) + 6;
let right_width = self.calculate_width(&self.options.status) + 6;
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{badge_width}" height="20">
<linearGradient id="smooth" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="round">
<rect width="{badge_width}" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#round)">
<rect width="{left_width}" height="20" fill="#555"/>
<rect x="{left_width}" width="{right_width}" height="20" fill="{color}"/>
<rect width="{badge_width}" height="20" fill="url(#smooth)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="{subject_x}" y="15" fill="#010101" fill-opacity=".3">{subject}</text>
<text x="{subject_x}" y="14">{subject}</text>
<text x="{status_x}" y="15" fill="#010101" fill-opacity=".3">{status}</text>
<text x="{status_x}" y="14">{status}</text>
</g>
</svg>"##,
badge_width = left_width + right_width,
left_width = left_width,
right_width = right_width,
color = self.options.color,
subject_x = left_width / 2,
subject = self.options.subject,
status_x = left_width + (right_width / 2),
status = self.options.status
);
svg
}
fn calculate_width(&self, text: &str) -> u32 {
#[allow(clippy::cast_sign_loss, // layout is always positive
clippy::cast_possible_truncation // TODO: this might break for texts that are too long
)]
let width = self.layout(text).ceil() as u32;
width + ((u32::try_from(text.len()).expect("text to long") - 1) * 2) - 2
}
fn layout(&self, text: &str) -> f32 {
let font = self.font.as_scaled(self.scale);
let mut caret = point(0.0, font.ascent());
let mut last_glyph: Option<Glyph> = None;
let mut target = Vec::new();
for c in text.chars() {
if c.is_control() {
continue;
}
let mut glyph = font.scaled_glyph(c);
if let Some(previous) = last_glyph.take() {
caret.x += font.kern(previous.id, glyph.id);
}
glyph.position = caret;
last_glyph = Some(glyph.clone());
caret.x += font.h_advance(glyph.id);
target.push(glyph);
}
match (target.first(), target.last()) {
(Some(first), Some(last)) => {
font.glyph_bounds(last).max.x - font.glyph_bounds(first).min.x
}
_ => 0.,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options() -> BadgeOptions {
BadgeOptions::default()
}
#[test]
fn test_new() {
assert!(Badge::new(options()).is_ok());
let mut bad_options_status = options();
bad_options_status.status = String::new();
assert!(Badge::new(bad_options_status).is_err());
let mut bad_options_subject = options();
bad_options_subject.subject = String::new();
assert!(Badge::new(bad_options_subject).is_err());
}
#[test]
fn test_calculate_width() {
let badge = Badge::new(options()).unwrap();
assert_eq!(badge.calculate_width("build"), 31);
assert_eq!(badge.calculate_width("passing"), 48);
}
#[test]
fn test_to_svg() {
const TEST_BADGE: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/test_badge.svg"));
let options = BadgeOptions {
subject: "docs".to_owned(),
status: "0.5.3".to_owned(),
color: "#4d76ae".to_owned(),
};
let badge = Badge::new(options).unwrap();
assert_eq!(
badge.to_svg().split_whitespace().collect::<String>(),
TEST_BADGE.split_whitespace().collect::<String>()
);
}
}