Skip to main content

reading_time/
lib.rs

1//! # reading-time — estimate how long a text takes to read
2//!
3//! Count the words in a text and estimate the reading time, with sensible handling of CJK
4//! characters (each counts as a word, and trailing punctuation is absorbed). A faithful Rust
5//! port of the widely-used [`reading-time`](https://www.npmjs.com/package/reading-time) npm
6//! package.
7//!
8//! ```
9//! use reading_time::reading_time;
10//!
11//! let result = reading_time("Hello world, this is a test.");
12//! assert_eq!(result.words, 6);
13//! assert_eq!(result.text, "1 min read");
14//! ```
15//!
16//! Use [`reading_time_with`] to set the words-per-minute rate (default 200):
17//!
18//! ```
19//! use reading_time::reading_time_with;
20//! assert_eq!(reading_time_with(&"word ".repeat(400), 200).minutes, 2.0);
21//! ```
22//!
23//! **Zero dependencies.** (Uses `std` for floating-point rounding.)
24
25#![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    // Reading-time minutes are simple ratios; exact comparison in tests is intentional.
33    clippy::float_cmp
34)]
35
36// Compile-test the README's examples as part of `cargo test`.
37#[cfg(doctest)]
38#[doc = include_str!("../README.md")]
39struct ReadmeDoctests;
40
41/// The result of a reading-time estimate.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ReadingTime {
44    /// A human-readable summary, e.g. `"3 min read"`.
45    pub text: String,
46    /// The estimated reading time in minutes (fractional).
47    pub minutes: f64,
48    /// The estimated reading time in milliseconds (rounded).
49    pub time: i64,
50    /// The number of words counted.
51    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
60/// A CJK character (each is counted as a standalone word).
61fn is_cjk(unit: Option<u16>) -> bool {
62    match unit {
63        Some(u) => in_ranges(
64            u32::from(u),
65            &[
66                (0x3040, 0x309F),   // Hiragana
67                (0x4E00, 0x9FFF),   // CJK Unified Ideographs
68                (0xAC00, 0xD7A3),   // Hangul
69                (0x20000, 0x2EBE0), // CJK extensions (never matches a 16-bit unit; kept for parity)
70            ],
71        ),
72        None => false,
73    }
74}
75
76/// The default ASCII word boundary set: space, newline, carriage return, tab.
77fn is_word_bound(unit: Option<u16>) -> bool {
78    matches!(unit, Some(0x20 | 0x0A | 0x0D | 0x09))
79}
80
81/// Punctuation absorbed after a CJK character.
82fn 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), // CJK symbols and punctuation
92                (0xFF00, 0xFFEF), // full-width forms
93            ],
94        ),
95        None => false,
96    }
97}
98
99/// Estimate the reading time of `text` at 200 words per minute.
100///
101/// ```
102/// # use reading_time::reading_time;
103/// assert_eq!(reading_time("one two three").words, 3);
104/// ```
105#[must_use]
106pub fn reading_time(text: &str) -> ReadingTime {
107    reading_time_with(text, 200)
108}
109
110/// Estimate the reading time of `text` at `words_per_minute` (a value of `0` falls back to
111/// the default of 200, matching the reference).
112#[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    // Work on UTF-16 code units (as the reference indexes the string), with a trailing
121    // newline appended so the final word is detected.
122    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) // the appended `\n`
128            } else {
129                units.get(i).copied()
130            }
131        })
132    };
133
134    // Trim leading and trailing word boundaries.
135    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        // A CJK character is always a word; otherwise a non-boundary followed by a boundary
150        // (or a CJK character) ends a word.
151        if is_cjk(current) || (!is_word_bound(current) && (is_word_bound(next) || is_cjk(next))) {
152            words += 1;
153        }
154        // After a CJK character, absorb following punctuation and boundaries.
155        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    // `Math.ceil(minutes.toFixed(2))`: round to two decimals (ties away from zero), then ceil.
166    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        ); // 1.005 -> 1
197        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); // hiragana
210        assert_eq!(reading_time("こんにちは世界").words, 7);
211        // CJK followed by punctuation: the punctuation is absorbed.
212        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); // 0 -> default 200
223        assert_eq!(reading_time_with("one two three", 100).minutes, 0.03);
224    }
225}