().ok())
.and_then(char::from_u32)
.map(|ch| ch.to_string())
.unwrap_or_else(|| caps[0].to_string())
})
.to_string();
result = re_hex_entity()
.replace_all(&result, |caps: ®ex::Captures| {
caps.get(1)
.and_then(|m| u32::from_str_radix(m.as_str(), 16).ok())
.and_then(char::from_u32)
.map(|ch| ch.to_string())
.unwrap_or_else(|| caps[0].to_string())
})
.to_string();
result
.replace(" ", " ")
.replace(""", "\"")
.replace("'", "'")
.replace(""", "\"")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("“", "\u{201C}")
.replace("”", "\u{201D}")
.replace("‘", "\u{2018}")
.replace("’", "\u{2019}")
.replace("–", "\u{2013}")
.replace("—", "\u{2014}")
.replace("…", "\u{2026}")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
}
// ── HTML 转义 ───────────────────────────────────────────────────
pub(crate) fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
// ── HTML 检测 ───────────────────────────────────────────────────
fn looks_like_html(s: &str) -> bool {
let t = s.trim();
if t.is_empty() {
return false;
}
// Fast-path: most real HTML descriptions include or
.
let lower = t.to_ascii_lowercase();
lower.contains("
String {
fn remove_tag_block(input: &str, tag: &str) -> String {
let mut out = String::with_capacity(input.len());
let lower = input.to_ascii_lowercase();
let open_pat = format!("<{}", tag);
let close_pat = format!("{}>", tag);
let mut i = 0;
while i < input.len() {
if lower[i..].starts_with(&open_pat) {
if let Some(close_pos) = lower[i..].find(&close_pat) {
i += close_pos + close_pat.len();
continue;
} else {
break;
}
}
let ch = input[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
let without_script = remove_tag_block(html, "script");
remove_tag_block(&without_script, "style")
}
// ── HTML → XHTML 片段归一化 ────────────────────────────────────
fn normalize_html_to_xhtml_fragment(html: &str) -> String {
let mut s = strip_script_and_style_blocks(html);
// Normalize line endings.
s = s.replace("\r\n", "\n").replace('\r', "\n");
//
must be self-closed.
s = s
.replace("
", "
")
.replace("
", "
")
.replace("
", "
")
.replace("
", "
");
// Some sources wrap content with ....
let lower = s.to_ascii_lowercase();
if lower.contains(""))
&& let Some(gt) = lower[a_start..].find('>')
{
let body_start = a_start + gt + 1;
let body_end = a_end;
if body_start <= body_end && body_end <= s.len() {
s = s[body_start..body_end].to_string();
}
}
s.trim().to_string()
}
// ── 描述渲染 ────────────────────────────────────────────────────
pub(crate) fn render_description_xhtml_fragment(description: &str) -> String {
let raw = description.trim();
if raw.is_empty() {
return "".to_string();
}
if looks_like_html(raw) {
let normalized = normalize_html_to_xhtml_fragment(raw);
if normalized.is_empty() {
return "".to_string();
}
return normalized;
}
// Plain-text: preserve line breaks as empty and normal paragraphs.
let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
let mut out = String::new();
for line in normalized.split('\n') {
let trimmed_end = line.trim_end();
if trimmed_end.trim().is_empty() {
out.push_str("");
} else {
out.push_str("");
out.push_str(&escape_html(trimmed_end));
out.push_str("
");
}
}
out
}
pub(crate) fn description_to_plain_text(description: &str) -> String {
let raw = description.trim();
if raw.is_empty() {
return String::new();
}
if !looks_like_html(raw) {
return raw.split_whitespace().collect::>().join(" ");
}
let mut out = String::with_capacity(raw.len());
let mut in_tag = false;
for ch in raw.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ => {
if !in_tag {
out.push(ch);
}
}
}
}
out.split_whitespace().collect::>().join(" ")
}
// ── EPUB 正文清理 ──────────────────────────────────────────────
pub(crate) fn clean_epub_body(html: &str) -> String {
let re_token = re_epub_token();
let re_src = re_src_attr();
let re_img = re_img_tag();
let re_tags = re_all_tags();
let mut out: Vec = Vec::new();
for cap in re_token.captures_iter(html) {
if let Some(img_tag) = cap.get(1).map(|m| m.as_str()) {
let src = re_src
.captures(img_tag)
.and_then(|c| c.get(1))
.map(|m| m.as_str())
.unwrap_or("");
if src.is_empty() {
continue;
}
if src.starts_with("images/") {
out.push(format!("
", escape_html(src)));
}
continue;
}
if let Some(p_tag) = cap.get(2).map(|m| m.as_str()) {
let lower = p_tag.to_ascii_lowercase();
if lower.contains("picturedesc") {
let inner = re_tags.replace_all(p_tag, "");
let inner = unescape_basic_entities(inner.as_ref());
let text = inner.trim();
if text.is_empty() {
continue;
}
let line = format!("﹝图﹞ {}", text);
out.push(format!("{}
", escape_html(&line)));
continue;
}
if lower.contains("
", escape_html(src)));
}
}
if lower.contains("picturedesc") {
let inner = re_tags.replace_all(p_tag, "");
let inner = unescape_basic_entities(inner.as_ref());
let text = inner.trim();
if !text.is_empty() {
let line = format!("﹝图﹞ {}", text);
out.push(format!("{}
", escape_html(&line)));
}
}
continue;
}
let inner = re_tags.replace_all(p_tag, "");
let inner = unescape_basic_entities(inner.as_ref());
let text = inner.trim();
if text.is_empty() {
continue;
}
out.push(format!("{}
", escape_html(text)));
continue;
}
// Headings inside content: skip (EpubGenerator already injects a ).
}
if out.is_empty() {
let plain = re_tags.replace_all(html, "");
let plain = unescape_basic_entities(plain.as_ref());
for line in plain.lines() {
let t = line.trim();
if !t.is_empty() {
out.push(format!("
{}
", escape_html(t)));
}
}
}
out.join("\n")
}
#[cfg(test)]
mod tests {
use super::{clean_epub_body, unescape_basic_entities};
#[test]
fn unescape_basic_entities_decodes_nested_entities() {
let out = unescape_basic_entities("他说"A&B"");
assert_eq!(out.as_ref(), "他说\"A&B\"");
}
#[test]
fn clean_epub_body_decodes_entities_before_reescaping() {
let out = clean_epub_body("他说"A&B"
");
assert_eq!(out, "他说"A&B"
");
assert!(!out.contains("""));
assert!(!out.contains("&"));
}
#[test]
fn clean_epub_body_preserves_ascii_emoticons_and_symbols() {
let out = clean_epub_body("(^_^)/ ~~ \"hi\" it's ok :-)
");
assert_eq!(out, "(^_^)/ ~~ "hi" it's ok :-)
");
assert!(!out.contains("(^_^)/"));
assert!(!out.contains(""hi""));
assert!(!out.contains(":-)"));
}
}