pub fn dedent_block_with_offset(text: &str) -> (String, Option<usize>) {
if text.is_empty() {
return (String::new(), None);
}
let mut lines = Vec::new();
let mut starts = Vec::new();
let mut offset = 0usize;
for segment in text.split_inclusive('\n') {
let mut line = segment.strip_suffix('\n').unwrap_or(segment);
line = line.strip_suffix('\r').unwrap_or(line);
lines.push(line);
starts.push(offset);
offset += segment.len();
}
if lines.is_empty() {
return (String::new(), None);
}
let first_non_empty = lines.iter().position(|l| !l.trim().is_empty());
let last_non_empty = lines.iter().rposition(|l| !l.trim().is_empty());
let (Some(start), Some(end)) = (first_non_empty, last_non_empty) else {
return (String::new(), None);
};
let block = &lines[start..=end];
let min_indent = block
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
.min()
.unwrap_or(0);
let dedented = block
.iter()
.map(|l| {
if l.trim().is_empty() {
""
} else {
&l[cut_indent_bytes(l, min_indent)..]
}
})
.collect::<Vec<_>>()
.join("\n");
let content_offset = starts[start] + cut_indent_bytes(lines[start], min_indent);
(dedented, Some(content_offset))
}
fn cut_indent_bytes(line: &str, indent_chars: usize) -> usize {
line.chars().take(indent_chars).map(char::len_utf8).sum()
}
pub fn collapse_whitespace(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut prev_ws = true; for ch in text.chars() {
if ch.is_whitespace() {
if !prev_ws {
result.push(' ');
}
prev_ws = true;
} else {
result.push(ch);
prev_ws = false;
}
}
if result.ends_with(' ') {
result.pop();
}
result
}
pub fn is_text_content_element(tag_name: &str) -> bool {
matches!(tag_name, "text" | "tspan" | "textPath" | "title" | "desc")
}
#[derive(Clone, Copy)]
enum TextContentToken<'a> {
Text(&'a str),
Entity(&'a str),
Whitespace(&'a str),
}
pub fn normalize_text_content_with_entities(text: &str) -> String {
let mut tokens = Vec::new();
let mut offset = 0;
while offset < text.len() {
let rest = &text[offset..];
let Some(ch) = rest.chars().next() else {
break;
};
if ch.is_whitespace() {
let start = offset;
offset += ch.len_utf8();
while offset < text.len() {
let Some(next) = text[offset..].chars().next() else {
break;
};
if !next.is_whitespace() {
break;
}
offset += next.len_utf8();
}
tokens.push(TextContentToken::Whitespace(&text[start..offset]));
continue;
}
if ch == '&'
&& let Some(len) = entity_reference_len(rest)
{
tokens.push(TextContentToken::Entity(&text[offset..offset + len]));
offset += len;
continue;
}
let start = offset;
offset += ch.len_utf8();
while offset < text.len() {
let Some(next) = text[offset..].chars().next() else {
break;
};
if next.is_whitespace() {
break;
}
if next == '&' && entity_reference_len(&text[offset..]).is_some() {
break;
}
offset += next.len_utf8();
}
tokens.push(TextContentToken::Text(&text[start..offset]));
}
let mut normalized = String::new();
for (index, token) in tokens.iter().enumerate() {
match token {
TextContentToken::Text(text) | TextContentToken::Entity(text) => {
normalized.push_str(text);
}
TextContentToken::Whitespace(space) => {
let prev = tokens[..index]
.iter()
.rev()
.find(|token| !matches!(token, TextContentToken::Whitespace(_)));
let next = tokens[index + 1..]
.iter()
.find(|token| !matches!(token, TextContentToken::Whitespace(_)));
let (Some(prev), Some(next)) = (prev, next) else {
continue;
};
if should_strip_entity_boundary_space(*prev, *next, space) {
continue;
}
if !normalized.ends_with(' ') {
normalized.push(' ');
}
}
}
}
normalized.trim().to_string()
}
fn should_strip_entity_boundary_space(
prev: TextContentToken<'_>,
next: TextContentToken<'_>,
whitespace: &str,
) -> bool {
if !whitespace.contains(['\n', '\r']) {
return false;
}
matches!(prev, TextContentToken::Entity(entity) if is_open_angle_entity(entity))
&& matches!(next, TextContentToken::Text(_))
|| matches!(prev, TextContentToken::Text(_))
&& matches!(next, TextContentToken::Entity(entity) if is_close_angle_entity(entity))
}
fn entity_reference_len(text: &str) -> Option<usize> {
let end = text.find(';')?;
let candidate = &text[..=end];
let body = &candidate[1..candidate.len() - 1];
if body.is_empty() {
return None;
}
let valid = body
.strip_prefix("#x")
.or_else(|| body.strip_prefix("#X"))
.map_or_else(
|| {
body.strip_prefix('#').map_or_else(
|| body.chars().all(|ch| ch.is_ascii_alphanumeric()),
|decimal| !decimal.is_empty() && decimal.chars().all(|ch| ch.is_ascii_digit()),
)
},
|hex| !hex.is_empty() && hex.chars().all(|ch| ch.is_ascii_hexdigit()),
);
valid.then_some(candidate.len())
}
fn is_open_angle_entity(entity: &str) -> bool {
matches!(
entity.to_ascii_lowercase().as_str(),
"<" | "<" | "<"
)
}
fn is_close_angle_entity(entity: &str) -> bool {
matches!(
entity.to_ascii_lowercase().as_str(),
">" | ">" | ">"
)
}
pub fn decode_xml_entities(text: String) -> String {
if !text.contains('&') {
return text;
}
let mut result = String::with_capacity(text.len());
let mut offset = 0;
while offset < text.len() {
let rest = &text[offset..];
if rest.starts_with('&')
&& let Some(len) = entity_reference_len(rest)
{
let entity = &rest[..len];
if let Some(decoded) = decode_entity_char(entity) {
result.push(decoded);
} else {
result.push_str(entity);
}
offset += len;
continue;
}
let Some(ch) = rest.chars().next() else {
break;
};
result.push(ch);
offset += ch.len_utf8();
}
result
}
pub fn strip_cdata_wrapper(text: &str) -> Option<(usize, &str)> {
const PREFIX: &str = "<![CDATA[";
let trimmed = text.trim_start();
let leading_ws = text.len() - trimmed.len();
let inner = trimmed
.trim_end()
.strip_prefix(PREFIX)
.and_then(|s| s.strip_suffix("]]>"))?;
if inner.contains("<![CDATA[") || inner.contains("]]>") {
return None;
}
Some((leading_ws + PREFIX.len(), inner))
}
pub fn encode_xml_entities(text: &str) -> String {
if !text.contains(['&', '<', '>']) {
return text.to_string();
}
let mut result = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
_ => result.push(ch),
}
}
result
}
fn decode_entity_char(entity: &str) -> Option<char> {
let body = &entity[1..entity.len() - 1];
match body {
"lt" => Some('<'),
"gt" => Some('>'),
"amp" => Some('&'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => body
.strip_prefix("#x")
.or_else(|| body.strip_prefix("#X"))
.map_or_else(
|| {
body.strip_prefix('#')
.and_then(|dec| dec.parse::<u32>().ok())
.and_then(char::from_u32)
},
|hex| u32::from_str_radix(hex, 16).ok().and_then(char::from_u32),
),
}
}