From 11a74f215e28d7c3971c9894351567edb68ef0f8 Mon Sep 17 00:00:00 2001 From: Kumpelinus Date: Thu, 1 May 2025 20:26:04 +0200 Subject: [PATCH] Implement a to_html method for FormattedText (#208) * Implement a to_html method for FormattedText Also fix a small issue with ansi formatting where it duplicated text. * cargo fmt * Make format conversion generic * cargo lint and fmt * Fix ascii conversion cleanup * Implement suggested changes * format, improve sanitization, add xss test --------- Co-authored-by: mat --- azalea-chat/src/component.rs | 152 +++++++++++++++++++++++------- azalea-chat/src/lib.rs | 2 +- azalea-chat/src/style.rs | 57 +++++++++++ azalea-chat/src/text_component.rs | 35 ++++++- 4 files changed, 212 insertions(+), 34 deletions(-) diff --git a/azalea-chat/src/component.rs b/azalea-chat/src/component.rs index e96ead43..ed7a0648 100644 --- a/azalea-chat/src/component.rs +++ b/azalea-chat/src/component.rs @@ -67,6 +67,105 @@ impl FormattedText { } } + /// Render all components into a single `String`, using your custom + /// closures to drive styling, text transformation, and final cleanup. + /// + /// # Type params + /// - `F`: `(running, component, default) -> (prefix, suffix)` for + /// per-component styling + /// - `S`: `&str -> String` for text tweaks (escaping, mapping, etc.) + /// - `C`: `&final_running_style -> String` for any trailing cleanup + /// + /// # Args + /// - `style_formatter`: how to open/close each component’s style + /// - `text_formatter`: how to turn raw text into output text + /// - `cleanup_formatter`: emit after all components (e.g. reset codes) + /// - `default_style`: where to reset when a component’s `reset` is true + /// + /// # Example + /// ```rust + /// use azalea_chat::{FormattedText, DEFAULT_STYLE}; + /// use serde::de::Deserialize; + /// + /// let component = FormattedText::deserialize(&serde_json::json!({ + /// "text": "Hello, world!", + /// "color": "red", + /// })).unwrap(); + /// + /// let ansi = component.to_custom_format( + /// |running, new, default| (running.compare_ansi(new, default), String::new()), + /// |text| text.to_string(), + /// |style| { + /// if !style.is_empty() { + /// "\u{1b}[m".to_string() + /// } else { + /// String::new() + /// } + /// }, + /// &DEFAULT_STYLE, + /// ); + /// println!("{}", ansi); + /// ``` + pub fn to_custom_format( + &self, + mut style_formatter: F, + mut text_formatter: S, + mut cleanup_formatter: C, + default_style: &Style, + ) -> String + where + F: FnMut(&Style, &Style, &Style) -> (String, String), + S: FnMut(&str) -> String, + C: FnMut(&Style) -> String, + { + let mut output = String::new(); + let mut running_style = Style::default(); + + for component in self.clone().into_iter() { + let component_text = match &component { + Self::Text(c) => c.text.to_string(), + Self::Translatable(c) => match c.read() { + Ok(c) => c.to_string(), + Err(_) => c.key.to_string(), + }, + }; + + let component_style = &component.get_base().style; + + let formatted_style = style_formatter(&running_style, component_style, default_style); + let formatted_text = text_formatter(&component_text); + + output.push_str(&formatted_style.0); + output.push_str(&formatted_text); + output.push_str(&formatted_style.1); + + // Reset running style if required + if component_style.reset { + running_style = default_style.clone(); + } else { + running_style.apply(component_style); + } + } + + output.push_str(&cleanup_formatter(&running_style)); + + output + } + + /// Convert this component into an + /// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code). + /// + /// This is the same as [`FormattedText::to_ansi`], but you can specify a + /// default [`Style`] to use. + pub fn to_ansi_with_custom_style(&self, default_style: &Style) -> String { + self.to_custom_format( + |running, new, default| (running.compare_ansi(new, default), "".to_owned()), + |text| text.to_string(), + |style| if !style.is_empty() { "\u{1b}[m" } else { "" }.to_string(), + default_style, + ) + } + /// Convert this component into an /// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code), so you /// can print it to your terminal and get styling. @@ -89,41 +188,30 @@ impl FormattedText { /// println!("{}", component.to_ansi()); /// ``` pub fn to_ansi(&self) -> String { - // default the default_style to white if it's not set self.to_ansi_with_custom_style(&DEFAULT_STYLE) } - /// Convert this component into an - /// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code). - /// - /// This is the same as [`FormattedText::to_ansi`], but you can specify a - /// default [`Style`] to use. - pub fn to_ansi_with_custom_style(&self, default_style: &Style) -> String { - // this contains the final string will all the ansi escape codes - let mut built_string = String::new(); - // this style will update as we visit components - let mut running_style = Style::default(); - - for component in self.clone().into_iter() { - let component_text = match &component { - Self::Text(c) => c.text.to_string(), - Self::Translatable(c) => c.to_string(), - }; - - let component_style = &component.get_base().style; - - let ansi_text = running_style.compare_ansi(component_style, default_style); - built_string.push_str(&ansi_text); - built_string.push_str(&component_text); - - running_style.apply(component_style); - } - - if !running_style.is_empty() { - built_string.push_str("\u{1b}[m"); - } - - built_string + pub fn to_html(&self) -> String { + self.to_custom_format( + |running, new, _| { + ( + format!( + "", + running.merged_with(new).get_html_style() + ), + "".to_owned(), + ) + }, + |text| { + text.replace("&", "&") + .replace("<", "<") + // usually unnecessary but good for compatibility + .replace(">", ">") + .replace("\n", "
") + }, + |_| "".to_string(), + &DEFAULT_STYLE, + ) } } diff --git a/azalea-chat/src/lib.rs b/azalea-chat/src/lib.rs index 9995a183..faa54d70 100644 --- a/azalea-chat/src/lib.rs +++ b/azalea-chat/src/lib.rs @@ -8,4 +8,4 @@ pub mod style; pub mod text_component; pub mod translatable_component; -pub use component::FormattedText; +pub use component::{FormattedText, DEFAULT_STYLE}; diff --git a/azalea-chat/src/style.rs b/azalea-chat/src/style.rs index 26fa2633..8b0c503c 100644 --- a/azalea-chat/src/style.rs +++ b/azalea-chat/src/style.rs @@ -559,6 +559,21 @@ impl Style { } } + /// Returns a new style that is a merge of self and other. + /// For any field that `other` does not specify (is None), self’s value is + /// used. + pub fn merged_with(&self, other: &Style) -> Style { + Style { + color: other.color.clone().or(self.color.clone()), + bold: other.bold.or(self.bold), + italic: other.italic.or(self.italic), + underlined: other.underlined.or(self.underlined), + strikethrough: other.strikethrough.or(self.strikethrough), + obfuscated: other.obfuscated.or(self.obfuscated), + reset: other.reset, // if reset is true in the new style, that takes precedence + } + } + /// Apply a ChatFormatting to this style pub fn apply_formatting(&mut self, formatting: &ChatFormatting) { match *formatting { @@ -576,6 +591,48 @@ impl Style { } } } + + pub fn get_html_style(&self) -> String { + let mut style = String::new(); + if let Some(color) = &self.color { + style.push_str(&format!("color: {};", color.format_value())); + } + if let Some(bold) = self.bold { + style.push_str(&format!( + "font-weight: {};", + if bold { "bold" } else { "normal" } + )); + } + if let Some(italic) = self.italic { + style.push_str(&format!( + "font-style: {};", + if italic { "italic" } else { "normal" } + )); + } + if let Some(underlined) = self.underlined { + style.push_str(&format!( + "text-decoration: {};", + if underlined { "underline" } else { "none" } + )); + } + if let Some(strikethrough) = self.strikethrough { + style.push_str(&format!( + "text-decoration: {};", + if strikethrough { + "line-through" + } else { + "none" + } + )); + } + if let Some(obfuscated) = self.obfuscated { + if obfuscated { + style.push_str("filter: blur(2px);"); + } + } + + style + } } #[cfg(feature = "simdnbt")] diff --git a/azalea-chat/src/text_component.rs b/azalea-chat/src/text_component.rs index db1b4edf..7332adfa 100644 --- a/azalea-chat/src/text_component.rs +++ b/azalea-chat/src/text_component.rs @@ -146,7 +146,7 @@ mod tests { use crate::style::Ansi; #[test] - fn test_hypixel_motd() { + fn test_hypixel_motd_ansi() { let component = TextComponent::new("§aHypixel Network §c[1.8-1.18]\n§b§lHAPPY HOLIDAYS".to_string()) .get(); @@ -163,6 +163,39 @@ mod tests { ); } + #[test] + fn test_hypixel_motd_html() { + let component = + TextComponent::new("§aHypixel Network §c[1.8-1.18]\n§b§lHAPPY HOLIDAYS".to_string()) + .get(); + + assert_eq!( + component.to_html(), + format!( + "{GREEN}Hypixel Network {END_SPAN}{RED}[1.8-1.18]
{END_SPAN}{BOLD_AQUA}HAPPY HOLIDAYS{END_SPAN}", + END_SPAN = "", + GREEN = "", + RED = "", + BOLD_AQUA = "", + ) + ); + } + + #[test] + fn test_xss_html() { + let component = TextComponent::new("§a&\n§b".to_string()).get(); + + assert_eq!( + component.to_html(), + format!( + "{GREEN}<b>&
{END_SPAN}{AQUA}</b>{END_SPAN}", + END_SPAN = "
", + GREEN = "", + AQUA = "", + ) + ); + } + #[test] fn test_legacy_color_code_to_component() { let component = TextComponent::new("§lHello §r§1w§2o§3r§4l§5d".to_string()).get();