the index's og card draws and encodes on the og cards' canvas
Signed-off-by: dawn <dawn@klbr.net>
112345678910345611 unmodified lines18192021302223242523 unmodified lines49505152626353545556575859606162636465666768697071727366676869707475767778798081828384858687888990737475767778798091929394959697989910010182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918010210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214318218318418518618718818919019119219319419514414514614719920020120220320420520620720820921021121221321421521621721821922022122222322422522622714814915015129 unmodified lines181182183184265266267268269270185186187188189190273191192193194195432 unmodified linesExpand alluse std::io::Write as _;use crate::og_card::canvas::{Canvas, Face, Point, Rgb};
use anyhow::Context as _;
use super::{OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH};
const FONT: &[u8] = include_bytes!("../favicon/JetBrainsMonoNL-Medium.ttf");
type Rgb = [u8; 3];
const BACKGROUND: Rgb = [0x1b, 0x15, 0x19];const TITLE: Rgb = [0xf1, 0xe4, 0xea];const MUTED: Rgb = [0xa8, 0x91, 0x9d];const ACCENT: Rgb = [0xf2, 0x8b, 0xb5];11 unmodified linesconst YARN_ORIGIN: (f32, f32) = (160.0, 168.0);const YARN_SCALE: f32 = 10.0;const YARN_CIRCLE: ((f32, f32), f32) = ((15.0, 14.5), 13.0);
type Point = (f32, f32);type Cubic = [Point; 4];
const fn line(from: Point, to: Point) -> Cubic { [from, from, to, to]23 unmodified linesconst TAIL_WIDTH: f32 = 2.6;
const CURVE_SEGMENTS: usize = 32;
struct Canvas { pixels: Vec<Rgb>,fn stroke(c: &mut Canvas, curves: &[Cubic], width: f32, color: Rgb) { let points: Vec<Point> = curves.iter().flat_map(flatten).collect(); let half = width * YARN_SCALE / 2.0; let bounds = points.iter().fold( ((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)), |((x0, y0), (x1, y1)), &(x, y)| { ( (x0.min(x - half), y0.min(y - half)), (x1.max(x + half), y1.max(y + half)), ) }, ); c.fill(bounds, color, |at| { points .windows(2) .map(|pair| segment_distance(at, pair[0], pair[1])) .fold(f32::MAX, f32::min) - half });}
impl Canvas { fn new() -> Self { Self { pixels: vec![BACKGROUND; (OG_IMAGE_WIDTH * OG_IMAGE_HEIGHT) as usize], }fn yarn(c: &mut Canvas) { let ((cx, cy), r) = YARN_CIRCLE; let (centre, radius) = (place((cx, cy)), r * YARN_SCALE); c.fill( ( (centre.0 - radius, centre.1 - radius), (centre.0 + radius, centre.1 + radius), ), YARN, |(x, y)| (x - centre.0).hypot(y - centre.1) - radius, ); for thread in &THREADS { stroke(c, std::slice::from_ref(thread), THREAD_WIDTH, YARN_THREAD); } stroke(c, &TAIL, TAIL_WIDTH, YARN);}
fn blend(&mut self, x: i32, y: i32, color: Rgb, alpha: f32) { if alpha <= 0.0 || x < 0 || y < 0 || x >= OG_IMAGE_WIDTH as i32 || y >= OG_IMAGE_HEIGHT as i32 { return;/// `*text*` spans take the accent colour. a line wider than the card shrinks to fitfn text(c: &mut Canvas, line: &str, baseline: f32, size: f32, color: Rgb) { let fallback = |ch: char| match Face::Wide.has(ch) { true => None, false => fallback_glyph(ch), }; let advance = |ch: char, s: f32| -> f32 { match fallback(ch) { Some(fb) => fb.advance * (s / 26.0), None => Face::Wide.width(s, ch.encode_utf8(&mut [0; 4])), } let alpha = alpha.min(1.0); let pixel = &mut self.pixels[y as usize * OG_IMAGE_WIDTH as usize + x as usize]; for (dst, src) in pixel.iter_mut().zip(color) { *dst = (f32::from(*dst) + (f32::from(src) - f32::from(*dst)) * alpha).round() as u8; } }
/// coverage from the distance of each pixel centre, `shape` returns it signed in pixels 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)); } } }
fn stroke(&mut self, curves: &[Cubic], width: f32, color: Rgb) { let points: Vec<Point> = curves.iter().flat_map(|curve| flatten(curve)).collect(); let half = width * YARN_SCALE / 2.0; let bounds = points.iter().fold( ((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)), |((x0, y0), (x1, y1)), &(x, y)| { ( (x0.min(x - half), y0.min(y - half)), (x1.max(x + half), y1.max(y + half)), ) }, ); self.fill(bounds, color, |at| { points .windows(2) .map(|pair| segment_distance(at, pair[0], pair[1])) .fold(f32::MAX, f32::min) - half }); }
fn yarn(&mut self) { let ((cx, cy), r) = YARN_CIRCLE; let (centre, radius) = (place((cx, cy)), r * YARN_SCALE); self.fill( ( (centre.0 - radius, centre.1 - radius), (centre.0 + radius, centre.1 + radius), ), YARN, |(x, y)| (x - centre.0).hypot(y - centre.1) - radius, ); for thread in &THREADS { self.stroke(std::slice::from_ref(thread), THREAD_WIDTH, YARN_THREAD); } self.stroke(&TAIL, TAIL_WIDTH, YARN); }
/// `*text*` spans take the accent colour. a line wider than the card shrinks to fit fn text(&mut self, font: &fontdue::Font, line: &str, baseline: f32, size: f32, color: Rgb) { let plain: String = line.chars().filter(|&ch| ch != '*').collect(); let advance = |ch: char, s: f32| -> f32 { if font.lookup_glyph_index(ch) == 0 && let Some(fb) = fallback_glyph(ch) { return fb.advance * (s / 26.0); } font.metrics(ch, s).advance_width }; let width: f32 = plain.chars().map(|ch| advance(ch, size)).sum(); let size = if width > TEXT_RIGHT - TEXT_LEFT { size * (TEXT_RIGHT - TEXT_LEFT) / width } else { size }; let mut pen = TEXT_LEFT; for (index, span) in line.split('*').enumerate() { let span_color = if index % 2 == 1 { ACCENT } else { color }; for ch in span.chars() { let ch_color = if color == TITLE && ch == '.' { ACCENT } else { span_color }; if font.lookup_glyph_index(ch) == 0 && let Some(fb) = fallback_glyph(ch) { let left = pen.round() as i32 + fb.xmin; let top = baseline.round() as i32 - fb.ymin - fb.height as i32; for (row, ink) in fb.coverage.chunks(fb.width.max(1)).enumerate() { for (column, &alpha) in ink.iter().enumerate() { self.blend( left + column as i32, top + row as i32, ch_color, f32::from(alpha) / 255.0, ); } } pen += fb.advance * (size / 26.0); continue; }; let width: f32 = line .chars() .filter(|&ch| ch != '*') .map(|ch| advance(ch, size)) .sum(); let size = if width > TEXT_RIGHT - TEXT_LEFT { size * (TEXT_RIGHT - TEXT_LEFT) / width } else { size }; let mut pen = TEXT_LEFT; for (index, span) in line.split('*').enumerate() { let span_color = if index % 2 == 1 { ACCENT } else { color }; for ch in span.chars() { let ch_color = if color == TITLE && ch == '.' { ACCENT } else { span_color }; let Some(fb) = fallback(ch) else { pen = c.text( Face::Wide, size, pen, baseline, ch.encode_utf8(&mut [0; 4]), ch_color, ); continue; }; let left = pen.round() as i32 + fb.xmin; let top = baseline.round() as i32 - fb.ymin - fb.height as i32; for (row, ink) in fb.coverage.chunks(fb.width.max(1)).enumerate() { for (column, &alpha) in ink.iter().enumerate() { c.blend( left + column as i32, top + row as i32, ch_color, f32::from(alpha) / 255.0, ); } 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, ch_color, f32::from(alpha) / 255.0, ); } } pen += metrics.advance_width; } pen += advance(ch, size); } }
fn png(&self) -> anyhow::Result<Vec<u8>> { let mut raw = Vec::with_capacity(self.pixels.len() * 3 + OG_IMAGE_HEIGHT as usize); for row in self.pixels.chunks(OG_IMAGE_WIDTH as usize) { raw.push(0); raw.extend(row.iter().flatten()); } let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); zlib.write_all(&raw).context("deflate og.png")?; let idat = zlib.finish().context("deflate og.png")?;
let mut ihdr = Vec::with_capacity(13); ihdr.extend(OG_IMAGE_WIDTH.to_be_bytes()); ihdr.extend(OG_IMAGE_HEIGHT.to_be_bytes()); // 8-bit rgb, deflate, adaptive filtering, no interlace ihdr.extend([8, 2, 0, 0, 0]);
let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); for (kind, data) in [(b"IHDR", ihdr.as_slice()), (b"IDAT", &idat), (b"IEND", &[])] { 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) }}
fn place((x, y): Point) -> Point { (29 unmodified lines}
/// the 1200x630 card of og-dark.svgpub fn render(title: &str, subtitle: &str, description: &str) -> anyhow::Result<Vec<u8>> { let font = fontdue::Font::from_bytes(FONT, fontdue::FontSettings::default()) .map_err(|err| anyhow::anyhow!("load og font: {err}"))?; let mut canvas = Canvas::new(); canvas.yarn(); canvas.text(&font, title, TITLE_BASELINE, 64.0, TITLE); canvas.text(&font, subtitle, SUBTITLE_BASELINE, 34.0, MUTED); let mut canvas = Canvas::exact(BACKGROUND); yarn(&mut canvas); text(&mut canvas, title, TITLE_BASELINE, 64.0, TITLE); text(&mut canvas, subtitle, SUBTITLE_BASELINE, 34.0, MUTED); for (index, line) in description.lines().enumerate() { let baseline = DESCRIPTION_BASELINE + index as f32 * DESCRIPTION_LINE_HEIGHT; canvas.text(&font, line, baseline, 26.0, MUTED); text(&mut canvas, line, baseline, 26.0, MUTED); } canvas.png()}
432 unmodified linesExpand all3 unmodified lines456789891011121112131415161718181920212237 unmodified lines6061626364656667686970711 unmodified line7374757673777879808182838084858687888990919289939495969711 unmodified lines10911011111210911311411511611736 unmodified lines1541551561571541581591601611621631641591651661671681691701711721731741751761771781791651661801811821831841851861871881891901911921751931941951961976 unmodified lines2042052062071902082092102112122131962142152162172187 unmodified lines22622722822921223023123223323422 unmodified lines2572582592602432612622632642652 unmodified lines26826927027125425525625727227327427527627727827928028128228328428528628728826528929029129229347 unmodified lines3413423433443213453463473483493503513523533303543553563 unmodified linesuse std::sync::OnceLock;
use anyhow::Context as _;
pub(super) const WIDTH: usize = crate::index_page::OG_IMAGE_WIDTH as usize;pub(super) const HEIGHT: usize = crate::index_page::OG_IMAGE_HEIGHT as usize;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(super) type Rgb = [u8; 3];pub(super) type Point = (f32, f32);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 paletteconst COVERAGE_STEPS: f32 = 15.0;
#[derive(Clone, Copy)]pub(super) enum Face {pub(crate) enum Face { Sans, SansBold, Mono, MonoBold,37 unmodified lines 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) {1 unmodified line _ => font, } }
pub(super) fn width(self, size: f32, text: &str) -> f32 { 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(super) fn baseline(self, size: f32, top: f32, height: f32) -> f32 { 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(super) fn fit(self, size: f32, max: f32, text: &str) -> String { 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, "…");11 unmodified lines }
/// `text` in lines of at most `max` wide, `first` for the first one, the last cut with an /// ellipsis pub(super) fn wrap( pub(crate) fn wrap( self, size: f32, first: f32, max: f32,36 unmodified lines out }}
pub(super) struct Canvas {pub(crate) struct Canvas { pixels: Vec<Rgb>, /// coverage kept to `COVERAGE_STEPS`, so the card fits a palette snapped: bool,}
impl Canvas { pub(super) fn new(background: Rgb) -> Self { 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(super) fn blend(&mut self, x: i32, y: i32, color: Rgb, alpha: f32) { let alpha = (alpha.min(1.0) * COVERAGE_STEPS).round() / COVERAGE_STEPS; 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(super) fn fill( pub(crate) fn fill( &mut self, bounds: (Point, Point), color: Rgb, shape: impl Fn(Point) -> f32,6 unmodified lines } } }
pub(super) fn rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Rgb) { 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(super) fn outline( pub(crate) fn outline( &mut self, x: f32, y: f32, width: f32,7 unmodified lines self.rect(x + width - line, y + line, line, height - 2.0 * line, color); }
/// draws `text` from `x` and returns where it ends pub(super) fn text( pub(crate) fn text( &mut self, face: Face, size: f32, x: f32,22 unmodified lines pen }
/// an rgb image drawn at its own size from `(x, y)` pub(super) fn image(&mut self, x: i32, y: i32, width: u32, rgb: &[u8]) { 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 {2 unmodified lines } }
/// indexed when the card fits a palette, which it does unless an avatar brings its own colours pub(super) fn png(&self) -> anyhow::Result<Vec<u8>> { let (colour_type, palette, rows) = match self.palette() { Some((palette, indices)) => (3, Some(palette), indices), None => (2, None, self.pixels.iter().flatten().copied().collect()), 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(), flate2::Compression::default()); 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);47 unmodified lines }}
/// `alpha` of `over` on `under`, css's color-mix in srgbpub(super) fn mix(over: Rgb, under: Rgb, alpha: f32) -> Rgb {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 insidepub(super) fn boxed(((x0, y0), (x1, y1)): (Point, Point), (x, y): Point) -> f32 {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)}11 unmodified lines12131415161617181920434 unmodified linesExpand all11 unmodified linesuse serde::Serialize;
use crate::cas::{atomic_write, link_or_reflink};
mod canvas;pub(crate) mod canvas;mod draw;mod plan;#[cfg(test)]mod tests;434 unmodified linesExpand all