diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a9c9f82..052f911 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,30 +2,9 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/rust { "name": "Cowsay Bot", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye" - - // Use 'mounts' to make the cargo cache persistent in a Docker Volume. - // "mounts": [ - // { - // "source": "devcontainer-cargo-cache-${devcontainerId}", - // "target": "/usr/local/cargo", - // "type": "volume" - // } - // ] - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "rustc --version", - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" + "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/dhoeric/features/act:1": {} + } } diff --git a/.github/workflows/standards.yml b/.github/workflows/standards.yml index 3fb0ab5..27b6c58 100644 --- a/.github/workflows/standards.yml +++ b/.github/workflows/standards.yml @@ -17,4 +17,4 @@ jobs: - name: Build run: cargo build --verbose - name: Test - run: cargo test + run: bash scripts/test.sh diff --git a/Cargo.lock b/Cargo.lock index 915254a..44d7031 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,14 +313,24 @@ dependencies = [ "custom_derive", ] +[[package]] +name = "cowparse" +version = "0.1.0" +dependencies = [ + "ansi_colours", + "image", + "imageproc", + "rusttype", +] + [[package]] name = "cowsay-bot" version = "0.1.0" dependencies = [ "ansi_colours", "charasay", + "cowparse", "image", - "imageproc", "rand 0.8.5", "rusttype", "serenity", @@ -746,9 +756,9 @@ dependencies = [ [[package]] name = "image" -version = "0.24.6" +version = "0.24.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527909aa81e20ac3a44803521443a765550f09b5130c2c2fa1ea59c2f8f50a3a" +checksum = "6f3dfdbdd72063086ff443e297b61695500514b1e41095b6fb9a5ab48a70a711" dependencies = [ "bytemuck", "byteorder", @@ -994,9 +1004,9 @@ dependencies = [ [[package]] name = "num-complex" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d" +checksum = "1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214" dependencies = [ "num-traits", ] diff --git a/Cargo.toml b/Cargo.toml index 72d2354..1fcab3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,15 @@ edition = "2021" [dependencies] ansi_colours = "1.2.2" charasay = "3.0.1" -imageproc = "0.23.0" rand = "0.8.5" rusttype = "0.9.3" +[dependencies.cowparse] +path = "./cowparse" +features = ["images"] + [dependencies.image] -version = "0.24.6" +version = "0.24.7" default-features = false features = ["webp", "webp-encoder"] diff --git a/cowparse/.gitignore b/cowparse/.gitignore new file mode 100644 index 0000000..7ade407 --- /dev/null +++ b/cowparse/.gitignore @@ -0,0 +1,3 @@ +/target +/Cargo.lock +output.png diff --git a/cowparse/Cargo.toml b/cowparse/Cargo.toml new file mode 100644 index 0000000..c5157bb --- /dev/null +++ b/cowparse/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cowparse" +version = "0.1.0" +edition = "2021" +license = "MIT" +homepage = "https://cowsay.app" +repository = "https://github.com/dylhack/cowparse" +description = "A library for parsing cowsay files." + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +ansi_colours = "1.2.2" +imageproc = { version = "0.23.0", optional = true } +rusttype = { version = "0.9.3", optional = true } + +[dependencies.image] +version = "0.24.7" +optional = true +default-features = false + +[dev-dependencies] +charasay = "3.0.1" + +[dev-dependencies.image] +version = "0.24.7" +default-features = false +features = ["png"] + +[features] +default = [] +images = ["image", "imageproc", "rusttype"] diff --git a/cowparse/LICENSE b/cowparse/LICENSE new file mode 100644 index 0000000..71b6581 --- /dev/null +++ b/cowparse/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Dylan Hackworth + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cowparse/README.md b/cowparse/README.md new file mode 100644 index 0000000..cf6e03f --- /dev/null +++ b/cowparse/README.md @@ -0,0 +1 @@ +At a high level this is an ANSI X3.64 SGR parser. diff --git a/cowparse/src/ansi.rs b/cowparse/src/ansi.rs new file mode 100644 index 0000000..03020fc --- /dev/null +++ b/cowparse/src/ansi.rs @@ -0,0 +1,12 @@ +mod parse; +/// A collection of constants from X3.64, ISO 6429, ECMA-48, and T.416. +/// +/// - [X3.64](https://nvlpubs.nist.gov/nistpubs/Legacy/FIPS/fipspub86.pdf) +/// - [ISO 6429](https://www.iso.org/standard/12782.html) +/// - [ECMA-48](https://www.ecma-international.org/wp-content/uploads/ECMA-48_5th_edition_june_1991.pdf) +/// - [T.416](https://www.itu.int/rec/dologin_pub.asp?lang=e&id=T-REC-T.416-199303-I!!PDF-E&type=items) +pub mod sgr; +pub mod types; +mod util; +pub use self::types::{ANSIChar, ANSIString}; +pub use parse::parse; diff --git a/cowparse/src/ansi/parse.rs b/cowparse/src/ansi/parse.rs new file mode 100644 index 0000000..170c26d --- /dev/null +++ b/cowparse/src/ansi/parse.rs @@ -0,0 +1,324 @@ +use std::sync::Arc; + +use super::{ + sgr, + types::{ANSIChar, ANSIString, ControlFunction, ControlSequence}, +}; + +const ESCAPE_START: u8 = 0x1B; +const INTRODUCER_START: u8 = 0x5B; +const PARAM_RULE: [u8; 2] = [0x30, 0x3F]; + +pub fn parse_control(ctrl: &str, last_control: &ControlSequence) -> ControlSequence { + let mut result = last_control.clone(); + let mut clean = vec![]; + let mut temp = String::new(); + + ctrl.chars().for_each(|c| { + let char_code = c as u8; + if char_code >= PARAM_RULE[0] || char_code <= PARAM_RULE[1] { + if c.is_digit(10) { + temp.push(c); + } else if char_code == sgr::SEPERATOR || char_code == sgr::FINAL_BYTE { + let param = temp.parse::().unwrap_or(0); + clean.push(param); + temp.clear(); + } + } + }); + + if clean.len() == 0 { + // X3.64: If no parameters are given, then the default value is NORMAL. + clean.push(sgr::NORMAL); + } + + let mut color_escape = false; + let mut rgb_escape = false; + let mut ansi_escape = false; + let mut temp: [u8; 5] = [0; 5]; + let mut i = 0; + macro_rules! finish { + () => { + result.push(ControlFunction { + escape: temp[0], + params: [temp[1], temp[2], temp[3], temp[4]], + }); + temp = [0; 5]; + i = 0; + }; + } + + clean.iter().for_each(|byte| { + if color_escape { + if byte == &sgr::T416_RGB { + rgb_escape = true; + } else if byte == &sgr::T416_256 { + ansi_escape = true; + } + temp[i] = *byte; + color_escape = false; + i += 1; + } else if ansi_escape { + temp[i] = *byte; + ansi_escape = false; + finish!(); + } else if rgb_escape { + temp[i] = *byte; + if i == 4 { + rgb_escape = false; + finish!(); + } else { + i += 1; + } + } else { + match byte { + &sgr::FOREGROUND | &sgr::BACKGROUND => { + color_escape = true; + temp[i] = *byte; + i += 1; + } + &sgr::NORMAL => result.clear(), + other => { + temp[0] = *other; + finish!(); + } + } + } + }); + + result +} + +/// Parse a string with ANSI X3.64 escape sequences. +/// +/// # Examples +/// ``` +/// use cowparse::parse; +/// +/// let result = parse("\x1B[38;2;197;108;59mHello World!\x1B[m\n"); +/// println!("{:?}", result); +/// ``` +pub fn parse(text: &str) -> ANSIString { + let mut result = Vec::new(); + let mut control = Arc::new(vec![]); + let mut escaped = false; + let mut escaped_control = false; + let mut control_tmp = String::new(); + + text.chars().for_each(|c| { + let char_code = c as u8; + if escaped { + if char_code == INTRODUCER_START { + escaped_control = true; + escaped = false; + } else { + escaped = false; + } + } else if escaped_control { + if char_code == sgr::FINAL_BYTE { + control_tmp.push(c); + control = Arc::new(parse_control(&control_tmp, &control.clone())); + control_tmp.clear(); + escaped_control = false; + } else { + control_tmp.push(c); + } + } else if char_code == ESCAPE_START { + escaped = true; + } else { + result.push(ANSIChar { + control: Arc::clone(&control), + char: c, + }); + } + }); + + result +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use crate::ansi::types::ControlFunction; + + use super::*; + + macro_rules! null_params { + () => { + [0, 0, 0, 0] + }; + } + + #[test] + fn test_parse_control() { + let answer = vec![sgr::BOLD, sgr::RED_FG, sgr::GREEN_BG]; + let result = parse_control("\x1B[1;31;42m", &vec![]); + + for (i, answer_byte) in answer.iter().enumerate() { + assert_eq!(answer_byte, &result[i].escape); + assert_eq!([0, 0, 0, 0], result[i].params); + } + } + + #[test] + fn test_parse_line() { + let result = parse_control("\x1B[38;2;197;108;59;1;3m", &vec![]); + let answer = vec![ + ControlFunction { + escape: 38, + params: [2, 197, 108, 59], + }, + ControlFunction { + escape: 1, + params: null_params!(), + }, + ControlFunction { + escape: 3, + params: null_params!(), + }, + ]; + + for (i, answer_byte) in answer.iter().enumerate() { + assert_eq!(answer_byte.escape, result[i].escape); + assert_eq!(answer_byte.params, result[i].params); + } + } + + #[test] + fn test_parse() { + let result = parse("\x1B[38;2;197;108;59mHello\x1B[m\nWorld!"); + let color = Arc::new(vec![ControlFunction { + escape: 38, + params: [2, 197, 108, 59], + }]); + let null_ctrl = Arc::new(vec![]); + let answer = vec![ + ANSIChar { + control: Arc::clone(&color), + char: 'H', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'e', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'l', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'l', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'o', + }, + ANSIChar { + control: Arc::clone(&null_ctrl), + char: '\n', + }, + ANSIChar { + control: Arc::clone(&null_ctrl), + char: 'W', + }, + ANSIChar { + control: Arc::clone(&null_ctrl), + char: 'o', + }, + ANSIChar { + control: Arc::clone(&null_ctrl), + char: 'r', + }, + ANSIChar { + control: Arc::clone(&null_ctrl), + char: 'l', + }, + ANSIChar { + control: Arc::clone(&null_ctrl), + char: 'd', + }, + ANSIChar { + control: Arc::clone(&null_ctrl), + char: '!', + }, + ]; + + for (i, char) in answer.iter().enumerate() { + assert_eq!(char, &result[i]); + } + } + + #[test] + fn test_parse_2() { + let result = parse("\x1B[38;2;197;108;59mHello\x1B[1m World!"); + let color = Arc::new(vec![ControlFunction { + escape: 38, + params: [2, 197, 108, 59], + }]); + let color_2 = Arc::new(vec![ + ControlFunction { + escape: 38, + params: [2, 197, 108, 59], + }, + ControlFunction { + escape: 1, + params: null_params!(), + }, + ]); + let answer = vec![ + ANSIChar { + control: Arc::clone(&color), + char: 'H', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'e', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'l', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'l', + }, + ANSIChar { + control: Arc::clone(&color), + char: 'o', + }, + ANSIChar { + control: Arc::clone(&color_2), + char: ' ', + }, + ANSIChar { + control: Arc::clone(&color_2), + char: 'W', + }, + ANSIChar { + control: Arc::clone(&color_2), + char: 'o', + }, + ANSIChar { + control: Arc::clone(&color_2), + char: 'r', + }, + ANSIChar { + control: Arc::clone(&color_2), + char: 'l', + }, + ANSIChar { + control: Arc::clone(&color_2), + char: 'd', + }, + ANSIChar { + control: Arc::clone(&color_2), + char: '!', + }, + ]; + + for (i, char) in answer.iter().enumerate() { + assert_eq!(char, &result[i]); + } + } +} diff --git a/cowparse/src/ansi/sgr.rs b/cowparse/src/ansi/sgr.rs new file mode 100644 index 0000000..bb9213f --- /dev/null +++ b/cowparse/src/ansi/sgr.rs @@ -0,0 +1,59 @@ +pub const SEPERATOR: u8 = 0x3B; +pub const FINAL_BYTE: u8 = 0x6D; + +// ANSI X3.64 - SGR +pub const NORMAL: u8 = 0; +pub const BOLD: u8 = 1; +pub const FAINT: u8 = 2; +pub const ITALIC: u8 = 3; +pub const UNDERSCORE: u8 = 4; +pub const SLOW_BLINK: u8 = 5; +pub const RAPID_BLINK: u8 = 6; +pub const REVERSE: u8 = 7; +// ISO 6429 +pub const CONCEAL: u8 = 8; +// ANSI X3.64 - FNT +pub const PRIMARY_FONT: u8 = 10; +pub const ALTERNATE_FONT_1: u8 = 11; +pub const ALTERNATE_FONT_2: u8 = 12; +pub const ALTERNATE_FONT_3: u8 = 13; +pub const ALTERNATE_FONT_4: u8 = 14; +pub const ALTERNATE_FONT_5: u8 = 15; +pub const ALTERNATE_FONT_6: u8 = 16; +pub const ALTERNATE_FONT_7: u8 = 17; +pub const ALTERNATE_FONT_8: u8 = 18; +pub const ALTERNATE_FONT_9: u8 = 19; +// ISO 6429 +pub const BLACK_FG: u8 = 30; +pub const RED_FG: u8 = 31; +pub const GREEN_FG: u8 = 32; +pub const YELLOW_FG: u8 = 33; +pub const BLUE_FG: u8 = 34; +pub const MAGENTA_FG: u8 = 35; +pub const CYAN_FG: u8 = 36; +pub const WHITE_FG: u8 = 37; +// T.416 - SGR +pub const FOREGROUND: u8 = 38; +// ISO 6429 +pub const BLACK_BG: u8 = 40; +pub const RED_BG: u8 = 41; +pub const GREEN_BG: u8 = 42; +pub const YELLOW_BG: u8 = 43; +pub const BLUE_BG: u8 = 44; +pub const MAGENTA_BG: u8 = 45; +pub const CYAN_BG: u8 = 46; +pub const WHITE_BG: u8 = 47; +// T.416 - SGR +pub const BACKGROUND: u8 = 48; +// ECMA-48 - SGR +// NOTE(dylhack): Not supporting 56-64. +pub const DEFAULT_BG: u8 = 49; +pub const FRAMED: u8 = 51; +pub const ENCIRCLED: u8 = 52; +pub const OVERLINED: u8 = 53; +pub const NOT_FRAMED_OR_ENCIRCLED: u8 = 54; +pub const NOT_OVERLINED: u8 = 55; + +// T.416 - Color +pub const T416_RGB: u8 = 2; +pub const T416_256: u8 = 5; diff --git a/cowparse/src/ansi/types.rs b/cowparse/src/ansi/types.rs new file mode 100644 index 0000000..f064cad --- /dev/null +++ b/cowparse/src/ansi/types.rs @@ -0,0 +1,93 @@ +use std::{ + fmt::{Debug, Display, Formatter}, + sync::Arc, +}; +pub struct ControlFunction { + pub escape: u8, + pub params: [u8; 4], +} + +pub struct ANSIChar { + pub control: Arc, + pub char: char, +} + +pub type ControlSequence = Vec; +pub type ANSIString = Vec; + +impl PartialEq for ANSIChar { + fn eq(&self, other: &Self) -> bool { + if self.char != other.char { + return false; + } + + let mut result = true; + for (i, param) in self.control.iter().enumerate() { + if param.escape != other.control[i].escape { + result = false; + break; + } + for (j, byte) in param.params.iter().enumerate() { + if byte != &other.control[i].params[j] { + result = false; + break; + } + } + } + + result + } +} + +impl Clone for ControlFunction { + fn clone(&self) -> Self { + ControlFunction { + escape: self.escape.clone(), + params: self.params.clone(), + } + } +} + +impl Debug for ControlFunction { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ControlFunction") + .field("escape", &self.escape) + .field("params", &self.params) + .finish() + } +} + +impl Debug for ANSIChar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ANSIChar") + .field("control", &self.control) + .field("char", &self.char) + .finish() + } +} + +impl Display for ANSIChar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut result = String::from("\x1B["); + self.control.iter().for_each(|ctrl| { + result.push_str(&format!("{}", ctrl)); + }); + result.push('m'); + result.push(self.char); + write!(f, "{}", result) + } +} + +impl Display for ControlFunction { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut result = format!("{};", self.escape); + let max = result.len() - 1; + self.params.iter().enumerate().for_each(|(i, param)| { + result.push_str(&format!("{}", param)); + if i != max { + result.push(';'); + } + }); + write!(f, "{}", result) + } +} diff --git a/cowparse/src/ansi/util.rs b/cowparse/src/ansi/util.rs new file mode 100644 index 0000000..b9b8986 --- /dev/null +++ b/cowparse/src/ansi/util.rs @@ -0,0 +1,207 @@ +use super::{sgr, types::ANSIChar}; +use ansi_colours::rgb_from_ansi256; + +type Color = (u8, u8, u8); + +const BLACK: Color = (0, 0, 0); +const RED: Color = (255, 0, 0); +const GREEN: Color = (0, 255, 0); +const YELLOW: Color = (255, 255, 0); +const CYAN: Color = (0, 255, 255); +const BLUE: Color = (0, 0, 255); +const MAGENTA: Color = (255, 0, 255); +const WHITE: Color = (255, 255, 255); + +macro_rules! is_method { + ($name:ident, $control:ident) => { + pub fn $name(&self) -> bool { + self.is(&sgr::$control) + } + }; +} + +impl ANSIChar { + // [38, 2, 255, 255, 255] -> (255, 255, 255) + // [38, 5, 15] -> (255, 255, 255) + fn parse_color(slice: &[u8]) -> Option { + match slice.get(1) { + Some(color_type) => match color_type { + &sgr::T416_RGB => { + let r = slice.get(2)?.clone(); + let g = slice.get(3)?.clone(); + let b = slice.get(4)?.clone(); + Some((r, g, b)) + } + &sgr::T416_256 => { + let color = slice.get(2)?; + Some(rgb_from_ansi256(*color)) + } + _ => None, + }, + None => None, + } + } + + fn get_color(&self, is_fg: bool) -> Option { + let mut result: Option = None; + self.for_each(|escape, args| { + if is_fg { + result = match escape { + &sgr::NORMAL => None, + &sgr::FOREGROUND => { + ANSIChar::parse_color(&[38, args[0], args[1], args[2], args[3]]) + } + &sgr::RED_FG => Some(RED), + &sgr::GREEN_FG => Some(GREEN), + &sgr::YELLOW_FG => Some(YELLOW), + &sgr::BLUE_FG => Some(BLUE), + &sgr::CYAN_FG => Some(CYAN), + &sgr::WHITE_FG => Some(WHITE), + &sgr::BLACK_FG => Some(BLACK), + &sgr::MAGENTA_FG => Some(MAGENTA), + _ => result, + }; + } else { + result = match escape { + &sgr::NORMAL => None, + &sgr::BACKGROUND => { + ANSIChar::parse_color(&[48, args[0], args[1], args[2], args[3]]) + } + &sgr::RED_BG => Some(RED), + &sgr::GREEN_BG => Some(GREEN), + &sgr::YELLOW_BG => Some(YELLOW), + &sgr::BLUE_BG => Some(BLUE), + &sgr::MAGENTA_BG => Some(MAGENTA), + &sgr::CYAN_BG => Some(CYAN), + &sgr::WHITE_BG => Some(WHITE), + &sgr::BLACK_BG => Some(BLACK), + &sgr::DEFAULT_BG => None, + _ => result, + }; + } + }); + + result + } + + pub fn get_fg_color(&self) -> Option { + self.get_color(true) + } + + pub fn get_bg_color(&self) -> Option { + self.get_color(false) + } + + pub fn for_each(&self, mut f: F) + where + F: FnMut(&u8, [u8; 4]), + { + self.control.iter().for_each(|ctrl| { + f(&ctrl.escape, ctrl.params); + }); + } + + // is(&sgr::NORMAL) + pub fn is(&self, control: &u8) -> bool { + let mut result = false; + self.for_each(|escape, _| { + if escape == control { + result = true; + } else if escape == &sgr::NORMAL { + result = false; + } + }); + result + } + + is_method!(is_normal, NORMAL); + is_method!(is_bold, BOLD); + is_method!(is_faint, FAINT); + is_method!(is_italic, ITALIC); + is_method!(is_underline, UNDERSCORE); + is_method!(is_reverse, REVERSE); + is_method!(is_conceal, CONCEAL); +} + +#[cfg(test)] +mod test { + use super::*; + use crate::ansi::parse; + + macro_rules! create_result { + ($data:expr) => { + &parse($data)[0] + }; + } + + #[test] + fn test_is_method() { + let answer = true; + let result = create_result!("\x1B[3;1mHello world"); + assert_eq!(result.is_bold(), answer); + } + + #[test] + fn test_is_method_2() { + let answer = false; + let result = create_result!("\x1B[1;0;3mHello world"); + assert_eq!(result.is_bold(), answer); + } + + // Color + #[test] + pub fn test_color() { + let answer = Some(GREEN); + let result = create_result!("\x1B[38;2;197;108;53;32;3mHello world"); + assert_eq!(result.get_fg_color(), answer); + } + + #[test] + pub fn test_color_2() { + let answer = None; + let result = create_result!("\x1B[42;3mHello world"); + assert_eq!(result.get_fg_color(), answer); + } + + #[test] + pub fn test_color_3() { + let answer = None; + let result = create_result!("\x1B[38;2;197;108;53;32;0mHello world"); + assert_eq!(result.get_fg_color(), answer); + } + + #[test] + pub fn test_color_rgb() { + let answer: Option = Some((197, 108, 53)); + let result = create_result!("\x1B[38;2;197;108;53mHello world"); + assert_eq!(result.get_fg_color(), answer); + } + + #[test] + pub fn test_color_rgb_2() { + let answer: Option = Some((197, 108, 53)); + let result = create_result!("\x1B[31;38;2;197;108;53mHello world"); + assert_eq!(result.get_fg_color(), answer); + } + + #[test] + pub fn test_color_rgb_3() { + let answer: Option = None; + let result = create_result!("\x1B[31;38;2;197;108;53;0mHello world"); + assert_eq!(result.get_fg_color(), answer); + } + + #[test] + pub fn test_color_ansi() { + let answer: Option = Some((215, 175, 0)); + let result = create_result!("\x1B[31;38;5;178;1mHello world"); + assert_eq!(result.get_fg_color(), answer); + } + + #[test] + pub fn test_color_ansi_2() { + let answer: Option = None; + let result = create_result!("\x1B[31;38;5;178;0mHello world"); + assert_eq!(result.get_fg_color(), answer); + } +} diff --git a/cowparse/src/images.rs b/cowparse/src/images.rs new file mode 100644 index 0000000..2956942 --- /dev/null +++ b/cowparse/src/images.rs @@ -0,0 +1 @@ +pub mod encoding; diff --git a/cowparse/src/images/encoding.rs b/cowparse/src/images/encoding.rs new file mode 100644 index 0000000..c73a69f --- /dev/null +++ b/cowparse/src/images/encoding.rs @@ -0,0 +1,172 @@ +use crate::{parse, types::ANSIChar}; +use image::{ImageBuffer, Rgba, RgbaImage}; +use imageproc::drawing::draw_text_mut; +use rusttype::{Font, Scale}; + +const VISIBLE: u8 = 255; +const BLOCK_CHAR: char = '█'; +const ORIGINAL_CHAR: char = ' '; +const WHITE: Rgba = Rgba([255, 255, 255, VISIBLE]); + +type Color = Rgba; +type Image = ImageBuffer>; + +// (w, h) +fn get_size(font_height: &u32, data: &Vec>) -> (u32, u32) { + let h = data.len() as u32; + let mut longest_line: u32 = 0; + for line in data { + let len = line.len() as u32; + if len > longest_line { + longest_line = len; + } + } + let height = (h * font_height) as u32; + let width = (longest_line * (font_height / 2) - 1) as u32; + + return (width, height); +} + +fn get_color(color_opt: &Option<(u8, u8, u8)>) -> Color { + match color_opt { + Some(color) => Rgba([color.0, color.1, color.2, VISIBLE]), + None => WHITE, + } +} + +/// ImageBuilder is a builder for creating an image from a cowsay string. +/// +/// - It's recommended to use a bold font for the bubble font. +/// +/// # Example +/// ``` +/// use charasay::{bubbles::BubbleType, Chara::Builtin, format_character}; +/// use cowparse::ImageBuilder; +/// +/// // Set to your own font. +/// let font = include_bytes!("../../test/fonts/JetBrainsMonoNerdFont-Regular.ttf").to_vec(); +/// let bold_font = include_bytes!("../../test/fonts/JetBrainsMonoNerdFont-Bold.ttf").to_vec(); +/// let cowsay = format_character("Hello world!", &Builtin(String::from("cow")), 80, BubbleType::Round).unwrap(); +/// let image = ImageBuilder::from(&cowsay) +/// .set_font(font) +/// .set_bubble_font(bold_font) +/// .build() +/// .unwrap(); +/// +/// // Make sure to install image with png feature flag enabled. +/// image.save("./output.png").expect("Failed to save image."); +/// ``` +pub struct ImageBuilder { + // required + data: Vec>, + font: Option>, + // optional + bubble_font: Option>, + font_size: i32, +} + +impl ImageBuilder { + pub fn from(cowsay: &str) -> ImageBuilder { + let mut data = vec![]; + let mut line = vec![]; + for char in parse(cowsay) { + if char.char == '\n' { + data.push(line); + line = vec![]; + } else { + line.push(char); + } + } + data.push(line); + + ImageBuilder { + data, + font: None, + bubble_font: None, + font_size: 20, + } + } + + pub fn set_font(mut self, font: Vec) -> ImageBuilder { + self.font = Some(font); + self + } + + pub fn set_bubble_font(mut self, font: Vec) -> ImageBuilder { + self.bubble_font = Some(font); + self + } + + pub fn set_font_size(mut self, font_size: i32) -> ImageBuilder { + self.font_size = font_size; + self + } + + pub fn build(self) -> Result { + let (w, h) = get_size(&(self.font_size as u32), &self.data); + let mut image = RgbaImage::new(w, h); + let font = self.font.ok_or("Font Family not set.")?; + let font = Font::try_from_vec(font).ok_or("Failed to set font data.")?; + let bubble_font = if let Some(bubble_font) = self.bubble_font { + Font::try_from_vec(bubble_font).ok_or("Failed to set bubble font data.")? + } else { + font.clone() + }; + + let scale = Scale { + x: self.font_size as f32, + y: self.font_size as f32, + }; + + let mut y = 0; + for line in self.data { + let mut x = 0; + for block in line { + // NOTE(dylhack): default is white for plain-text characters. (ie the cowsay bubble) + let original_color = block.get_bg_color(); + let color = get_color(&original_color); + let char = if block.char == ORIGINAL_CHAR && original_color.is_some() { + BLOCK_CHAR + } else { + block.char + }; + let font = if char == BLOCK_CHAR { + &font + } else { + &bubble_font + }; + draw_text_mut(&mut image, color, x, y, scale, &font, &String::from(char)); + x += (self.font_size / 2) - 1; + } + y += self.font_size; + } + + Ok(image) + } +} + +#[cfg(test)] +mod test { + use super::ImageBuilder; + use charasay::{bubbles::BubbleType, format_character, Chara::Builtin}; + + #[test] + pub fn test_image() { + let font = include_bytes!("../../test/fonts/JetBrainsMonoNerdFont-Regular.ttf").to_vec(); + let bold_font = include_bytes!("../../test/fonts/JetBrainsMonoNerdFont-Bold.ttf").to_vec(); + let cowsay = format_character( + "Hello world!", + &Builtin(String::from("cow")), + 80, + BubbleType::Round, + ) + .unwrap(); + let image = ImageBuilder::from(&cowsay) + .set_font(font) + .set_bubble_font(bold_font) + .build() + .unwrap(); + + assert!(image.save("./test/test.png").is_ok()); + } +} diff --git a/cowparse/src/lib.rs b/cowparse/src/lib.rs new file mode 100644 index 0000000..f8f8a7e --- /dev/null +++ b/cowparse/src/lib.rs @@ -0,0 +1,11 @@ +/// This parsing module is a loose implementation of X3.64, ECMA-48, and T.416. +/// +/// - [X3.64](https://nvlpubs.nist.gov/nistpubs/Legacy/FIPS/fipspub86.pdf) +/// - [ECMA-48](https://www.ecma-international.org/wp-content/uploads/ECMA-48_5th_edition_june_1991.pdf) +/// - [T.416](https://www.itu.int/rec/dologin_pub.asp?lang=e&id=T-REC-T.416-199303-I!!PDF-E&type=items) +pub mod ansi; +#[cfg(feature = "images")] +pub mod images; +pub use ansi::{parse, types}; +#[cfg(feature = "images")] +pub use images::encoding::ImageBuilder; diff --git a/cowparse/test/.gitignore b/cowparse/test/.gitignore new file mode 100644 index 0000000..00de3d5 --- /dev/null +++ b/cowparse/test/.gitignore @@ -0,0 +1 @@ +test.png diff --git a/cowparse/test/fonts/JetBrainsMonoNerdFont-Bold.ttf b/cowparse/test/fonts/JetBrainsMonoNerdFont-Bold.ttf new file mode 100644 index 0000000..8640f4c Binary files /dev/null and b/cowparse/test/fonts/JetBrainsMonoNerdFont-Bold.ttf differ diff --git a/cowparse/test/fonts/JetBrainsMonoNerdFont-Regular.ttf b/cowparse/test/fonts/JetBrainsMonoNerdFont-Regular.ttf new file mode 100644 index 0000000..521f56e Binary files /dev/null and b/cowparse/test/fonts/JetBrainsMonoNerdFont-Regular.ttf differ diff --git a/cowparse/test/fonts/README.md b/cowparse/test/fonts/README.md new file mode 100644 index 0000000..57e1f17 --- /dev/null +++ b/cowparse/test/fonts/README.md @@ -0,0 +1,100 @@ +Fonts used in cowsay-bot are by [Nerd Fonts](https://www.nerdfonts.com/). + +--- + +# LICENSES + +## Various Fonts, Patched Fonts, SVGs, Glyph Fonts, and any files in a folder with explicit SIL OFL 1.1 License + +Copyright (c) 2014, Ryan L McIntyre (https://ryanlmcintyre.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100644 index 0000000..fce224c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,3 @@ +cargo test +cd ./cowparse +cargo test --all-features diff --git a/src/bot.rs b/src/bot.rs index 06b897b..59ce658 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,6 +1,6 @@ -mod events; mod commands; mod embeds; +mod events; use crate::config::get_bot_token; use events::Handler; use serenity::{client::ClientBuilder, prelude::GatewayIntents}; diff --git a/src/bot/commands.rs b/src/bot/commands.rs index 510e462..9b4af6e 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -1,8 +1,8 @@ mod cowsay; use crate::{ - config::get_dev_server, bot::embeds::create_error_embed, + config::get_dev_server, types::{CommandResult, Response}, }; use serenity::{ diff --git a/src/bot/commands/cowsay.rs b/src/bot/commands/cowsay.rs index ec7ac87..a4d3125 100644 --- a/src/bot/commands/cowsay.rs +++ b/src/bot/commands/cowsay.rs @@ -4,13 +4,13 @@ use serenity::{ builder::CreateApplicationCommand, model::prelude::application_command::ApplicationCommandInteraction, prelude::Context, }; + use crate::{ - cowsay::{cowsay, images::cowsay_to_image}, + cowsay::{cowsay, cowsay_to_image}, tmp::get_path, types::{CommandResult, Response}, }; - async fn respond( ctx: &Context, cmd: &ApplicationCommandInteraction, diff --git a/src/bot/commands/cowsay/fortune.rs b/src/bot/commands/cowsay/fortune.rs index 0044f25..78bd818 100644 --- a/src/bot/commands/cowsay/fortune.rs +++ b/src/bot/commands/cowsay/fortune.rs @@ -1,9 +1,10 @@ use super::respond; -use crate::{fortune::get_fortune, types::CommandResult, cowsay::BUILTIN_CHARA}; +use crate::{cowsay::BUILTIN_CHARA, fortune::get_fortune, types::CommandResult}; use serenity::{ builder::CreateApplicationCommandOption, model::prelude::{ - application_command::{ApplicationCommandInteraction, CommandDataOption}, command::CommandOptionType, + application_command::{ApplicationCommandInteraction, CommandDataOption}, + command::CommandOptionType, }, prelude::Context, }; @@ -26,7 +27,11 @@ pub fn register(grp: &mut CreateApplicationCommandOption) { }); } -pub async fn handle(ctx: &Context, cmd: &ApplicationCommandInteraction, subcmd: &CommandDataOption) -> CommandResult { +pub async fn handle( + ctx: &Context, + cmd: &ApplicationCommandInteraction, + subcmd: &CommandDataOption, +) -> CommandResult { let fortune = get_fortune(); let mut chara = "cow"; if let Some(chara_arg) = subcmd.options.get(0) { diff --git a/src/bot/commands/cowsay/say.rs b/src/bot/commands/cowsay/say.rs index 82eb74c..e9dabf3 100644 --- a/src/bot/commands/cowsay/say.rs +++ b/src/bot/commands/cowsay/say.rs @@ -1,5 +1,5 @@ -use crate::{types::CommandResult, cowsay::BUILTIN_CHARA}; use super::respond; +use crate::{cowsay::BUILTIN_CHARA, types::CommandResult}; use serenity::{ builder::CreateApplicationCommandOption, model::prelude::{ diff --git a/src/cowsay.rs b/src/cowsay.rs index 9e262e0..32fadd7 100644 --- a/src/cowsay.rs +++ b/src/cowsay.rs @@ -1,13 +1,11 @@ -pub mod images; use crate::types::Result; use charasay::{format_character, Chara::Builtin}; +use cowparse::ImageBuilder; +use image::RgbaImage; -// TODO(dylhack): The following characters are not supported because the -// cowsay_bot::cowsay::images::chop function doesn't work -// with colors greater than xterm / ansi 256 -pub const BUILTIN_CHARA: [&str; 20] = [ - // "aya", - // "cirno", +pub const BUILTIN_CHARA: [&str; 23] = [ + "aya", + "cirno", "clefairy", "cow", "eevee", @@ -24,7 +22,7 @@ pub const BUILTIN_CHARA: [&str; 20] = [ "pikachu", "piplup", "psyduck", - // "remilia-scarlet", + "remilia-scarlet", "seaking", "togepi", "tux", @@ -41,3 +39,14 @@ pub fn cowsay(character: &str, msg: &str) -> Result { Ok(result.unwrap()) } } + +pub fn cowsay_to_image(cowsay: &str) -> Result { + let font = include_bytes!("../assets/font/JetBrainsMonoNerdFont-Regular.ttf").to_vec(); + let bold_font = include_bytes!("../assets/font/JetBrainsMonoNerdFont-Bold.ttf").to_vec(); + let image = ImageBuilder::from(cowsay) + .set_font(font) + .set_bubble_font(bold_font) + .build()?; + + Ok(image) +} diff --git a/src/fortune.rs b/src/fortune.rs index 0f14c17..1bf5b66 100644 --- a/src/fortune.rs +++ b/src/fortune.rs @@ -3,10 +3,7 @@ type Fortunes = Vec; macro_rules! fortune { ($file:expr) => { - include_bytes!(concat!( - "../assets/fortune/datfiles/", - $file - )) + include_bytes!(concat!("../assets/fortune/datfiles/", $file)) }; }