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 中 `` 格式的图片数量。
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}