mirror of
https://github.com/mat-1/azalea.git
synced 2025-08-02 06:16: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:
parent
4a7d21425c
commit
11a74f215e
4 changed files with 212 additions and 34 deletions
|
@ -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<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("&", "&")
|
||||
.replace("<", "<")
|
||||
// usually unnecessary but good for compatibility
|
||||
.replace(">", ">")
|
||||
.replace("\n", "<br>")
|
||||
},
|
||||
|_| "".to_string(),
|
||||
&DEFAULT_STYLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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}<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();
|
||||
|
|
Loading…
Add table
Reference in a new issue