//! a flat-colour canvas: shapes by their signed distance, text through fontdue, png out
use std::io::Write as _;
use std::sync::OnceLock;
use anyhow::Context as _;
pub(crate) const WIDTH: usize = crate::index_page::OG_IMAGE_WIDTH as usize;
pub(crate) const HEIGHT: usize = crate::index_page::OG_IMAGE_HEIGHT as usize;
pub(crate) type Rgb = [u8; 3];
pub(crate) type Point = (f32, f32);
/// coverage is kept to this many steps, so a card fits a 256-colour palette
const COVERAGE_STEPS: f32 = 15.0;
#[derive(Clone, Copy)]
pub(crate) enum Face {
Sans,
SansBold,
Mono,
MonoBold,
/// the kaomoji's face, and any glyph the others don't have
Wide,
}
struct Fonts {
sans: fontdue::Font,
sans_bold: fontdue::Font,
mono: fontdue::Font,
mono_bold: fontdue::Font,
wide: fontdue::Font,
}
/// the faces' files, in `Fonts` order
const FONT_FILES: [&[u8]; 5] = [
include_bytes!("fonts/host-grotesk-400.ttf"),
include_bytes!("fonts/host-grotesk-600.ttf"),
include_bytes!("fonts/kode-mono-400.ttf"),
include_bytes!("fonts/kode-mono-700.ttf"),
include_bytes!("../favicon/JetBrainsMonoNL-Medium.ttf"),
];
fn fonts() -> &'static Fonts {
static FONTS: OnceLock<Fonts> = OnceLock::new();
FONTS.get_or_init(|| {
let [sans, sans_bold, mono, mono_bold, wide] = FONT_FILES.map(|bytes| {
fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default())
.expect("the card fonts are built in")
});
Fonts {
sans,
sans_bold,
mono,
mono_bold,
wide,
}
})
}
/// the fonts' hash, so new font files draw every card again
pub(crate) fn fonts_digest() -> &'static str {
static DIGEST: OnceLock<String> = OnceLock::new();
DIGEST.get_or_init(|| crate::atproto::short_hash(&FONT_FILES.concat()))
}
impl Face {
fn font(self) -> &'static fontdue::Font {
let fonts = fonts();
match self {
Self::Sans => &fonts.sans,
Self::SansBold => &fonts.sans_bold,
Self::Mono => &fonts.mono,
Self::MonoBold => &fonts.mono_bold,
Self::Wide => &fonts.wide,
}
}
pub(crate) fn has(self, ch: char) -> bool {
self.font().lookup_glyph_index(ch) != 0
}
/// the face that draws `ch`: this one, else the wide one
fn for_char(self, ch: char) -> &'static fontdue::Font {
let font = self.font();
match font.lookup_glyph_index(ch) {
0 => Self::Wide.font(),
_ => font,
}
}
pub(crate) fn width(self, size: f32, text: &str) -> f32 {
text.chars()
.map(|ch| self.for_char(ch).metrics(ch, size).advance_width)
.sum()
}
/// the baseline of a line box `height` tall whose top is at `top`
pub(crate) fn baseline(self, size: f32, top: f32, height: f32) -> f32 {
let metrics = self
.font()
.horizontal_line_metrics(size)
.expect("the card fonts have horizontal metrics");
top + (height - (metrics.ascent - metrics.descent)) / 2.0 + metrics.ascent
}
/// `text` cut to `max` wide, with an ellipsis when it was cut
pub(crate) fn fit(self, size: f32, max: f32, text: &str) -> String {
if self.width(size, text) <= max {
return text.to_string();
}
let room = max - self.width(size, "…");
let mut out = String::new();
let mut used = 0.0;
for ch in text.chars() {
let advance = self.for_char(ch).metrics(ch, size).advance_width;
if used + advance > room {
break;
}
used += advance;
out.push(ch);
}
format!("{}…", out.trim_end())
}
/// `text` in lines of at most `max` wide, `first` for the first one, the last cut with an
/// ellipsis
pub(crate) fn wrap(
self,
size: f32,
first: f32,
max: f32,
lines: usize,
text: &str,
) -> Vec<String> {
let width = |out: &Vec<String>| if out.is_empty() { first } else { max };
let mut out: Vec<String> = Vec::new();
let mut line = String::new();
let words: Vec<&str> = text.split_whitespace().collect();
for (index, word) in words.iter().enumerate() {
let candidate = match line.is_empty() {
true => word.to_string(),
false => format!("{line} {word}"),
};
if self.width(size, &candidate) <= width(&out) {
line = candidate;
continue;
}
if out.len() + 1 == lines {
let rest = words[index..].join(" ");
let last = match line.is_empty() {
true => rest,
false => format!("{line} {rest}"),
};
out.push(self.fit(size, width(&out), &last));
return out;
}
if !line.is_empty() {
out.push(std::mem::take(&mut line));
}
line = match self.width(size, word) <= width(&out) {
true => word.to_string(),
false => self.fit(size, width(&out), word),
};
}
if !line.is_empty() {
out.push(line);
}
out
}
}
pub(crate) struct Canvas {
pixels: Vec<Rgb>,
/// coverage kept to `COVERAGE_STEPS`, so the card fits a palette
snapped: bool,
}
impl Canvas {
pub(crate) fn new(background: Rgb) -> Self {
Self {
pixels: vec![background; WIDTH * HEIGHT],
snapped: true,
}
}
/// a canvas that blends coverage as it is
pub(crate) fn exact(background: Rgb) -> Self {
Self {
snapped: false,
..Self::new(background)
}
}
pub(crate) fn blend(&mut self, x: i32, y: i32, color: Rgb, alpha: f32) {
let alpha = match self.snapped {
true => (alpha.min(1.0) * COVERAGE_STEPS).round() / COVERAGE_STEPS,
false => alpha.min(1.0),
};
if alpha <= 0.0 || x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 {
return;
}
let pixel = &mut self.pixels[y as usize * WIDTH + x as usize];
*pixel = mix(color, *pixel, alpha);
}
/// coverage from the distance of each pixel centre, `shape` returns it signed in pixels
pub(crate) fn fill(
&mut self,
bounds: (Point, Point),
color: Rgb,
shape: impl Fn(Point) -> f32,
) {
let ((x0, y0), (x1, y1)) = bounds;
for y in (y0.floor() as i32 - 1)..=(y1.ceil() as i32 + 1) {
for x in (x0.floor() as i32 - 1)..=(x1.ceil() as i32 + 1) {
let distance = shape((x as f32 + 0.5, y as f32 + 0.5));
self.blend(x, y, color, (0.5 - distance).clamp(0.0, 1.0));
}
}
}
pub(crate) fn rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Rgb) {
let bounds = ((x, y), (x + width, y + height));
self.fill(bounds, color, |point| boxed(bounds, point));
}
/// a `width` thick line inside the rectangle's edge
pub(crate) fn outline(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
line: f32,
color: Rgb,
) {
self.rect(x, y, width, line, color);
self.rect(x, y + height - line, width, line, color);
self.rect(x, y + line, line, height - 2.0 * line, color);
self.rect(x + width - line, y + line, line, height - 2.0 * line, color);
}
/// draws `text` from `x` and returns where it ends
pub(crate) fn text(
&mut self,
face: Face,
size: f32,
x: f32,
baseline: f32,
text: &str,
color: Rgb,
) -> f32 {
let mut pen = x;
for ch in text.chars() {
let font = face.for_char(ch);
let (metrics, coverage) = font.rasterize(ch, size);
let left = pen.round() as i32 + metrics.xmin;
let top = baseline.round() as i32 - metrics.ymin - metrics.height as i32;
for (row, ink) in coverage.chunks(metrics.width.max(1)).enumerate() {
for (column, &alpha) in ink.iter().enumerate() {
self.blend(
left + column as i32,
top + row as i32,
color,
f32::from(alpha) / 255.0,
);
}
}
pen += metrics.advance_width;
}
pen
}
/// an rgb image drawn at its own size from `(x, y)`
pub(crate) fn image(&mut self, x: i32, y: i32, width: u32, rgb: &[u8]) {
for (index, pixel) in rgb.as_chunks::<3>().0.iter().enumerate() {
let (column, row) = (index as u32 % width, index as u32 / width);
let (px, py) = (x + column as i32, y + row as i32);
if px >= 0 && py >= 0 && (px as usize) < WIDTH && (py as usize) < HEIGHT {
self.pixels[py as usize * WIDTH + px as usize] = *pixel;
}
}
}
/// indexed when the card fits a palette, which it does unless an avatar brings its own colours
pub(crate) fn png(&self) -> anyhow::Result<Vec<u8>> {
// an rgb card is the rare one (an avatar, the index's), so it's worth the slower best
let (colour_type, palette, rows, level) = match self.palette() {
Some((palette, indices)) => (3, Some(palette), indices, flate2::Compression::default()),
None => (
2,
None,
self.pixels.iter().flatten().copied().collect(),
flate2::Compression::best(),
),
};
let stride = rows.len() / HEIGHT;
let mut raw = Vec::with_capacity(rows.len() + HEIGHT);
for row in rows.chunks(stride) {
raw.push(0);
raw.extend(row);
}
let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), level);
zlib.write_all(&raw).context("deflate a card")?;
let idat = zlib.finish().context("deflate a card")?;
let mut ihdr = Vec::with_capacity(13);
ihdr.extend((WIDTH as u32).to_be_bytes());
ihdr.extend((HEIGHT as u32).to_be_bytes());
// 8-bit indexed or rgb, deflate, no filtering, no interlace
ihdr.extend([8, colour_type, 0, 0, 0]);
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
let mut chunks: Vec<(&[u8; 4], &[u8])> = vec![(b"IHDR", &ihdr)];
if let Some(palette) = &palette {
chunks.push((b"PLTE", palette));
}
chunks.extend([(b"IDAT", idat.as_slice()), (b"IEND", &[][..])]);
for (kind, data) in chunks {
png.extend((data.len() as u32).to_be_bytes());
let mut crc = crc32fast::Hasher::new();
crc.update(kind);
crc.update(data);
png.extend(kind);
png.extend(data);
png.extend(crc.finalize().to_be_bytes());
}
Ok(png)
}
/// the palette and each pixel's index into it, if there are 256 colours or fewer
fn palette(&self) -> Option<(Vec<u8>, Vec<u8>)> {
let mut palette: Vec<Rgb> = Vec::new();
let mut indices = Vec::with_capacity(self.pixels.len());
let mut last: Option<(Rgb, u8)> = None;
for &pixel in &self.pixels {
let index = match last {
Some((colour, index)) if colour == pixel => index,
_ => {
let index = match palette.iter().position(|&colour| colour == pixel) {
Some(index) => index,
None if palette.len() == 256 => return None,
None => {
palette.push(pixel);
palette.len() - 1
}
} as u8;
last = Some((pixel, index));
index
}
};
indices.push(index);
}
Some((palette.concat(), indices))
}
}
/// `alpha` of `over` on `under`, css's color-mix in srgb
pub(crate) fn mix(over: Rgb, under: Rgb, alpha: f32) -> Rgb {
let mut out = under;
for (out, (over, under)) in out.iter_mut().zip(over.into_iter().zip(under)) {
*out = (f32::from(under) + (f32::from(over) - f32::from(under)) * alpha).round() as u8;
}
out
}
/// the signed distance to an axis-aligned rectangle, negative inside
pub(crate) fn boxed(((x0, y0), (x1, y1)): (Point, Point), (x, y): Point) -> f32 {
(x0 - x).max(x - x1).max(y0 - y).max(y - y1)
}