Skip to main content

yggdrasil/utils/
text.rs

1//! Markdown 与文本处理工具。
2//!
3//! 提供移除 Markdown 标记、字数统计、自动生成摘要等功能。
4
5use std::sync::LazyLock;
6
7/// 匹配 fenced code block(```...```)的正则。
8static CODE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
9    regex::Regex::new(r"```[\s\S]*?```").expect("CODE_BLOCK_RE 正则模式应在编译期通过校验")
10});
11
12/// 匹配行内代码(`...`)的正则。
13static INLINE_CODE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
14    regex::Regex::new(r"`[^`]*`").expect("INLINE_CODE_RE 正则模式应在编译期通过校验")
15});
16
17/// 匹配 Markdown 链接 `[text](url)` 的正则。
18static LINK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
19    regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").expect("LINK_RE 正则模式应在编译期通过校验")
20});
21
22/// 匹配 Markdown 标题(# 到 ######)的正则。
23static HEADING_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
24    regex::Regex::new(r"^#{1,6}\s*").expect("HEADING_RE 正则模式应在编译期通过校验")
25});
26
27/// 匹配 Markdown 图片 `![alt](url)` 的正则。
28static IMAGE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
29    regex::Regex::new(r"!\[([^\]]*)\]\([^)]*\)").expect("IMAGE_RE 正则模式应在编译期通过校验")
30});
31
32/// 匹配任意空白字符的正则,用于把多个空白合并为单个空格。
33static WHITESPACE_RE: LazyLock<regex::Regex> =
34    LazyLock::new(|| regex::Regex::new(r"\s+").expect("WHITESPACE_RE 正则模式应在编译期通过校验"));
35
36/// 去除 Markdown 标记,返回近似纯文本。
37///
38/// 处理顺序:代码块 → 行内代码 → 图片 → 链接(保留文字)→ 标题 → 加粗/斜体 → 合并空白。
39pub fn strip_markdown(md: &str) -> String {
40    let mut plain = CODE_BLOCK_RE.replace_all(md, "").to_string();
41    plain = INLINE_CODE_RE.replace_all(&plain, "").to_string();
42    // 必须先移除图片再处理链接,否则 `![](url)` 会残留 `!`
43    plain = IMAGE_RE.replace_all(&plain, "").to_string();
44    plain = LINK_RE.replace_all(&plain, "$1").to_string();
45    plain = HEADING_RE.replace_all(&plain, "").to_string();
46    plain = plain
47        .replace("**", "")
48        .replace('*', "")
49        .replace("__", "")
50        .replace('_', "");
51    plain = WHITESPACE_RE.replace_all(&plain, " ").to_string();
52    plain.trim().to_string()
53}
54
55/// 统计 Markdown 文本的有效字数。
56///
57/// 中文字符每个计 1;英文字母按连续字母串计 1 个词。
58/// 空文本返回 1,避免摘要或列表中出现 0 字的显示问题。
59pub fn count_words(md: &str) -> u32 {
60    let plain = strip_markdown(md);
61    let mut count = 0u32;
62    let mut in_word = false;
63
64    for c in plain.chars() {
65        // CJK 统一表意文字范围(基本区)
66        if c as u32 >= 0x4E00 && c as u32 <= 0x9FFF {
67            count += 1;
68            in_word = false;
69        } else if c.is_alphabetic() {
70            if !in_word {
71                count += 1;
72                in_word = true;
73            }
74        } else {
75            in_word = false;
76        }
77    }
78    count.max(1)
79}
80
81/// 由字数估算阅读时长(分钟)。
82///
83/// 按每分钟 200 字计算,至少返回 1 分钟。
84pub fn reading_time(word_count: u32) -> u32 {
85    (word_count / 200).max(1)
86}
87
88/// 自动生成文本摘要,取去除 Markdown 后的前 200 个字符。
89pub fn auto_summary(md: &str) -> String {
90    let plain = strip_markdown(md);
91    plain.chars().take(200).collect()
92}
93
94#[cfg(all(test, feature = "server"))]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn strip_markdown_removes_code_blocks() {
100        let input = "before```code here```after";
101        assert_eq!(strip_markdown(input), "beforeafter");
102    }
103
104    #[test]
105    fn strip_markdown_removes_inline_code() {
106        assert_eq!(strip_markdown("text `code` more"), "text more");
107    }
108
109    #[test]
110    fn strip_markdown_removes_images() {
111        assert_eq!(strip_markdown("![alt](url)"), "");
112    }
113
114    #[test]
115    fn strip_markdown_keeps_link_text() {
116        assert_eq!(
117            strip_markdown("[click me](https://example.com)"),
118            "click me"
119        );
120    }
121
122    #[test]
123    fn strip_markdown_removes_headings() {
124        assert_eq!(strip_markdown("## Hello"), "Hello");
125    }
126
127    #[test]
128    fn strip_markdown_removes_bold_and_italic() {
129        assert_eq!(
130            strip_markdown("**bold** *italic* __bold__ _italic_"),
131            "bold italic bold italic"
132        );
133    }
134
135    #[test]
136    fn strip_markdown_empty_input() {
137        assert_eq!(strip_markdown(""), "");
138    }
139
140    #[test]
141    fn strip_markdown_mixed() {
142        let md = "# Title\n\nSome **bold** and `code` [link](url)\n\n![img](img.png)";
143        let result = strip_markdown(md);
144        assert_eq!(result, "Title Some bold and link");
145    }
146
147    #[test]
148    fn count_words_english() {
149        assert_eq!(count_words("hello world"), 2);
150    }
151
152    #[test]
153    fn count_words_chinese() {
154        assert_eq!(count_words("你好世界"), 4);
155    }
156
157    #[test]
158    fn count_words_mixed() {
159        let count = count_words("Hello 你好 world 世界");
160        assert_eq!(count, 6);
161    }
162
163    #[test]
164    fn count_words_with_markdown() {
165        let count = count_words("# Hello **World**\n\nSome `code` here");
166        assert_eq!(count, 4);
167    }
168
169    #[test]
170    fn count_words_empty_returns_one() {
171        assert_eq!(count_words(""), 1);
172    }
173
174    #[test]
175    fn reading_time_defaults_to_one() {
176        assert_eq!(reading_time(0), 1);
177        assert_eq!(reading_time(1), 1);
178        assert_eq!(reading_time(199), 1);
179    }
180
181    #[test]
182    fn reading_time_scales_by_two_hundred() {
183        assert_eq!(reading_time(200), 1);
184        assert_eq!(reading_time(201), 1);
185        assert_eq!(reading_time(400), 2);
186        assert_eq!(reading_time(1000), 5);
187    }
188
189    #[test]
190    fn auto_summary_truncates_at_200_chars() {
191        let long_md: String = "a ".repeat(200);
192        let summary = auto_summary(&long_md);
193        assert_eq!(summary.chars().count(), 200);
194    }
195
196    #[test]
197    fn auto_summary_short_input() {
198        assert_eq!(auto_summary("short"), "short");
199    }
200
201    #[test]
202    fn auto_summary_strips_markdown() {
203        let summary = auto_summary("**bold** and `code`");
204        assert_eq!(summary, "bold and");
205    }
206}