markdown_readtime/
lib.rs

1//! # markdown-readtime
2//!
3//! 一个用于估算 Markdown 内容阅读时间的 Rust 库。
4//!
5//! ## 功能特性
6//!
7//! - 📊 准确估算 Markdown 文本的阅读时间
8//! - 🌍 支持中英文文本
9//! - 😊 Emoji 处理支持
10//! - 🖼️ 图片阅读时间计算
11//! - 💻 代码块阅读时间计算
12//! - ⚙️ 可自定义阅读速度参数
13//! - 📦 轻量级,零依赖(可选 serde 支持)
14//!
15//! ## 快速开始
16//!
17//! ### 基础用法
18//!
19//! ```
20//! use markdown_readtime::{estimate, minutes, words, formatted};
21//!
22//! let markdown_content = r#"
23//! # 我的第一篇博客文章
24//!
25//! 这是一些示例内容,用来演示如何使用 markdown-readtime 库。
26//!
27//! ## 子标题
28//!
29//! 我们还可以添加一些列表:
30//! - 第一项
31//! - 第二项
32//! - 第三项
33//! "#;
34//!
35//! // 获取完整的阅读时间信息
36//! let read_time = estimate(markdown_content);
37//! println!("总阅读时间: {}秒", read_time.total_seconds);
38//! println!("格式化时间: {}", read_time.formatted);
39//! println!("字数统计: {}", read_time.word_count);
40//!
41//! // 或者使用快捷函数
42//! println!("预计需要 {} 分钟读完", minutes(markdown_content));
43//! println!("大约有 {} 个字", words(markdown_content));
44//! println!("阅读时间: {}", formatted(markdown_content));
45//! ```
46//!
47//! ### 自定义阅读速度
48//!
49//! ```
50//! use markdown_readtime::{estimate_with_speed, ReadSpeed};
51//!
52//! let markdown_content = "# 示例文章\n\n这是用来测试的文章内容。";
53//!
54//! // 创建自定义阅读速度配置
55//! let speed = ReadSpeed::default()
56//!     .wpm(180.0)             // 设置每分钟阅读180个词
57//!     .image_time(15.0)       // 每张图片额外增加15秒
58//!     .code_block_time(25.0)  // 每个代码块额外增加25秒
59//!     .emoji(true)            // 考虑emoji
60//!     .chinese(true);         // 中文模式
61//!
62//! let read_time = estimate_with_speed(markdown_content, &speed);
63//! println!("自定义配置下的阅读时间: {}秒", read_time.total_seconds);
64//! ```
65mod utils;
66use pulldown_cmark::{Event, Parser, Tag, TagEnd};
67use utils::*;
68
69/// 阅读时间估算结果
70#[derive(Debug, Clone, PartialEq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub struct ReadTime {
73    /// 总阅读时间(秒)
74    ///
75    /// 这是向上取整后的总秒数,包括文本阅读时间、图片额外时间和代码块额外时间。
76    pub total_seconds: u64,
77
78    /// 格式化后的阅读时间字符串
79    ///
80    /// 将秒数转换为人类友好的格式,例如 "30秒"、"5分钟" 或 "2分30秒"。
81    pub formatted: String,
82
83    /// 单词数量
84    ///
85    /// 根据是否为中文文本,分别采用不同的计数方式:
86    /// - 中文:计算非空白字符数
87    /// - 英文:计算空格分隔的单词数
88    pub word_count: usize,
89
90    /// 图片数量
91    ///
92    /// Markdown 中 `![alt text](image_url)` 格式的图片数量。
93    pub image_count: usize,
94
95    /// 代码块数量
96    ///
97    /// Markdown 中 ```code``` 格式的代码块数量。
98    pub code_block_count: usize,
99}
100
101/// 阅读速度配置
102///
103/// 允许自定义各种影响阅读时间的因素。
104///
105/// # Examples
106///
107/// ```
108/// use markdown_readtime::ReadSpeed;
109///
110/// // 使用构建器模式创建自定义配置
111/// let speed = ReadSpeed::default()
112///     .wpm(180.0)
113///     .image_time(15.0)
114///     .code_block_time(25.0)
115///     .emoji(false);
116///
117/// // 或者直接创建
118/// let speed = ReadSpeed::new(180.0, 15.0, 25.0, false, true);
119/// ```
120#[derive(Debug, Clone, Copy)]
121pub struct ReadSpeed {
122    /// 每分钟阅读单词数(默认:200)
123    ///
124    /// 这是阅读速度的核心参数,用于计算文本的基础阅读时间。
125    pub words_per_minute: f64,
126
127    /// 每张图片额外时间(秒,默认:12)
128    ///
129    /// 每发现一张图片就会增加相应的时间,因为读者通常需要额外时间查看图片。
130    pub seconds_per_image: f64,
131
132    /// 每个代码块额外时间(秒,默认:20)
133    ///
134    /// 每发现一个代码块就会增加相应的时间,因为代码通常需要更仔细的阅读。
135    pub seconds_per_code_block: f64,
136
137    /// 是否考虑emoji(默认:true)
138    ///
139    /// 当启用时,emoji 会被单独计数,影响总的阅读时间估算。
140    pub count_emoji: bool,
141
142    /// 是否中文(默认:true)
143    ///
144    /// 决定使用哪种文本计数方式:
145    /// - `true`: 使用中文计数方式(计算字符数)
146    /// - `false`: 使用英文计数方式(计算单词数)
147    pub chinese: bool,
148}
149
150impl Default for ReadSpeed {
151    fn default() -> Self {
152        Self {
153            words_per_minute: 300.0,
154            seconds_per_image: 30.0,
155            seconds_per_code_block: 20.0,
156            count_emoji: true,
157            chinese: true,
158        }
159    }
160}
161
162impl ReadSpeed {
163    pub fn new(
164        wpm: f64,
165        seconds_per_image: f64,
166        seconds_per_code_block: f64,
167        count_emoji: bool,
168        chinese: bool,
169    ) -> Self {
170        Self {
171            words_per_minute: wpm,
172            seconds_per_image,
173            seconds_per_code_block,
174            count_emoji,
175            chinese,
176        }
177    }
178
179    pub fn wpm(mut self, wpm: f64) -> Self {
180        self.words_per_minute = wpm;
181        self
182    }
183
184    pub fn image_time(mut self, seconds: f64) -> Self {
185        self.seconds_per_image = seconds;
186        self
187    }
188
189    pub fn code_block_time(mut self, seconds: f64) -> Self {
190        self.seconds_per_code_block = seconds;
191        self
192    }
193
194    pub fn emoji(mut self, count: bool) -> Self {
195        self.count_emoji = count;
196        self
197    }
198
199    pub fn chinese(mut self, is_chinese: bool) -> Self {
200        self.chinese = is_chinese;
201        self
202    }
203}
204
205/// 估算Markdown的阅读时间
206///
207/// 使用默认的阅读速度配置来估算给定 Markdown 文本的阅读时间。
208///
209/// # Arguments
210///
211/// * `markdown` - 需要估算阅读时间的 Markdown 文本
212///
213/// # Returns
214///
215/// 返回包含阅读时间信息的 [`ReadTime`] 结构体。
216///
217/// # Examples
218///
219/// ```
220/// use markdown_readtime::estimate;
221///
222/// let markdown = "# 标题\n\n这是内容";
223/// let read_time = estimate(markdown);
224/// println!("阅读需要 {} 时间", read_time.formatted);
225/// ```
226pub fn estimate(markdown: &str) -> ReadTime {
227    estimate_with_speed(markdown, &ReadSpeed::default())
228}
229
230/// 使用自定义速度配置估算阅读时间
231///
232/// 使用指定的阅读速度配置来估算给定 Markdown 文本的阅读时间。
233///
234/// # Arguments
235///
236/// * `markdown` - 需要估算阅读时间的 Markdown 文本
237/// * `speed` - 自定义的阅读速度配置
238///
239/// # Returns
240///
241/// 返回包含阅读时间信息的 [`ReadTime`] 结构体。
242///
243/// # Examples
244///
245/// ```
246/// use markdown_readtime::{estimate_with_speed, ReadSpeed};
247///
248/// let markdown = "# Title\n\nThis is content";
249/// let speed = ReadSpeed::default().wpm(180.0);
250/// let read_time = estimate_with_speed(markdown, &speed);
251/// println!("阅读需要 {} 时间", read_time.formatted);
252/// ```
253pub fn estimate_with_speed(markdown: &str, speed: &ReadSpeed) -> ReadTime {
254    let parser = Parser::new(markdown);
255
256    let mut word_count = 0;
257    let mut image_count = 0;
258    let mut code_block_count = 0;
259    let mut in_code_block = false;
260    let mut in_image_alt = false;
261
262    for event in parser {
263        match event {
264            Event::Start(tag) => match tag {
265                Tag::Image { .. } => {
266                    image_count += 1;
267                    in_image_alt = true;
268                }
269                Tag::CodeBlock(_) => {
270                    code_block_count += 1;
271                    in_code_block = true;
272                }
273                _ => {}
274            },
275            Event::End(tag) => match tag {
276                TagEnd::Image { .. } => {
277                    in_image_alt = false;
278                }
279                TagEnd::CodeBlock => {
280                    in_code_block = false;
281                }
282                _ => {}
283            },
284            Event::Text(text) => {
285                if !in_image_alt && !in_code_block {
286                    if speed.chinese {
287                        word_count += count_words(&text, speed.count_emoji);
288                    } else {
289                        word_count += count_english_words(&text, speed.count_emoji);
290                    }
291                }
292            }
293            Event::Code(code) => {
294                if !in_code_block {
295                    if speed.chinese {
296                        word_count += count_words(&code, speed.count_emoji);
297                    } else {
298                        word_count += count_english_words(&code, speed.count_emoji);
299                    }
300                }
301            }
302            _ => {}
303        }
304    }
305
306    // 计算基础阅读时间(基于单词数)
307    let base_seconds = (word_count as f64 / speed.words_per_minute) * 60.0;
308
309    // 添加图片和代码块的额外时间
310    let image_seconds = image_count as f64 * speed.seconds_per_image;
311    let code_seconds = code_block_count as f64 * speed.seconds_per_code_block;
312
313    let total_seconds = (base_seconds + image_seconds + code_seconds).ceil() as u64;
314
315    ReadTime {
316        total_seconds,
317        formatted: format_time(total_seconds),
318        word_count,
319        image_count,
320        code_block_count,
321    }
322}
323
324/// 快捷函数:获取分钟数
325///
326/// 估算阅读时间并向上去整到最近的分钟数。
327///
328/// # Arguments
329///
330/// * `markdown` - 需要估算阅读时间的 Markdown 文本
331///
332/// # Returns
333///
334/// 向上取整后的分钟数。
335///
336/// # Examples
337///
338/// ```
339/// use markdown_readtime::minutes;
340///
341/// let markdown = "# 标题\n\n这是内容";
342/// let mins = minutes(markdown);
343/// println!("大约需要 {} 分钟阅读", mins);
344/// ```
345pub fn minutes(markdown: &str) -> u64 {
346    let read_time = estimate(markdown);
347    (read_time.total_seconds as f64 / 60.0).ceil() as u64
348}
349
350/// 快捷函数:获取单词数
351///
352/// 计算 Markdown 文本中的单词数量。
353///
354/// # Arguments
355///
356/// * `markdown` - 需要计算单词数的 Markdown 文本
357///
358/// # Returns
359///
360/// 单词数量。
361///
362/// # Examples
363///
364/// ```
365/// use markdown_readtime::words;
366///
367/// let markdown = "# 标题\n\n这是内容";
368/// let word_count = words(markdown);
369/// println!("共有 {} 个字", word_count);
370/// ```
371pub fn words(markdown: &str) -> usize {
372    estimate(markdown).word_count
373}
374
375/// 快捷函数:获取格式化字符串
376///
377/// 获取格式化后的阅读时间字符串。
378///
379/// # Arguments
380///
381/// * `markdown` - 需要估算阅读时间的 Markdown 文本
382///
383/// # Returns
384///
385/// 格式化后的阅读时间字符串,例如 "30秒"、"5分钟" 或 "2分30秒"。
386///
387/// # Examples
388///
389/// ```
390/// use markdown_readtime::formatted;
391///
392/// let markdown = "# 标题\n\n这是内容";
393/// let formatted_time = formatted(markdown);
394/// println!("阅读时间: {}", formatted_time);
395/// ```
396pub fn formatted(markdown: &str) -> String {
397    estimate(markdown).formatted
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_estimate() {
406        let md_txt = r#"
407# 标题
408## 子标题
409### 子子标题
4101. 列表1
4112. 列表2
412"#
413        .trim();
414        let read_time = estimate(md_txt);
415        assert_eq!(read_time.word_count, 15);
416        assert_eq!(read_time.image_count, 0);
417        assert_eq!(read_time.code_block_count, 0);
418        assert_eq!(read_time.total_seconds, 5);
419        assert_eq!(read_time.formatted, "5秒");
420    }
421
422    #[test]
423    fn test_estimate_with_speed() {
424        // 测试中文
425        let md_txt = r#"
426# 标题
427## 子标题
428### 子子标题
4291. 列表1
4302. 列表2
431"#
432        .trim();
433        let speed = ReadSpeed::new(100.0, 10.0, 15.0, true, true);
434        let read_time = estimate_with_speed(md_txt, &speed);
435        assert_eq!(read_time.word_count, 15);
436        assert_eq!(read_time.image_count, 0);
437        assert_eq!(read_time.code_block_count, 0);
438        assert_eq!(read_time.total_seconds, 9);
439        assert_eq!(read_time.formatted, "9秒");
440
441        // 测试英文
442        let md_txt_english = r#"
443# Title
444
445This is a test paragraph. It contains some words.
446"#
447        .trim();
448
449        let speed = ReadSpeed::new(200.0, 10.0, 15.0, true, false);
450        let read_time = estimate_with_speed(md_txt_english, &speed);
451        assert_eq!(read_time.word_count, 10);
452        assert_eq!(read_time.total_seconds, 3);
453        assert_eq!(read_time.formatted, "3秒");
454    }
455
456    #[test]
457    fn test_formatted() {
458        let md_txt = r#"
459# 测试标题
460## 子标题
461### 子子标题
462- 列表项1
463- 列表项2
464"#
465        .trim();
466        let formatted_time = formatted(md_txt);
467        assert_eq!(formatted_time, "6秒");
468    }
469}