| |
| |
| |
|
|
| use regex::Regex; |
| use std::sync::OnceLock; |
|
|
| |
| fn re_epub_token() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| { |
| Regex::new(r"(?is)(<img\b[^>]*?>)|(<p\b[^>]*?>.*?</p>)|(<h[1-6]\b[^>]*?>.*?</h[1-6]>)") |
| .unwrap() |
| }) |
| } |
|
|
| fn re_src_attr() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r#"(?is)\bsrc\s*=\s*['"]([^'"]+)['"]"#).unwrap()) |
| } |
|
|
| fn re_img_tag() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r#"(?is)<img\b[^>]*?>"#).unwrap()) |
| } |
|
|
| fn re_all_tags() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"(?is)<[^>]+>").unwrap()) |
| } |
|
|
| fn re_decimal_entity() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"&#(\d+);").unwrap()) |
| } |
|
|
| fn re_hex_entity() -> &'static Regex { |
| static R: OnceLock<Regex> = 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::<u32>().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("&", "&") |
| } |
|
|
| |
|
|
| pub(crate) fn escape_html(s: &str) -> String { |
| s.replace('&', "&") |
| .replace('<', "<") |
| .replace('>', ">") |
| .replace('"', """) |
| .replace('\'', "'") |
| } |
|
|
| |
|
|
| fn looks_like_html(s: &str) -> bool { |
| let t = s.trim(); |
| if t.is_empty() { |
| return false; |
| } |
|
|
| |
| let lower = t.to_ascii_lowercase(); |
| lower.contains("<p") |
| || lower.contains("</p") |
| || lower.contains("<br") |
| || lower.contains("<div") |
| || lower.contains("<span") |
| || lower.contains("<a ") |
| || lower.contains("<img") |
| } |
|
|
| |
|
|
| fn strip_script_and_style_blocks(html: &str) -> 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") |
| } |
|
|
| |
|
|
| fn normalize_html_to_xhtml_fragment(html: &str) -> String { |
| let mut s = strip_script_and_style_blocks(html); |
|
|
| |
| s = s.replace("\r\n", "\n").replace('\r', "\n"); |
|
|
| |
| s = s |
| .replace("<br>", "<br/>") |
| .replace("<br />", "<br/>") |
| .replace("<BR>", "<br/>") |
| .replace("<BR />", "<br/>"); |
|
|
| |
| let lower = s.to_ascii_lowercase(); |
| if lower.contains("<article") |
| && let (Some(a_start), Some(a_end)) = (lower.find("<article"), lower.rfind("</article>")) |
| && 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 "<p></p>".to_string(); |
| } |
|
|
| if looks_like_html(raw) { |
| let normalized = normalize_html_to_xhtml_fragment(raw); |
| if normalized.is_empty() { |
| return "<p></p>".to_string(); |
| } |
| return normalized; |
| } |
|
|
| |
| 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("<p></p>"); |
| } else { |
| out.push_str("<p>"); |
| out.push_str(&escape_html(trimmed_end)); |
| out.push_str("</p>"); |
| } |
| } |
| 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::<Vec<_>>().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::<Vec<_>>().join(" ") |
| } |
|
|
| |
|
|
| 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<String> = 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!("<img alt=\"\" src=\"{}\"/>", 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!("<p class=\"img-desc\">{}</p>", escape_html(&line))); |
| continue; |
| } |
| if lower.contains("<img") { |
| for img_tag in re_img.find_iter(p_tag).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.starts_with("images/") { |
| out.push(format!("<img alt=\"\" src=\"{}\"/>", 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!("<p class=\"img-desc\">{}</p>", 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!("<p>{}</p>", escape_html(text))); |
| continue; |
| } |
|
|
| |
| } |
|
|
| 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!("<p>{}</p>", 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("<p>他说&#34;A&amp;B&#34;</p>"); |
|
|
| assert_eq!(out, "<p>他说"A&B"</p>"); |
| assert!(!out.contains("&#34;")); |
| assert!(!out.contains("&amp;")); |
| } |
|
|
| #[test] |
| fn clean_epub_body_preserves_ascii_emoticons_and_symbols() { |
| let out = clean_epub_body("<p>(^_^)/ ~~ \"hi\" it's ok :-)</p>"); |
|
|
| assert_eq!(out, "<p>(^_^)/ ~~ "hi" it's ok :-)</p>"); |
| assert!(!out.contains("(^_^)/")); |
| assert!(!out.contains(""hi"")); |
| assert!(!out.contains(":-)")); |
| } |
| } |
|
|