use std::path::Path;
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
use anyhow::{anyhow, Context, Result};
use image::ImageEncoder;
use image::{ImageBuffer, Rgba, RgbaImage};
use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut};
use imageproc::rect::Rect;
use crate::render::colors::color_to_rgba;
use crate::render::screen::ScreenSnapshot;
static EMBEDDED_FONT: &[u8] = include_bytes!("fonts/JetBrainsMono-Regular.ttf");
#[derive(Debug, Clone)]
pub struct ScreenshotConfig {
pub font_path: Option<String>,
pub font_size: f32,
pub line_height: f32,
pub mouse_cursor: Option<(u16, u16)>,
pub mouse_held: bool,
}
impl Default for ScreenshotConfig {
fn default() -> Self {
Self {
font_path: None,
font_size: 14.0,
line_height: 1.2,
mouse_cursor: None,
mouse_held: false,
}
}
}
pub struct Screenshot {
screen: ScreenSnapshot,
config: ScreenshotConfig,
}
impl Screenshot {
pub fn new(screen: ScreenSnapshot) -> Self {
Self {
screen,
config: ScreenshotConfig::default(),
}
}
pub fn font_path(mut self, path: &str) -> Self {
self.config.font_path = Some(path.to_string());
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.config.font_size = size;
self
}
pub fn mouse_cursor(mut self, col: u16, row: u16, held: bool) -> Self {
self.config.mouse_cursor = Some((col, row));
self.config.mouse_held = held;
self
}
pub fn render(&self) -> Result<RgbaImage> {
render_screen(&self.screen, &self.config)
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
if ext.as_deref() != Some("png") {
return Err(anyhow!(
"screenshot output must be a .png file, got: {}",
path.display()
));
}
let image = self.render()?;
let bytes = encode_png(&image)?;
std::fs::write(path, bytes)
.with_context(|| format!("save screenshot to {}", path.display()))?;
Ok(())
}
pub fn to_png(&self) -> Result<Vec<u8>> {
let image = self.render()?;
encode_png(&image)
}
}
fn encode_png(image: &RgbaImage) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut bytes)
.write_image(
image.as_raw(),
image.width(),
image.height(),
image::ColorType::Rgba8.into(),
)
.context("encode PNG screenshot")?;
Ok(bytes)
}
fn render_screen(screen: &ScreenSnapshot, config: &ScreenshotConfig) -> Result<RgbaImage> {
if !config.font_size.is_finite() || config.font_size <= 0.0 {
return Err(anyhow!("font size must be a finite number greater than 0"));
}
if !config.line_height.is_finite() || config.line_height <= 0.0 {
return Err(anyhow!(
"line height must be a finite number greater than 0"
));
}
let font_data: std::borrow::Cow<'_, [u8]> = match &config.font_path {
Some(path) => {
let bytes = std::fs::read(path).with_context(|| format!("read font file {path:?}"))?;
std::borrow::Cow::Owned(bytes)
}
None => std::borrow::Cow::Borrowed(EMBEDDED_FONT),
};
let font =
FontRef::try_from_slice(&font_data).map_err(|err| anyhow!("parse font data: {err}"))?;
let scale = PxScale::from(config.font_size);
let scaled_font = font.as_scaled(scale);
let line_height = config.font_size * config.line_height;
let char_width = scaled_font.h_advance(font.glyph_id('M'));
let width = (screen.cols() as f32 * char_width).ceil() as u32;
let height = (screen.rows() as f32 * line_height).ceil() as u32;
let mut image: RgbaImage = ImageBuffer::new(width, height);
let full_rect = Rect::at(0, 0).of_size(width, height);
draw_filled_rect_mut(&mut image, full_rect, Rgba([0, 0, 0, 255]));
for (row_idx, row) in screen.cells().iter().enumerate() {
let y = row_idx as f32 * line_height;
for (col_idx, cell) in row.iter().enumerate() {
let x = col_idx as f32 * char_width;
let bg = if cell.attrs.inverse {
color_to_rgba(cell.fg, true)
} else {
color_to_rgba(cell.bg, false)
};
let rect = Rect::at(x.round() as i32, y.round() as i32)
.of_size(char_width.ceil() as u32, line_height.ceil() as u32);
draw_filled_rect_mut(&mut image, rect, bg);
}
}
for (row_idx, row) in screen.cells().iter().enumerate() {
let y = row_idx as f32 * line_height;
for (col_idx, cell) in row.iter().enumerate() {
let x = col_idx as f32 * char_width;
let fg = if cell.attrs.inverse {
color_to_rgba(cell.bg, false)
} else {
color_to_rgba(cell.fg, true)
};
if cell.is_wide_continuation || cell.contents.is_empty() || cell.contents == " " {
continue;
}
draw_text_mut(
&mut image,
fg,
x.round() as i32,
y.round() as i32,
scale,
&font,
&cell.contents,
);
}
}
if let Some((cur_col, cur_row)) = config.mouse_cursor {
if cur_col < screen.cols() && cur_row < screen.rows() {
paint_mouse_cursor(
&mut image,
cur_col,
cur_row,
char_width,
line_height,
scale,
&font,
config.mouse_held,
);
}
}
Ok(image)
}
const MOUSE_CURSOR_RGBA: Rgba<u8> = Rgba([255, 0, 200, 255]);
const MOUSE_CURSOR_HELD_FG: Rgba<u8> = Rgba([255, 255, 255, 255]);
#[allow(clippy::too_many_arguments)]
fn paint_mouse_cursor(
image: &mut RgbaImage,
cur_col: u16,
cur_row: u16,
char_width: f32,
line_height: f32,
scale: PxScale,
font: &FontRef<'_>,
held: bool,
) {
let x = (cur_col as f32 * char_width).round() as i32;
let y = (cur_row as f32 * line_height).round() as i32;
let w = char_width.ceil() as u32;
let h = line_height.ceil() as u32;
let glyph_color = if held {
draw_filled_rect_mut(image, Rect::at(x, y).of_size(w, h), MOUSE_CURSOR_RGBA);
MOUSE_CURSOR_HELD_FG
} else {
MOUSE_CURSOR_RGBA
};
draw_text_mut(image, glyph_color, x, y, scale, font, "△");
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::Screenshot;
use crate::render::screen::ScreenSnapshot;
#[test]
fn saves_png() {
let mut parser = crate::emu::Parser::new(4, 20, 0);
parser.process(b"\x1b[32mhello\x1b[0m");
let screenshot = Screenshot::new(ScreenSnapshot::from_vt100(parser.screen()));
let base = std::env::temp_dir().join(format!(
"tu-screenshot-test-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let png = base.with_extension("png");
let gif = base.with_extension("gif");
screenshot.save(&png).unwrap();
assert!(screenshot.save(&gif).is_err());
let _ = std::fs::remove_file(png);
}
#[test]
fn rejects_invalid_font_size() {
let mut parser = crate::emu::Parser::new(2, 4, 0);
parser.process(b"hi");
let screenshot =
Screenshot::new(ScreenSnapshot::from_vt100(parser.screen())).font_size(0.0);
assert!(screenshot.render().is_err());
}
}