//! HTML/XHTML 文本处理工具。 //! //! 转义、清理 EPUB 正文、描述渲染等纯文本操作。 use regex::Regex; use std::sync::OnceLock; // 编译一次复用的正则缓存 fn re_epub_token() -> &'static Regex { static R: OnceLock = OnceLock::new(); R.get_or_init(|| { Regex::new(r"(?is)(]*?>)|(]*?>.*?

)|(]*?>.*?)") .unwrap() }) } fn re_src_attr() -> &'static Regex { static R: OnceLock = OnceLock::new(); R.get_or_init(|| Regex::new(r#"(?is)\bsrc\s*=\s*['"]([^'"]+)['"]"#).unwrap()) } fn re_img_tag() -> &'static Regex { static R: OnceLock = OnceLock::new(); R.get_or_init(|| Regex::new(r#"(?is)]*?>"#).unwrap()) } fn re_all_tags() -> &'static Regex { static R: OnceLock = OnceLock::new(); R.get_or_init(|| Regex::new(r"(?is)<[^>]+>").unwrap()) } fn re_decimal_entity() -> &'static Regex { static R: OnceLock = OnceLock::new(); R.get_or_init(|| Regex::new(r"&#(\d+);").unwrap()) } fn re_hex_entity() -> &'static Regex { static R: OnceLock = OnceLock::new(); R.get_or_init(|| Regex::new(r"&#[xX]([0-9a-fA-F]+);").unwrap()) } // ── 实体解码 ──────────────────────────────────────────────────── pub(crate) fn decode_xhtml_attr_url(src: &str) -> std::borrow::Cow<'_, str> { if src.contains("&") { return std::borrow::Cow::Owned(src.replace("&", "&")); } std::borrow::Cow::Borrowed(src) } pub(crate) fn unescape_basic_entities(s: &str) -> std::borrow::Cow<'_, str> { if !contains_basic_entity(s) { return std::borrow::Cow::Borrowed(s); } let mut result = s.to_string(); for _ in 0..4 { if !contains_basic_entity(&result) { break; } let decoded = unescape_basic_entities_once(&result); if decoded == result { break; } result = decoded; } std::borrow::Cow::Owned(result) } fn contains_basic_entity(s: &str) -> bool { s.contains("&#") || s.contains("&") || s.contains("<") || s.contains(">") || s.contains(""") || s.contains("'") || s.contains(""") || s.contains("'") || s.contains("'") || s.contains(""") || s.contains(" ") || s.contains("“") || s.contains("”") || s.contains("‘") || s.contains("’") || s.contains("–") || s.contains("—") || s.contains("…") } fn unescape_basic_entities_once(s: &str) -> String { let mut result = s.to_string(); result = re_decimal_entity() .replace_all(&result, |caps: ®ex::Captures| { caps.get(1) .and_then(|m| m.as_str().parse::().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("他说&#34;A&amp;B&#34;"); assert_eq!(out.as_ref(), "他说\"A&B\""); } #[test] fn clean_epub_body_decodes_entities_before_reescaping() { let out = clean_epub_body("

他说&#34;A&amp;B&#34;

"); assert_eq!(out, "

他说"A&B"

"); assert!(!out.contains("&#34;")); assert!(!out.contains("&amp;")); } #[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(":-)")); } }