| |
|
|
| #![cfg_attr(not(feature = "official-api"), allow(dead_code))] |
|
|
| use regex::Regex; |
| use std::sync::OnceLock; |
|
|
| use super::html_utils::{escape_html, unescape_basic_entities}; |
|
|
| |
| fn class_attr_regex() -> &'static Regex { |
| static REGEX: OnceLock<Regex> = OnceLock::new(); |
| |
| |
| |
| REGEX.get_or_init(|| Regex::new(r#"(?i)\bclass\s*=\s*["']([^"']*)["']"#).unwrap()) |
| } |
|
|
| |
| fn para_and_heading_regex() -> &'static Regex { |
| static REGEX: OnceLock<Regex> = OnceLock::new(); |
| REGEX.get_or_init(|| { |
| Regex::new(r"(?is)(<p\b[^>]*>)(.*?)(</p>)|(<h[1-6]\b[^>]*?>.*?</h[1-6]>)").unwrap() |
| }) |
| } |
|
|
| fn id_attr_regex() -> &'static Regex { |
| static REGEX: OnceLock<Regex> = OnceLock::new(); |
| REGEX.get_or_init(|| Regex::new(r"(?is)\bid\s*=").unwrap()) |
| } |
|
|
| fn html_tags_regex() -> &'static Regex { |
| static REGEX: OnceLock<Regex> = OnceLock::new(); |
| REGEX.get_or_init(|| Regex::new(r"<[^>]+>").unwrap()) |
| } |
|
|
| fn bracket_emoji_regex() -> &'static Regex { |
| static REGEX: OnceLock<Regex> = OnceLock::new(); |
| REGEX.get_or_init(|| Regex::new(r"\[([\u4e00-\u9fa5]{1,4})\]").unwrap()) |
| } |
|
|
| |
| pub fn convert_bracket_emojis(text: &str) -> String { |
| if !text.contains('[') { |
| return text.to_string(); |
| } |
| let map = emoji_map(); |
| bracket_emoji_regex() |
| .replace_all(text, |caps: ®ex::Captures| { |
| let key = caps.get(1).map(|m| m.as_str()).unwrap_or(""); |
| map.get(key).cloned().unwrap_or_else(|| caps[0].to_string()) |
| }) |
| .to_string() |
| } |
|
|
| pub fn to_cjk_numeral(n: i32) -> String { |
| let digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]; |
| if n <= 0 { |
| return n.to_string(); |
| } |
| if n < 10 { |
| return digits[n as usize].to_string(); |
| } |
| if n == 10 { |
| return "十".to_string(); |
| } |
| if n < 20 { |
| return format!("十{}", digits[(n - 10) as usize]); |
| } |
| if n < 100 { |
| let shi = n / 10; |
| let ge = n % 10; |
| if ge == 0 { |
| format!("{}十", digits[shi as usize]) |
| } else { |
| format!("{}十{}", digits[shi as usize], digits[ge as usize]) |
| } |
| } else { |
| n.to_string() |
| } |
| } |
|
|
| |
| fn should_skip_para_for_comments(open_tag: &str) -> bool { |
| |
| |
| static SKIP_CLASSES: &[&str] = &[ |
| "picture", |
| "volumetitle", |
| "volume-title", |
| "sectiontitle", |
| "section-title", |
| "catalogtitle", |
| "catalog-title", |
| "chaptertitle", |
| "chapter-title", |
| ]; |
|
|
| |
| if let Some(caps) = class_attr_regex().captures(open_tag) |
| && let Some(class_list) = caps.get(1) |
| { |
| let classes = class_list.as_str(); |
| |
| for class in classes.split_whitespace() { |
| let lower = class.to_ascii_lowercase(); |
| if SKIP_CLASSES.contains(&lower.as_str()) { |
| return true; |
| } |
| } |
| } |
|
|
| false |
| } |
|
|
| |
| pub fn extract_para_snippet(chapter_html: &str, target_idx: usize) -> String { |
| let mut content_idx = 0; |
| for cap in para_and_heading_regex().captures_iter(chapter_html) { |
| |
| if cap.get(4).is_some() { |
| continue; |
| } |
|
|
| let open_tag = cap.get(1).map(|m| m.as_str()).unwrap_or(""); |
| |
| if should_skip_para_for_comments(open_tag) { |
| continue; |
| } |
| if content_idx == target_idx { |
| let inner = cap.get(2).map(|m| m.as_str()).unwrap_or(""); |
| let inner_text = strip_tags(inner); |
| let inner_text = unescape_basic_entities(&inner_text); |
| let inner_text = inner_text.trim().to_string(); |
| if inner_text.is_empty() { |
| return String::new(); |
| } |
| let cut = ["。", "!", "?", ".", "!", "?", ";", "…"] |
| .iter() |
| .filter_map(|sep| inner_text.find(sep).map(|p| p + sep.len())) |
| .min() |
| .unwrap_or_else(|| inner_text.len().min(20)); |
| return inner_text[..cut].trim().to_string(); |
| } |
| content_idx += 1; |
| } |
| String::new() |
| } |
|
|
| pub fn inject_segment_links( |
| content_html: &str, |
| comments_file: &str, |
| seg_counts: &serde_json::Map<String, serde_json::Value>, |
| ) -> String { |
| |
| |
| |
| |
| |
| |
|
|
| let mut out = String::new(); |
| let mut last_end = 0usize; |
| let mut content_idx = 0usize; |
|
|
| for m in para_and_heading_regex().find_iter(content_html) { |
| out.push_str(&content_html[last_end..m.start()]); |
|
|
| let caps = para_and_heading_regex().captures(m.as_str()).unwrap(); |
|
|
| |
| if caps.get(4).is_some() { |
| |
| last_end = m.end(); |
| continue; |
| } |
|
|
| |
| let mut open_tag = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string(); |
| let mut inner = caps.get(2).map(|m| m.as_str()).unwrap_or("").to_string(); |
| inner = normalize_html_text_nodes(&inner); |
| let close_tag = caps.get(3).map(|m| m.as_str()).unwrap_or(""); |
|
|
| |
| if should_skip_para_for_comments(&open_tag) { |
| out.push_str(&open_tag); |
| out.push_str(&inner); |
| out.push_str(close_tag); |
| last_end = m.end(); |
| continue; |
| } |
|
|
| let cnt = seg_counts |
| .get(&content_idx.to_string()) |
| .and_then(|v| v.as_u64()) |
| .unwrap_or(0); |
|
|
| if cnt > 0 { |
| if !id_attr_regex().is_match(&open_tag) && open_tag.ends_with('>') { |
| open_tag.pop(); |
| open_tag.push_str(&format!(" id=\"p-{}\">", content_idx)); |
| } |
| inner.push_str(&format!( |
| " <a class=\"seg-count\" href=\"{}#para-{}\" title=\"查看本段评论\">({})</a>", |
| html_escape_attr(comments_file), |
| content_idx, |
| cnt |
| )); |
| } |
|
|
| out.push_str(&open_tag); |
| out.push_str(&inner); |
| out.push_str(close_tag); |
|
|
| last_end = m.end(); |
| content_idx += 1; |
| } |
|
|
| out.push_str(&content_html[last_end..]); |
| out |
| } |
|
|
| fn html_escape_attr(input: &str) -> String { |
| |
| input |
| .replace('&', "&") |
| .replace('<', "<") |
| .replace('>', ">") |
| .replace('"', """) |
| .replace('\'', "'") |
| } |
|
|
| fn strip_tags(raw: &str) -> String { |
| html_tags_regex().replace_all(raw, "").to_string() |
| } |
|
|
| fn normalize_html_text_nodes(fragment: &str) -> String { |
| if !fragment.contains('&') { |
| return fragment.to_string(); |
| } |
|
|
| let mut out = String::with_capacity(fragment.len()); |
| let mut text_buf = String::new(); |
| let mut in_tag = false; |
|
|
| for ch in fragment.chars() { |
| match ch { |
| '<' if !in_tag => { |
| if !text_buf.is_empty() { |
| out.push_str(&normalize_text_segment(&text_buf)); |
| text_buf.clear(); |
| } |
| in_tag = true; |
| out.push(ch); |
| } |
| '>' if in_tag => { |
| in_tag = false; |
| out.push(ch); |
| } |
| _ if in_tag => out.push(ch), |
| _ => text_buf.push(ch), |
| } |
| } |
|
|
| if !text_buf.is_empty() { |
| out.push_str(&normalize_text_segment(&text_buf)); |
| } |
|
|
| out |
| } |
|
|
| fn normalize_text_segment(text: &str) -> String { |
| let decoded = unescape_basic_entities(text); |
| escape_html(decoded.as_ref()) |
| } |
|
|
| fn emoji_map() -> std::collections::HashMap<&'static str, String> { |
| use std::collections::HashMap; |
| let mut m = HashMap::new(); |
| m.insert("笑", "😄".to_string()); |
| m.insert("哭", "😭".to_string()); |
| m.insert("汗", "😅".to_string()); |
| m.insert("怒", "😡".to_string()); |
| m.insert("痛", "😣".to_string()); |
| m.insert("赞", "👍".to_string()); |
| m.insert("踩", "👎".to_string()); |
| m.insert("惊", "😲".to_string()); |
| m.insert("疑", "🤔".to_string()); |
| m.insert("色", "😍".to_string()); |
| m.insert("呆", "😐".to_string()); |
| m.insert("坏", "😈".to_string()); |
| m.insert("奸笑", "😏".to_string()); |
| m.insert("舔屏", "🤤".to_string()); |
| m.insert("委屈", "🥺".to_string()); |
| m.insert("飞吻", "😘".to_string()); |
| m.insert("酷", "😎".to_string()); |
| m.insert("送心", "💖".to_string()); |
| m.insert("我也强推", "💯".to_string()); |
| m.insert("惊呆", "😲".to_string()); |
| m.insert("偷笑", "🤭".to_string()); |
| m.insert("翻白眼", "🙄".to_string()); |
| m.insert("石化", "🗿".to_string()); |
| m |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::{extract_para_snippet, inject_segment_links}; |
| use serde_json::json; |
|
|
| #[test] |
| fn extract_para_snippet_decodes_html_entities() { |
| let html = "<p>她说&#34;你好&#34;。</p>"; |
|
|
| assert_eq!(extract_para_snippet(html, 0), "她说\"你好\"。"); |
| } |
|
|
| #[test] |
| fn inject_segment_links_normalizes_text_nodes_but_preserves_attrs() { |
| let mut seg_counts = serde_json::Map::new(); |
| seg_counts.insert("0".to_string(), json!(3)); |
|
|
| let out = inject_segment_links( |
| r#"<p class="content" data-ref="a&b">她说&#34;你好&#34;<span>It&#39;s me</span></p>"#, |
| "aux_00001.xhtml", |
| &seg_counts, |
| ); |
|
|
| assert!(out.contains(r#"data-ref="a&b""#)); |
| assert!(out.contains("她说"你好"<span>It's me</span>")); |
| assert!(out.contains(r#"id="p-0""#)); |
| assert!(out.contains(r#"href="aux_00001.xhtml#para-0""#)); |
| assert!(!out.contains("&#34;")); |
| assert!(!out.contains("&#39;")); |
| } |
| } |
|
|