1#![forbid(unsafe_code)]
26#![doc(html_root_url = "https://docs.rs/reading-time/0.1.0")]
27#![allow(
28 clippy::cast_possible_truncation,
29 clippy::cast_precision_loss,
30 clippy::cast_sign_loss,
31 clippy::cast_possible_wrap,
32 clippy::float_cmp
34)]
35
36#[cfg(doctest)]
38#[doc = include_str!("../README.md")]
39struct ReadmeDoctests;
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct ReadingTime {
44 pub text: String,
46 pub minutes: f64,
48 pub time: i64,
50 pub words: usize,
52}
53
54fn in_ranges(code: u32, ranges: &[(u32, u32)]) -> bool {
55 ranges
56 .iter()
57 .any(|&(low, high)| low <= code && code <= high)
58}
59
60fn is_cjk(unit: Option<u16>) -> bool {
62 match unit {
63 Some(u) => in_ranges(
64 u32::from(u),
65 &[
66 (0x3040, 0x309F), (0x4E00, 0x9FFF), (0xAC00, 0xD7A3), (0x20000, 0x2EBE0), ],
71 ),
72 None => false,
73 }
74}
75
76fn is_word_bound(unit: Option<u16>) -> bool {
78 matches!(unit, Some(0x20 | 0x0A | 0x0D | 0x09))
79}
80
81fn is_punctuation(unit: Option<u16>) -> bool {
83 match unit {
84 Some(u) => in_ranges(
85 u32::from(u),
86 &[
87 (0x21, 0x2F),
88 (0x3A, 0x40),
89 (0x5B, 0x60),
90 (0x7B, 0x7E),
91 (0x3000, 0x303F), (0xFF00, 0xFFEF), ],
94 ),
95 None => false,
96 }
97}
98
99#[must_use]
106pub fn reading_time(text: &str) -> ReadingTime {
107 reading_time_with(text, 200)
108}
109
110#[must_use]
113pub fn reading_time_with(text: &str, words_per_minute: usize) -> ReadingTime {
114 let words_per_minute = if words_per_minute == 0 {
115 200
116 } else {
117 words_per_minute
118 };
119
120 let units: Vec<u16> = text.encode_utf16().collect();
123 let length = units.len();
124 let get = |i: isize| -> Option<u16> {
125 usize::try_from(i).ok().and_then(|i| {
126 if i == length {
127 Some(0x0A) } else {
129 units.get(i).copied()
130 }
131 })
132 };
133
134 let mut start = 0isize;
136 while (start as usize) < length && is_word_bound(get(start)) {
137 start += 1;
138 }
139 let mut end = length as isize - 1;
140 while end >= 0 && is_word_bound(get(end)) {
141 end -= 1;
142 }
143
144 let mut words = 0usize;
145 let mut i = start;
146 while i <= end {
147 let current = get(i);
148 let next = get(i + 1);
149 if is_cjk(current) || (!is_word_bound(current) && (is_word_bound(next) || is_cjk(next))) {
152 words += 1;
153 }
154 if is_cjk(current) {
156 while i <= end && (is_punctuation(get(i + 1)) || is_word_bound(get(i + 1))) {
157 i += 1;
158 }
159 }
160 i += 1;
161 }
162
163 let minutes = words as f64 / words_per_minute as f64;
164 let time = (minutes * 60.0 * 1000.0).round() as i64;
165 let displayed = ((minutes * 100.0).round() / 100.0).ceil() as i64;
167
168 ReadingTime {
169 text: format!("{displayed} min read"),
170 minutes,
171 time,
172 words,
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn basic() {
182 let r = reading_time("Hello world, this is a test.");
183 assert_eq!(r.words, 6);
184 assert_eq!(r.minutes, 0.03);
185 assert_eq!(r.time, 1800);
186 assert_eq!(r.text, "1 min read");
187 }
188
189 #[test]
190 fn word_count_boundaries() {
191 assert_eq!(reading_time("word ".repeat(200).trim()).words, 200);
192 assert_eq!(reading_time("word ".repeat(200).trim()).text, "1 min read");
193 assert_eq!(
194 reading_time("w ".repeat(201).trim()).text,
195 "1 min read"
196 ); assert_eq!(
198 reading_time("w ".repeat(300).trim()).text,
199 "2 min read"
200 );
201 assert_eq!(reading_time(" leading and trailing ").words, 3);
202 assert_eq!(reading_time("a\nb\tc\rd").words, 4);
203 }
204
205 #[test]
206 fn cjk() {
207 assert_eq!(reading_time("中文字符测试一二三").words, 9);
208 assert_eq!(reading_time("hello 中文 world").words, 4);
209 assert_eq!(reading_time("ありがとう").words, 5); assert_eq!(reading_time("こんにちは世界").words, 7);
211 assert_eq!(reading_time("中。文,字!").words, 3);
213 }
214
215 #[test]
216 fn empty_and_wpm() {
217 let r = reading_time("");
218 assert_eq!(
219 (r.words, r.minutes, r.time, r.text.as_str()),
220 (0, 0.0, 0, "0 min read")
221 );
222 assert_eq!(reading_time_with("word", 0).minutes, 0.005); assert_eq!(reading_time_with("one two three", 100).minutes, 0.03);
224 }
225}