1
2
Fork 0
mirror of https://github.com/mat-1/azalea.git synced 2025-08-02 14:26:04 +00:00

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>
This commit is contained in:
Kumpelinus 2025-05-01 20:26:04 +02:00 committed by GitHub
parent 4a7d21425c
commit 11a74f215e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 212 additions and 34 deletions

View file

@ -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 components 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 components `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<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) => 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!(
"<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,
)
}
}

View file

@ -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};

View file

@ -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), selfs 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")]

View file

@ -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]<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();