yggdrasil/src/utils/text.rs

201 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Markdown 与文本处理工具。
//!
//! 提供移除 Markdown 标记、字数统计、自动生成摘要等功能。
use std::sync::LazyLock;
/// 匹配 fenced code block```...```)的正则。
static CODE_BLOCK_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"```[\s\S]*?```").unwrap());
/// 匹配行内代码(`...`)的正则。
static INLINE_CODE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"`[^`]*`").unwrap());
/// 匹配 Markdown 链接 `[text](url)` 的正则。
static LINK_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").unwrap());
/// 匹配 Markdown 标题(# 到 ######)的正则。
static HEADING_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^#{1,6}\s*").unwrap());
/// 匹配 Markdown 图片 `![alt](url)` 的正则。
static IMAGE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"!\[([^\]]*)\]\([^)]*\)").unwrap());
/// 匹配任意空白字符的正则,用于把多个空白合并为单个空格。
static WHITESPACE_RE: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new(r"\s+").unwrap());
/// 去除 Markdown 标记,返回近似纯文本。
///
/// 处理顺序:代码块 → 行内代码 → 图片 → 链接(保留文字)→ 标题 → 加粗/斜体 → 合并空白。
pub fn strip_markdown(md: &str) -> String {
let mut plain = CODE_BLOCK_RE.replace_all(md, "").to_string();
plain = INLINE_CODE_RE.replace_all(&plain, "").to_string();
// 必须先移除图片再处理链接,否则 `![](url)` 会残留 `!`
plain = IMAGE_RE.replace_all(&plain, "").to_string();
plain = LINK_RE.replace_all(&plain, "$1").to_string();
plain = HEADING_RE.replace_all(&plain, "").to_string();
plain = plain
.replace("**", "")
.replace('*', "")
.replace("__", "")
.replace('_', "");
plain = WHITESPACE_RE.replace_all(&plain, " ").to_string();
plain.trim().to_string()
}
/// 统计 Markdown 文本的有效字数。
///
/// 中文字符每个计 1英文字母按连续字母串计 1 个词。
/// 空文本返回 1避免摘要或列表中出现 0 字的显示问题。
pub fn count_words(md: &str) -> u32 {
let plain = strip_markdown(md);
let mut count = 0u32;
let mut in_word = false;
for c in plain.chars() {
// CJK 统一表意文字范围(基本区)
if c as u32 >= 0x4E00 && c as u32 <= 0x9FFF {
count += 1;
in_word = false;
} else if c.is_alphabetic() {
if !in_word {
count += 1;
in_word = true;
}
} else {
in_word = false;
}
}
count.max(1)
}
/// 由字数估算阅读时长(分钟)。
///
/// 按每分钟 200 字计算,至少返回 1 分钟。
pub fn reading_time(word_count: u32) -> u32 {
(word_count / 200).max(1)
}
/// 自动生成文本摘要,取去除 Markdown 后的前 200 个字符。
pub fn auto_summary(md: &str) -> String {
let plain = strip_markdown(md);
plain.chars().take(200).collect()
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn strip_markdown_removes_code_blocks() {
let input = "before```code here```after";
assert_eq!(strip_markdown(input), "beforeafter");
}
#[test]
fn strip_markdown_removes_inline_code() {
assert_eq!(strip_markdown("text `code` more"), "text more");
}
#[test]
fn strip_markdown_removes_images() {
assert_eq!(strip_markdown("![alt](url)"), "");
}
#[test]
fn strip_markdown_keeps_link_text() {
assert_eq!(
strip_markdown("[click me](https://example.com)"),
"click me"
);
}
#[test]
fn strip_markdown_removes_headings() {
assert_eq!(strip_markdown("## Hello"), "Hello");
}
#[test]
fn strip_markdown_removes_bold_and_italic() {
assert_eq!(
strip_markdown("**bold** *italic* __bold__ _italic_"),
"bold italic bold italic"
);
}
#[test]
fn strip_markdown_empty_input() {
assert_eq!(strip_markdown(""), "");
}
#[test]
fn strip_markdown_mixed() {
let md = "# Title\n\nSome **bold** and `code` [link](url)\n\n![img](img.png)";
let result = strip_markdown(md);
assert_eq!(result, "Title Some bold and link");
}
#[test]
fn count_words_english() {
assert_eq!(count_words("hello world"), 2);
}
#[test]
fn count_words_chinese() {
assert_eq!(count_words("你好世界"), 4);
}
#[test]
fn count_words_mixed() {
let count = count_words("Hello 你好 world 世界");
assert_eq!(count, 6);
}
#[test]
fn count_words_with_markdown() {
let count = count_words("# Hello **World**\n\nSome `code` here");
assert_eq!(count, 4);
}
#[test]
fn count_words_empty_returns_one() {
assert_eq!(count_words(""), 1);
}
#[test]
fn reading_time_defaults_to_one() {
assert_eq!(reading_time(0), 1);
assert_eq!(reading_time(1), 1);
assert_eq!(reading_time(199), 1);
}
#[test]
fn reading_time_scales_by_two_hundred() {
assert_eq!(reading_time(200), 1);
assert_eq!(reading_time(201), 1);
assert_eq!(reading_time(400), 2);
assert_eq!(reading_time(1000), 5);
}
#[test]
fn auto_summary_truncates_at_200_chars() {
let long_md: String = "a ".repeat(200);
let summary = auto_summary(&long_md);
assert_eq!(summary.chars().count(), 200);
}
#[test]
fn auto_summary_short_input() {
assert_eq!(auto_summary("short"), "short");
}
#[test]
fn auto_summary_strips_markdown() {
let summary = auto_summary("**bold** and `code`");
assert_eq!(summary, "bold and");
}
}