aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKumpelinus <kumpelinus@jat.de>2025-05-01 20:26:04 +0200
committerGitHub <noreply@github.com>2025-05-01 13:26:04 -0500
commit11a74f215e28d7c3971c9894351567edb68ef0f8 (patch)
tree54f6be9b4152d71d0bf5016cc804854ccc6bcfb1
parent4a7d21425ca644de776bd380abbfabc816710f43 (diff)
downloadazalea-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.rs152
-rw-r--r--azalea-chat/src/lib.rs2
-rw-r--r--azalea-chat/src/style.rs57
-rw-r--r--azalea-chat/src/text_component.rs35
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("&", "&amp;")
+ .replace("<", "&lt;")
+ // usually unnecessary but good for compatibility
+ .replace(">", "&gt;")
+ .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}&lt;b&gt;&amp;<br>{END_SPAN}{AQUA}&lt;/b&gt;{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!(