1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
// SPDX-License-Identifier: Apache-2.0
#![allow(dead_code)]
//! HTML entity escaping/unescaping utilities.
/// Escape a string for safe embedding in HTML content.
pub fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
/// Unescape common named and numeric HTML entities.
pub fn html_unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp_pos) = rest.find('&') {
out.push_str(&rest[..amp_pos]);
rest = &rest[amp_pos..];
if let Some(semi_pos) = rest.find(';') {
let entity = &rest[1..semi_pos]; /* between & and ; */
let replacement = match entity {
"amp" => "&",
"lt" => "<",
"gt" => ">",
"quot" => "\"",
"#x27" | "apos" => "'",
"#x26" => "&",
"#60" => "<",
"#62" => ">",
"#34" => "\"",
_ => {
/* unknown entity: pass through as-is */
out.push_str(&rest[..semi_pos + 1]);
rest = &rest[semi_pos + 1..];
continue;
}
};
out.push_str(replacement);
rest = &rest[semi_pos + 1..];
} else {
/* no closing semicolon */
out.push('&');
rest = &rest[1..];
}
}
out.push_str(rest);
out
}
/// Return true if the string contains any characters that must be escaped.
pub fn html_needs_escape(s: &str) -> bool {
s.chars().any(|c| matches!(c, '&' | '<' | '>' | '"' | '\''))
}
/// Escape a string for use in an HTML attribute value (double-quoted).
pub fn html_escape_attr(s: &str) -> String {
/* same as html_escape but always escapes apostrophes too */
html_escape(s)
}
/// Verify round-trip escape/unescape.
pub fn html_roundtrip_ok(s: &str) -> bool {
html_unescape(&html_escape(s)) == s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_amp() {
/* ampersand escapes */
assert_eq!(html_escape("a&b"), "a&b");
}
#[test]
fn test_escape_lt_gt() {
/* angle brackets escape */
assert_eq!(html_escape("<div>"), "<div>");
}
#[test]
fn test_escape_quote() {
/* double quote escapes */
assert_eq!(html_escape("say \"hi\""), "say "hi"");
}
#[test]
fn test_escape_no_change() {
/* safe string unchanged */
assert_eq!(html_escape("hello world"), "hello world");
}
#[test]
fn test_unescape_amp() {
/* unescape & */
assert_eq!(html_unescape("a&b"), "a&b");
}
#[test]
fn test_unescape_lt_gt() {
/* unescape angle brackets */
assert_eq!(html_unescape("<div>"), "<div>");
}
#[test]
fn test_needs_escape_true() {
/* angle bracket triggers escape check */
assert!(html_needs_escape("<script>"));
}
#[test]
fn test_needs_escape_false() {
/* plain text does not need escaping */
assert!(!html_needs_escape("hello"));
}
#[test]
fn test_roundtrip_complex() {
/* complex string round-trips */
let s = "<h1>Hello & \"World\"!</h1>";
assert!(html_roundtrip_ok(s));
}
}