From 5fae94f4559fb6c56fc69ef120c40ffe9e89820d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Far=C3=ADas=20Santana?= Date: Sun, 30 May 2021 18:42:25 +0200 Subject: [PATCH] refactor: Split up drawer into multiple files in drawer module Drawer grew to more than a thousand lines, which was making it hard to unit test and work with. I've separated into multiple files and moved them into a drawer module. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cli.rs | 2 +- src/{drawer.rs => drawer/board.rs} | 456 +---------------------------- src/drawer/error.rs | 26 ++ src/drawer/mod.rs | 11 + src/drawer/svgs.rs | 231 +++++++++++++++ src/drawer/termination.rs | 212 ++++++++++++++ src/{ => drawer}/utils.rs | 28 +- src/lib.rs | 1 - 10 files changed, 517 insertions(+), 454 deletions(-) rename src/{drawer.rs => drawer/board.rs} (55%) create mode 100644 src/drawer/error.rs create mode 100644 src/drawer/mod.rs create mode 100644 src/drawer/svgs.rs create mode 100644 src/drawer/termination.rs rename src/{ => drawer}/utils.rs (58%) diff --git a/Cargo.lock b/Cargo.lock index 1e546c9..1f584fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b" [[package]] name = "c2g" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 88648b5..3b4b2db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "c2g" -version = "0.6.0" +version = "0.6.1" authors = ["Tomas Farias "] edition = "2018" diff --git a/src/cli.rs b/src/cli.rs index cc5526f..439196d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -24,7 +24,7 @@ impl Chess2Gif { T: Into + Clone, { let app = App::new("Chess to GIF") - .version("0.6.0") + .version("0.6.1") .author("Tomas Farias ") .about("Turns a PGN chess game into a GIF") .arg( diff --git a/src/drawer.rs b/src/drawer/board.rs similarity index 55% rename from src/drawer.rs rename to src/drawer/board.rs index f4bac6e..4a3ab49 100644 --- a/src/drawer.rs +++ b/src/drawer/board.rs @@ -1,453 +1,11 @@ -use std::fmt; - use image::{imageops, ImageBuffer, Rgba, RgbaImage}; -use include_dir::{include_dir, Dir}; -use pgn_reader::Outcome; use shakmaty::{self, File, Move, Pieces, Rank, Role, Square}; -use thiserror::Error; use tiny_skia::{self, Pixmap, PixmapPaint, Transform}; -use usvg::{self, fontdb, FitTo, Options, Tree}; - -use crate::utils; - -#[cfg(feature = "include-svgs")] -static SVGS_DIR: Dir = include_dir!("svgs/"); - -#[cfg(feature = "include-svgs")] -fn load_svg_string(svg_path: &str) -> Result { - let svg_file = SVGS_DIR - .get_file(&svg_path) - .ok_or(DrawerError::SVGNotFound { - svg: svg_path.to_owned(), - })?; - Ok(svg_file - .contents_utf8() - .expect("Failed to parse file contents") - .to_owned()) -} - -#[cfg(not(feature = "include-svgs"))] -fn load_svg_string(svg_path: &str) -> Result { - let mut f = fs::File::open(&svg_path).map_err(|_| DrawerError::SVGNotFound { - svg: svg_path.to_owned(), - })?; - let mut svg_str = String::new(); - f.read_to_string(&mut svg_str) - .map_err(|source| DrawerError::LoadFile { source })?; - - Ok(svg_str) -} - -#[cfg(feature = "include-fonts")] -static FONTS_DIR: Dir = include_dir!("fonts/"); - -#[cfg(feature = "include-fonts")] -fn font_data(font: &str) -> Result, DrawerError> { - let font_file = FONTS_DIR.get_file(font).ok_or(DrawerError::FontNotFound { - font: font.to_owned(), - })?; - Ok(font_file.contents.to_vec()) -} - -#[cfg(not(feature = "include-fonts"))] -fn font_data(font: &str) -> Result, DrawerError> { - let mut f = fs::File::open(font).map_err(|_| DrawerError::FontNotFound { - font: font.to_owned(), - })?; - let mut buffer: Vec = Vec::new(); - f.read_to_end(&mut buffer) - .map_err(|source| DrawerError::LoadFile { source: source })?; - - Ok(buffer) -} - -/// SVG font-weight attribute options -pub enum FontWeight { - Normal, - Bold, - Bolder, - Lighter, - Number(f32), -} - -impl fmt::Display for FontWeight { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - FontWeight::Normal => write!(f, "normal"), - FontWeight::Bold => write!(f, "bold"), - FontWeight::Bolder => write!(f, "bolder"), - FontWeight::Lighter => write!(f, "lighter"), - FontWeight::Number(n) => write!(f, "{}", n), - } - } -} - -/// SVG font-size attribute options -pub enum FontSize { - XXSmall, - XSmall, - Small, - Medium, - Large, - XLarge, - XXLarge, - XXXLarge, - Unit(f32, String), -} - -impl fmt::Display for FontSize { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - FontSize::XXSmall => write!(f, "xx-small"), - FontSize::XSmall => write!(f, "x-small"), - FontSize::Small => write!(f, "small"), - FontSize::Medium => write!(f, "medium"), - FontSize::Large => write!(f, "large"), - FontSize::XLarge => write!(f, "x-large"), - FontSize::XXLarge => write!(f, "xx-large"), - FontSize::XXXLarge => write!(f, "xxx-large"), - FontSize::Unit(n, s) => write!(f, "{}{}", n, s), - } - } -} - -/// A collection of SVG trees -pub struct SVGForest { - pieces_path: Option, - terminations_path: Option, - svg_options: Options, -} - -impl SVGForest { - pub fn new( - font_path: &str, - pieces_path: Option<&str>, - terminations_path: Option<&str>, - ) -> Result { - let mut opt = Options::default(); - - // Load font for coordinates - let mut fonts = fontdb::Database::new(); - let font_data = font_data(font_path)?; - fonts.load_font_data(font_data); - opt.keep_named_groups = true; - opt.fontdb = fonts; - opt.font_size = 16.0; - // There should only be 1 font in DB - opt.font_family = (*(opt.fontdb.faces())[0].family).to_owned(); - - Ok(SVGForest { - pieces_path: pieces_path.map_or(None, |s| Some(s.to_owned())), - terminations_path: terminations_path.map_or(None, |s| Some(s.to_owned())), - svg_options: opt, - }) - } - - pub fn piece_tree( - &self, - role: &Role, - color: &shakmaty::Color, - additional: Option<&str>, - ) -> Result { - let pieces_path = self.pieces_path.as_ref().expect("pieces_path not defined"); - let full_piece_path = match additional { - Some(s) => format!("{}/{}_{}_{}.svg", pieces_path, color.char(), role.char(), s), - None => format!("{}/{}_{}.svg", pieces_path, color.char(), role.char()), - }; - let svg_string = load_svg_string(&full_piece_path).unwrap_or_else( - // Fallback to regular piece if additional not found - |_| { - load_svg_string(&format!( - "{}/{}_{}.svg", - pieces_path, - color.char(), - role.char() - )) - .unwrap() - }, - ); - Tree::from_str(&svg_string, &self.svg_options) - .map_err(|source| DrawerError::LoadPieceSVG { source }) - } - - pub fn termination_tree( - &self, - termination: F, - color: Option, - ) -> Result - where - F: fmt::Display, - { - let terminations_path = self - .terminations_path - .as_ref() - .expect("terminations_path not defined"); - let full_termination_path = match color { - Some(c) => format!("{}/{}_{}.svg", terminations_path, termination, c.char()), - None => format!("{}/{}.svg", terminations_path, termination), - }; - - let svg_string = load_svg_string(&full_termination_path).unwrap_or_else( - // Fallback to regular termination if color not found - |_| load_svg_string(&format!("{}/{}.svg", terminations_path, termination)).unwrap(), - ); - Tree::from_str(&svg_string, &self.svg_options) - .map_err(|source| DrawerError::LoadPieceSVG { source }) - } - - pub fn str_svg_tree( - &self, - s: &str, - color: Rgba, - background: Rgba, - height: u32, - width: u32, - x: u32, - y: u32, - font_weight: FontWeight, - font_size: FontSize, - ) -> Result { - let svg_string = format!( - " {}", - height, - width, - background[0], - background[1], - background[2], - x, - y, - color[0], - color[1], - color[2], - font_weight.to_string(), - font_size.to_string(), - s, - ); - - Tree::from_str(&svg_string, &self.svg_options).map_err(|source| { - DrawerError::SVGTreeFromStrError { - source, - s: s.to_owned(), - } - }) - } -} - -/// A piece in a chess board -#[derive(Debug)] -pub struct PieceInBoard { - square: Square, - role: Role, - color: shakmaty::Color, -} - -impl PieceInBoard { - pub fn new_king(square: shakmaty::Square, color: shakmaty::Color) -> Self { - PieceInBoard { - square, - color, - role: Role::King, - } - } -} - -/// All possible endings for a chess game -#[derive(Debug)] -pub enum TerminationReason { - Checkmate { winner: shakmaty::Color }, - Stalemate, - DrawAgreement, - DrawByRepetition, - Timeout { winner: shakmaty::Color }, - Resignation { winner: shakmaty::Color }, - InsufficientMaterial, - DrawByTimeoutVsInsufficientMaterial, -} - -impl TerminationReason { - pub fn from_outcome(outcome: Outcome, reason: Option<&str>) -> Self { - match outcome { - Outcome::Decisive { winner: w } => { - let winner = shakmaty::Color::from_char(w.char()).unwrap(); - match reason { - None | Some("checkmate") => TerminationReason::Checkmate { winner }, - Some("timeout") => TerminationReason::Timeout { winner }, - Some("resignation") => TerminationReason::Resignation { winner }, - Some(&_) => panic!("Unknown termination reason"), - } - } - Outcome::Draw => match reason { - Some("insufficient material") => TerminationReason::InsufficientMaterial, - Some("timeout") => TerminationReason::DrawByTimeoutVsInsufficientMaterial, - Some("stalemate") => TerminationReason::Stalemate, - Some("repetition") => TerminationReason::DrawByRepetition, - Some("agreement") | None => TerminationReason::DrawAgreement, - Some(&_) => panic!("Unknown termination reason"), - }, - } - } - - pub fn is_draw(&self) -> bool { - match self { - TerminationReason::Stalemate - | TerminationReason::DrawAgreement - | TerminationReason::DrawByRepetition - | TerminationReason::DrawByTimeoutVsInsufficientMaterial - | TerminationReason::InsufficientMaterial => true, - _ => false, - } - } -} - -impl fmt::Display for TerminationReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - TerminationReason::Checkmate { winner: _ } => write!(f, "checkmate"), - // Each draw variation should have it's own circle eventually - TerminationReason::Stalemate - | TerminationReason::DrawAgreement - | TerminationReason::DrawByRepetition - | TerminationReason::DrawByTimeoutVsInsufficientMaterial - | TerminationReason::InsufficientMaterial => write!(f, "draw"), - TerminationReason::Resignation { winner: _ } => write!(f, "resignation"), - TerminationReason::Timeout { winner: _ } => write!(f, "timeout"), - } - } -} - -/// Draws highlights to indicate the game's termination reason -pub struct TerminationDrawer { - width: u32, - height: u32, - svgs: SVGForest, -} - -impl TerminationDrawer { - pub fn new( - width: u32, - height: u32, - termination_path: &str, - font_path: &str, - ) -> Result { - let svgs = SVGForest::new(font_path, None, Some(termination_path))?; - - Ok(TerminationDrawer { - width, - height, - svgs, - }) - } - - pub fn termination_circle_pixmap( - &self, - color: shakmaty::Color, - reason: &TerminationReason, - ) -> Result { - let mut pixmap = Pixmap::new(self.width, self.height).unwrap(); - - let rtree = self.svgs.termination_tree(reason, Some(color))?; - - let fit_to = FitTo::Height(self.height); - resvg::render(&rtree, fit_to, pixmap.as_mut()).ok_or(DrawerError::SVGRenderError { - svg: format!("{}", reason), - })?; - - Ok(pixmap) - } - - pub fn win_circle_pixmap(&self) -> Result { - let mut pixmap = Pixmap::new(self.width, self.height).unwrap(); - - let rtree = self.svgs.termination_tree("win", None)?; +use usvg::FitTo; - let fit_to = FitTo::Height(self.height); - resvg::render(&rtree, fit_to, pixmap.as_mut()).ok_or(DrawerError::SVGRenderError { - svg: "win".to_string(), - })?; - - Ok(pixmap) - } - - pub fn termination_circle_image( - &self, - color: shakmaty::Color, - reason: &TerminationReason, - ) -> Result { - let pixmap = self.termination_circle_pixmap(color, reason)?; - - ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).ok_or( - DrawerError::ImageTooBig { - image: format!("{}_{:?}.svg", reason, color), - }, - ) - } - - pub fn win_circle_image(&self) -> Result { - let pixmap = self.win_circle_pixmap()?; - - ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).ok_or( - DrawerError::ImageTooBig { - image: "win.svg".to_string(), - }, - ) - } - - pub fn draw_termination_circles( - &mut self, - reason: TerminationReason, - winner: PieceInBoard, - loser: PieceInBoard, - img: &mut RgbaImage, - ) -> Result<(), DrawerError> { - let (circle_winner, circle_loser) = if reason.is_draw() { - let c1 = self.termination_circle_image(loser.color, &reason)?; - let c2 = self.termination_circle_image(winner.color, &reason)?; - (c1, c2) - } else { - let c1 = self.win_circle_image()?; - let c2 = self.termination_circle_image(loser.color, &reason)?; - (c1, c2) - }; - - let height = img.height(); - let width = img.width(); - - let winner_x = (width / 8) * u32::from(winner.square.file()); - let winner_y = height - (width / 8) * (u32::from(winner.square.rank()) + 2); - - let loser_x = (width / 8) * u32::from(loser.square.file()); - let loser_y = height - (width / 8) * (u32::from(loser.square.rank()) + 2); - - imageops::overlay(img, &circle_winner, winner_x, winner_y); - imageops::overlay(img, &circle_loser, loser_x, loser_y); - - Ok(()) - } -} - -#[derive(Error, Debug)] -pub enum DrawerError { - #[error("SVG {svg:?} not found")] - SVGNotFound { svg: String }, - #[error("Font {font:?} not found in fonts directory")] - FontNotFound { font: String }, - #[error("Could not load file")] - LoadFile { - #[from] - source: std::io::Error, - }, - #[error("Could not load piece svg file")] - LoadPieceSVG { - #[from] - source: usvg::Error, - }, - #[error("An image {image:?} is too big to fit in an ImageBuffer")] - ImageTooBig { image: String }, - #[error("SVG {svg:?} failed to be rendered")] - SVGRenderError { svg: String }, - #[error("A correct SVG for {s:?} could not be produced")] - SVGTreeFromStrError { source: usvg::Error, s: String }, -} +use super::error::DrawerError; +use super::svgs::{FontSize, FontWeight, SVGForest}; +use super::utils; pub struct BoardDrawer { size: u32, @@ -997,7 +555,7 @@ mod tests { let dark: [u8; 4] = [249, 100, 100, 1]; let light: [u8; 4] = [255, 253, 253, 1]; let mut drawer = - BoardDrawer::new("some/path/", false, "roboto.ttf", 80, dark, light).unwrap(); + BoardDrawer::new(false, 80, dark, light, "some/path/", "roboto.ttf").unwrap(); let square = Square::new(0); // A1 is dark let expected = ImageBuffer::from_pixel(10, 10, image::Rgba(dark)); @@ -1012,7 +570,7 @@ mod tests { fn test_sizes() { let dark: [u8; 4] = [249, 100, 100, 1]; let light: [u8; 4] = [255, 253, 253, 1]; - let drawer = BoardDrawer::new("some/path/", false, "roboto.ttf", 80, dark, light).unwrap(); + let drawer = BoardDrawer::new(false, 80, dark, light, "some/path/", "roboto.ttf").unwrap(); assert_eq!(drawer.size(), 80); assert_eq!(drawer.square_size(), 10); @@ -1023,7 +581,7 @@ mod tests { let dark: [u8; 4] = [249, 100, 100, 1]; let light: [u8; 4] = [255, 253, 253, 1]; let mut drawer = - BoardDrawer::new("some/path/", false, "roboto.ttf", 80, dark, light).unwrap(); + BoardDrawer::new(false, 80, dark, light, "some/path/", "roboto.ttf").unwrap(); let mut pixmap = Pixmap::new(10, 10).unwrap(); let square = Square::new(9); // B2 is dark diff --git a/src/drawer/error.rs b/src/drawer/error.rs new file mode 100644 index 0000000..822dc64 --- /dev/null +++ b/src/drawer/error.rs @@ -0,0 +1,26 @@ +use thiserror::Error; +use usvg; + +#[derive(Error, Debug)] +pub enum DrawerError { + #[error("SVG {svg:?} not found")] + SVGNotFound { svg: String }, + #[error("Font {font:?} not found in fonts directory")] + FontNotFound { font: String }, + #[error("Could not load file")] + LoadFile { + #[from] + source: std::io::Error, + }, + #[error("Could not load piece svg file")] + LoadPieceSVG { + #[from] + source: usvg::Error, + }, + #[error("An image {image:?} is too big to fit in an ImageBuffer")] + ImageTooBig { image: String }, + #[error("SVG {svg:?} failed to be rendered")] + SVGRenderError { svg: String }, + #[error("A correct SVG for {s:?} could not be produced")] + SVGTreeFromStrError { source: usvg::Error, s: String }, +} diff --git a/src/drawer/mod.rs b/src/drawer/mod.rs new file mode 100644 index 0000000..23d9aaf --- /dev/null +++ b/src/drawer/mod.rs @@ -0,0 +1,11 @@ +pub mod board; +pub mod error; +pub mod svgs; +pub mod termination; +pub mod utils; + +pub use board::BoardDrawer; +pub use error::DrawerError; +pub use svgs::{FontSize, FontWeight, SVGForest}; +pub use termination::{TerminationDrawer, TerminationReason}; +pub use utils::PieceInBoard; diff --git a/src/drawer/svgs.rs b/src/drawer/svgs.rs new file mode 100644 index 0000000..33f2d0b --- /dev/null +++ b/src/drawer/svgs.rs @@ -0,0 +1,231 @@ +use std::fmt; + +use image::Rgba; +use include_dir::{include_dir, Dir}; +use shakmaty::{self, Role}; +use usvg::{self, fontdb, Options, Tree}; + +use super::error::DrawerError; + +#[cfg(feature = "include-svgs")] +static SVGS_DIR: Dir = include_dir!("svgs/"); + +#[cfg(feature = "include-svgs")] +fn load_svg_string(svg_path: &str) -> Result { + let svg_file = SVGS_DIR + .get_file(&svg_path) + .ok_or(DrawerError::SVGNotFound { + svg: svg_path.to_owned(), + })?; + Ok(svg_file + .contents_utf8() + .expect("Failed to parse file contents") + .to_owned()) +} + +#[cfg(not(feature = "include-svgs"))] +fn load_svg_string(svg_path: &str) -> Result { + let mut f = fs::File::open(&svg_path).map_err(|_| DrawerError::SVGNotFound { + svg: svg_path.to_owned(), + })?; + let mut svg_str = String::new(); + f.read_to_string(&mut svg_str) + .map_err(|source| DrawerError::LoadFile { source })?; + + Ok(svg_str) +} + +#[cfg(feature = "include-fonts")] +static FONTS_DIR: Dir = include_dir!("fonts/"); + +#[cfg(feature = "include-fonts")] +fn font_data(font: &str) -> Result, DrawerError> { + let font_file = FONTS_DIR.get_file(font).ok_or(DrawerError::FontNotFound { + font: font.to_owned(), + })?; + Ok(font_file.contents.to_vec()) +} + +#[cfg(not(feature = "include-fonts"))] +fn font_data(font: &str) -> Result, DrawerError> { + let mut f = fs::File::open(font).map_err(|_| DrawerError::FontNotFound { + font: font.to_owned(), + })?; + let mut buffer: Vec = Vec::new(); + f.read_to_end(&mut buffer) + .map_err(|source| DrawerError::LoadFile { source: source })?; + + Ok(buffer) +} + +/// SVG font-weight attribute options +pub enum FontWeight { + Normal, + Bold, + Bolder, + Lighter, + Number(f32), +} + +impl fmt::Display for FontWeight { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + FontWeight::Normal => write!(f, "normal"), + FontWeight::Bold => write!(f, "bold"), + FontWeight::Bolder => write!(f, "bolder"), + FontWeight::Lighter => write!(f, "lighter"), + FontWeight::Number(n) => write!(f, "{}", n), + } + } +} + +/// SVG font-size attribute options +pub enum FontSize { + XXSmall, + XSmall, + Small, + Medium, + Large, + XLarge, + XXLarge, + XXXLarge, + Unit(f32, String), +} + +impl fmt::Display for FontSize { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FontSize::XXSmall => write!(f, "xx-small"), + FontSize::XSmall => write!(f, "x-small"), + FontSize::Small => write!(f, "small"), + FontSize::Medium => write!(f, "medium"), + FontSize::Large => write!(f, "large"), + FontSize::XLarge => write!(f, "x-large"), + FontSize::XXLarge => write!(f, "xx-large"), + FontSize::XXXLarge => write!(f, "xxx-large"), + FontSize::Unit(n, s) => write!(f, "{}{}", n, s), + } + } +} + +/// A collection of SVG trees +pub struct SVGForest { + pieces_path: Option, + terminations_path: Option, + svg_options: Options, +} + +impl SVGForest { + pub fn new( + font_path: &str, + pieces_path: Option<&str>, + terminations_path: Option<&str>, + ) -> Result { + let mut opt = Options::default(); + + // Load font for coordinates + let mut fonts = fontdb::Database::new(); + let font_data = font_data(font_path)?; + fonts.load_font_data(font_data); + opt.keep_named_groups = true; + opt.fontdb = fonts; + opt.font_size = 16.0; + // There should only be 1 font in DB + opt.font_family = (*(opt.fontdb.faces())[0].family).to_owned(); + + Ok(SVGForest { + pieces_path: pieces_path.map_or(None, |s| Some(s.to_owned())), + terminations_path: terminations_path.map_or(None, |s| Some(s.to_owned())), + svg_options: opt, + }) + } + + pub fn piece_tree( + &self, + role: &Role, + color: &shakmaty::Color, + additional: Option<&str>, + ) -> Result { + let pieces_path = self.pieces_path.as_ref().expect("pieces_path not defined"); + let full_piece_path = match additional { + Some(s) => format!("{}/{}_{}_{}.svg", pieces_path, color.char(), role.char(), s), + None => format!("{}/{}_{}.svg", pieces_path, color.char(), role.char()), + }; + let svg_string = load_svg_string(&full_piece_path).unwrap_or_else( + // Fallback to regular piece if additional not found + |_| { + load_svg_string(&format!( + "{}/{}_{}.svg", + pieces_path, + color.char(), + role.char() + )) + .unwrap() + }, + ); + Tree::from_str(&svg_string, &self.svg_options) + .map_err(|source| DrawerError::LoadPieceSVG { source }) + } + + pub fn termination_tree( + &self, + termination: F, + color: Option, + ) -> Result + where + F: fmt::Display, + { + let terminations_path = self + .terminations_path + .as_ref() + .expect("terminations_path not defined"); + let full_termination_path = match color { + Some(c) => format!("{}/{}_{}.svg", terminations_path, termination, c.char()), + None => format!("{}/{}.svg", terminations_path, termination), + }; + + let svg_string = load_svg_string(&full_termination_path).unwrap_or_else( + // Fallback to regular termination if color not found + |_| load_svg_string(&format!("{}/{}.svg", terminations_path, termination)).unwrap(), + ); + Tree::from_str(&svg_string, &self.svg_options) + .map_err(|source| DrawerError::LoadPieceSVG { source }) + } + + pub fn str_svg_tree( + &self, + s: &str, + color: Rgba, + background: Rgba, + height: u32, + width: u32, + x: u32, + y: u32, + font_weight: FontWeight, + font_size: FontSize, + ) -> Result { + let svg_string = format!( + " {}", + height, + width, + background[0], + background[1], + background[2], + x, + y, + color[0], + color[1], + color[2], + font_weight.to_string(), + font_size.to_string(), + s, + ); + + Tree::from_str(&svg_string, &self.svg_options).map_err(|source| { + DrawerError::SVGTreeFromStrError { + source, + s: s.to_owned(), + } + }) + } +} diff --git a/src/drawer/termination.rs b/src/drawer/termination.rs new file mode 100644 index 0000000..40da431 --- /dev/null +++ b/src/drawer/termination.rs @@ -0,0 +1,212 @@ +use std::fmt; + +use image::{imageops, ImageBuffer, RgbaImage}; +use pgn_reader::Outcome; +use shakmaty; +use tiny_skia::{self, Pixmap}; +use usvg::FitTo; + +use super::error::DrawerError; +use super::svgs::SVGForest; +use super::utils::PieceInBoard; + +/// All possible endings for a chess game +#[derive(Debug)] +pub enum TerminationReason { + Checkmate { winner: shakmaty::Color }, + Stalemate, + DrawAgreement, + DrawByRepetition, + Timeout { winner: shakmaty::Color }, + Resignation { winner: shakmaty::Color }, + InsufficientMaterial, + DrawByTimeoutVsInsufficientMaterial, +} + +impl TerminationReason { + /// Create a TerminationReason from a pgn_reader Outcome. Requires a reason to + /// decide between similar outcomes. + pub fn from_outcome(outcome: Outcome, reason: Option<&str>) -> Self { + match outcome { + Outcome::Decisive { winner: w } => { + let winner = shakmaty::Color::from_char(w.char()).unwrap(); + match reason { + None | Some("checkmate") => TerminationReason::Checkmate { winner }, + Some("timeout") => TerminationReason::Timeout { winner }, + Some("resignation") => TerminationReason::Resignation { winner }, + Some(&_) => panic!("Unknown termination reason"), + } + } + Outcome::Draw => match reason { + Some("insufficient material") => TerminationReason::InsufficientMaterial, + Some("timeout") => TerminationReason::DrawByTimeoutVsInsufficientMaterial, + Some("stalemate") => TerminationReason::Stalemate, + Some("repetition") => TerminationReason::DrawByRepetition, + Some("agreement") | None => TerminationReason::DrawAgreement, + Some(&_) => panic!("Unknown termination reason"), + }, + } + } + + pub fn is_draw(&self) -> bool { + match self { + TerminationReason::Stalemate + | TerminationReason::DrawAgreement + | TerminationReason::DrawByRepetition + | TerminationReason::DrawByTimeoutVsInsufficientMaterial + | TerminationReason::InsufficientMaterial => true, + _ => false, + } + } +} + +impl fmt::Display for TerminationReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TerminationReason::Checkmate { winner: _ } => write!(f, "checkmate"), + // Each draw variation should have it's own circle eventually + TerminationReason::Stalemate + | TerminationReason::DrawAgreement + | TerminationReason::DrawByRepetition + | TerminationReason::DrawByTimeoutVsInsufficientMaterial + | TerminationReason::InsufficientMaterial => write!(f, "draw"), + TerminationReason::Resignation { winner: _ } => write!(f, "resignation"), + TerminationReason::Timeout { winner: _ } => write!(f, "timeout"), + } + } +} + +/// Draws highlights to indicate the game's termination reason +pub struct TerminationDrawer { + width: u32, + height: u32, + svgs: SVGForest, +} + +impl TerminationDrawer { + pub fn new( + width: u32, + height: u32, + termination_path: &str, + font_path: &str, + ) -> Result { + let svgs = SVGForest::new(font_path, None, Some(termination_path))?; + + Ok(TerminationDrawer { + width, + height, + svgs, + }) + } + + pub fn termination_circle_pixmap( + &self, + color: shakmaty::Color, + reason: &TerminationReason, + ) -> Result { + let mut pixmap = Pixmap::new(self.width, self.height).unwrap(); + + let rtree = self.svgs.termination_tree(reason, Some(color))?; + + let fit_to = FitTo::Height(self.height); + resvg::render(&rtree, fit_to, pixmap.as_mut()).ok_or(DrawerError::SVGRenderError { + svg: format!("{}", reason), + })?; + + Ok(pixmap) + } + + pub fn win_circle_pixmap(&self) -> Result { + let mut pixmap = Pixmap::new(self.width, self.height).unwrap(); + + let rtree = self.svgs.termination_tree("win", None)?; + + let fit_to = FitTo::Height(self.height); + resvg::render(&rtree, fit_to, pixmap.as_mut()).ok_or(DrawerError::SVGRenderError { + svg: "win".to_string(), + })?; + + Ok(pixmap) + } + + pub fn termination_circle_image( + &self, + color: shakmaty::Color, + reason: &TerminationReason, + ) -> Result { + let pixmap = self.termination_circle_pixmap(color, reason)?; + + ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).ok_or( + DrawerError::ImageTooBig { + image: format!("{}_{:?}.svg", reason, color), + }, + ) + } + + pub fn win_circle_image(&self) -> Result { + let pixmap = self.win_circle_pixmap()?; + + ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).ok_or( + DrawerError::ImageTooBig { + image: "win.svg".to_string(), + }, + ) + } + + pub fn draw_termination_circles( + &mut self, + reason: TerminationReason, + winner: PieceInBoard, + loser: PieceInBoard, + img: &mut RgbaImage, + ) -> Result<(), DrawerError> { + let (circle_winner, circle_loser) = if reason.is_draw() { + let c1 = self.termination_circle_image(loser.color, &reason)?; + let c2 = self.termination_circle_image(winner.color, &reason)?; + (c1, c2) + } else { + let c1 = self.win_circle_image()?; + let c2 = self.termination_circle_image(loser.color, &reason)?; + (c1, c2) + }; + + let height = img.height(); + let width = img.width(); + + let winner_x = (width / 8) * u32::from(winner.square.file()); + let winner_y = height - (width / 8) * (u32::from(winner.square.rank()) + 2); + + let loser_x = (width / 8) * u32::from(loser.square.file()); + let loser_y = height - (width / 8) * (u32::from(loser.square.rank()) + 2); + + imageops::overlay(img, &circle_winner, winner_x, winner_y); + imageops::overlay(img, &circle_loser, loser_x, loser_y); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_circle_pixmap_draw() { + let drawer = TerminationDrawer::new(16, 16, "terminations", "roboto.ttf").unwrap(); + let circle = drawer + .termination_circle_pixmap(shakmaty::Color::Black, &TerminationReason::DrawAgreement) + .unwrap(); + + assert_eq!(circle.width(), 16); + assert_eq!(circle.height(), 16); + } + + #[test] + fn test_circle_pixmap_win() { + let drawer = TerminationDrawer::new(16, 16, "terminations", "roboto.ttf").unwrap(); + let circle = drawer.win_circle_pixmap().unwrap(); + + assert_eq!(circle.width(), 16); + assert_eq!(circle.height(), 16); + } +} diff --git a/src/utils.rs b/src/drawer/utils.rs similarity index 58% rename from src/utils.rs rename to src/drawer/utils.rs index 006a824..0f87b64 100644 --- a/src/utils.rs +++ b/src/drawer/utils.rs @@ -1,5 +1,25 @@ -use shakmaty::{File, Rank, Square}; +use shakmaty::{self, File, Rank, Role, Square}; +/// A piece in a chess board +#[derive(Debug)] +pub struct PieceInBoard { + pub square: Square, + pub role: Role, + pub color: shakmaty::Color, +} + +impl PieceInBoard { + pub fn new_king(square: shakmaty::Square, color: shakmaty::Color) -> Self { + PieceInBoard { + square, + color, + role: Role::King, + } + } +} + +/// Check if a square contains a coordinate. Coordindates are found in the A file +/// and first rank pub fn has_coordinate(s: &Square, flip: bool) -> bool { if (s.rank() == Rank::First || s.file() == File::A) && flip == false { true @@ -14,6 +34,12 @@ pub fn has_coordinate(s: &Square, flip: bool) -> bool { mod tests { use super::*; + #[test] + fn test_piece_in_board_new_king() { + let piece = PieceInBoard::new_king(Square::new(0), shakmaty::Color::Black); + assert_eq!(piece.role, Role::King); + } + #[test] fn test_has_coordinate() { let square = Square::new(0); // A1 diff --git a/src/lib.rs b/src/lib.rs index fbb5ece..e451af0 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,4 +6,3 @@ pub mod cli; pub mod drawer; pub mod error; pub mod giffer; -pub mod utils;