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:
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
|
/// Convert this component into an
|
||||||
/// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code), so you
|
/// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code), so you
|
||||||
/// can print it to your terminal and get styling.
|
/// can print it to your terminal and get styling.
|
||||||
|
@ -89,41 +188,30 @@ impl FormattedText {
|
||||||
/// println!("{}", component.to_ansi());
|
/// println!("{}", component.to_ansi());
|
||||||
/// ```
|
/// ```
|
||||||
pub fn to_ansi(&self) -> String {
|
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)
|
self.to_ansi_with_custom_style(&DEFAULT_STYLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert this component into an
|
pub fn to_html(&self) -> String {
|
||||||
/// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code).
|
self.to_custom_format(
|
||||||
///
|
|running, new, _| {
|
||||||
/// This is the same as [`FormattedText::to_ansi`], but you can specify a
|
(
|
||||||
/// default [`Style`] to use.
|
format!(
|
||||||
pub fn to_ansi_with_custom_style(&self, default_style: &Style) -> String {
|
"<span style=\"{}\">",
|
||||||
// this contains the final string will all the ansi escape codes
|
running.merged_with(new).get_html_style()
|
||||||
let mut built_string = String::new();
|
),
|
||||||
// this style will update as we visit components
|
"</span>".to_owned(),
|
||||||
let mut running_style = Style::default();
|
)
|
||||||
|
},
|
||||||
for component in self.clone().into_iter() {
|
|text| {
|
||||||
let component_text = match &component {
|
text.replace("&", "&")
|
||||||
Self::Text(c) => c.text.to_string(),
|
.replace("<", "<")
|
||||||
Self::Translatable(c) => c.to_string(),
|
// usually unnecessary but good for compatibility
|
||||||
};
|
.replace(">", ">")
|
||||||
|
.replace("\n", "<br>")
|
||||||
let component_style = &component.get_base().style;
|
},
|
||||||
|
|_| "".to_string(),
|
||||||
let ansi_text = running_style.compare_ansi(component_style, default_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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,4 +8,4 @@ pub mod style;
|
||||||
pub mod text_component;
|
pub mod text_component;
|
||||||
pub mod translatable_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
|
/// Apply a ChatFormatting to this style
|
||||||
pub fn apply_formatting(&mut self, formatting: &ChatFormatting) {
|
pub fn apply_formatting(&mut self, formatting: &ChatFormatting) {
|
||||||
match *formatting {
|
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")]
|
#[cfg(feature = "simdnbt")]
|
||||||
|
|
|
@ -146,7 +146,7 @@ mod tests {
|
||||||
use crate::style::Ansi;
|
use crate::style::Ansi;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hypixel_motd() {
|
fn test_hypixel_motd_ansi() {
|
||||||
let component =
|
let component =
|
||||||
TextComponent::new("§aHypixel Network §c[1.8-1.18]\n§b§lHAPPY HOLIDAYS".to_string())
|
TextComponent::new("§aHypixel Network §c[1.8-1.18]\n§b§lHAPPY HOLIDAYS".to_string())
|
||||||
.get();
|
.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]
|
#[test]
|
||||||
fn test_legacy_color_code_to_component() {
|
fn test_legacy_color_code_to_component() {
|
||||||
let component = TextComponent::new("§lHello §r§1w§2o§3r§4l§5d".to_string()).get();
|
let component = TextComponent::new("§lHello §r§1w§2o§3r§4l§5d".to_string()).get();
|
||||||
|
|
Loading…
Add table
Reference in a new issue