diff options
| author | Kumpelinus <kumpelinus@jat.de> | 2025-05-01 20:26:04 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-01 13:26:04 -0500 |
| commit | 11a74f215e28d7c3971c9894351567edb68ef0f8 (patch) | |
| tree | 54f6be9b4152d71d0bf5016cc804854ccc6bcfb1 | |
| parent | 4a7d21425ca644de776bd380abbfabc816710f43 (diff) | |
| download | azalea-drasl-11a74f215e28d7c3971c9894351567edb68ef0f8.tar.xz | |
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 <git@matdoes.dev>
| -rw-r--r-- | azalea-chat/src/component.rs | 152 | ||||
| -rw-r--r-- | azalea-chat/src/lib.rs | 2 | ||||
| -rw-r--r-- | azalea-chat/src/style.rs | 57 | ||||
| -rw-r--r-- | 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,18 +67,24 @@ impl FormattedText { } } - /// 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. + /// Render all components into a single `String`, using your custom + /// closures to drive styling, text transformation, and final cleanup. /// - /// This is technically a shortcut for - /// [`FormattedText::to_ansi_with_custom_style`] with a default [`Style`] - /// colored white. + /// # 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 /// - /// # Examples + /// # 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; + /// use azalea_chat::{FormattedText, DEFAULT_STYLE}; /// use serde::de::Deserialize; /// /// let component = FormattedText::deserialize(&serde_json::json!({ @@ -86,44 +92,126 @@ impl FormattedText { /// "color": "red", /// })).unwrap(); /// - /// println!("{}", component.to_ansi()); + /// 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_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 + pub fn to_custom_format<F, S, C>( + &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) => c.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 ansi_text = running_style.compare_ansi(component_style, default_style); - built_string.push_str(&ansi_text); - built_string.push_str(&component_text); + let formatted_style = style_formatter(&running_style, component_style, default_style); + let formatted_text = text_formatter(&component_text); - running_style.apply(component_style); - } + output.push_str(&formatted_style.0); + output.push_str(&formatted_text); + output.push_str(&formatted_style.1); - if !running_style.is_empty() { - built_string.push_str("\u{1b}[m"); + // Reset running style if required + if component_style.reset { + running_style = default_style.clone(); + } else { + running_style.apply(component_style); + } } - built_string + 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. + /// + /// This is technically a shortcut for + /// [`FormattedText::to_ansi_with_custom_style`] with a default [`Style`] + /// colored white. + /// + /// # Examples + /// + /// ```rust + /// use azalea_chat::FormattedText; + /// use serde::de::Deserialize; + /// + /// let component = FormattedText::deserialize(&serde_json::json!({ + /// "text": "Hello, world!", + /// "color": "red", + /// })).unwrap(); + /// + /// println!("{}", component.to_ansi()); + /// ``` + pub fn to_ansi(&self) -> String { + self.to_ansi_with_custom_style(&DEFAULT_STYLE) + } + + pub fn to_html(&self) -> String { + self.to_custom_format( + |running, new, _| { + ( + format!( + "<span style=\"{}\">", + running.merged_with(new).get_html_style() + ), + "</span>".to_owned(), + ) + }, + |text| { + text.replace("&", "&") + .replace("<", "<") + // usually unnecessary but good for compatibility + .replace(">", ">") + .replace("\n", "<br>") + }, + |_| "".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(); @@ -164,6 +164,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]<br>{END_SPAN}{BOLD_AQUA}HAPPY HOLIDAYS{END_SPAN}", + END_SPAN = "</span>", + GREEN = "<span style=\"color: #55FF55;\">", + RED = "<span style=\"color: #FF5555;\">", + BOLD_AQUA = "<span style=\"color: #55FFFF;font-weight: bold;\">", + ) + ); + } + + #[test] + fn test_xss_html() { + let component = TextComponent::new("§a<b>&\n§b</b>".to_string()).get(); + + assert_eq!( + component.to_html(), + format!( + "{GREEN}<b>&<br>{END_SPAN}{AQUA}</b>{END_SPAN}", + END_SPAN = "</span>", + GREEN = "<span style=\"color: #55FF55;\">", + AQUA = "<span style=\"color: #55FFFF;\">", + ) + ); + } + + #[test] fn test_legacy_color_code_to_component() { let component = TextComponent::new("§lHello §r§1w§2o§3r§4l§5d".to_string()).get(); assert_eq!( |
