#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/reading-time/0.1.0")]
#![allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
// Reading-time minutes are simple ratios; exact comparison in tests is intentional.
clippy::float_cmp
)]
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, PartialEq)]
pub struct ReadingTime {
pub text: String,
pub minutes: f64,
pub time: i64,
pub words: usize,
}
fn in_ranges(code: u32, ranges: &[(u32, u32)]) -> bool {
ranges
.iter()
.any(|&(low, high)| low <= code && code <= high)
}
fn is_cjk(unit: Option<u16>) -> bool {
match unit {
Some(u) => in_ranges(
u32::from(u),
&[
(0x3040, 0x309F), (0x4E00, 0x9FFF), (0xAC00, 0xD7A3), (0x20000, 0x2EBE0), ],
),
None => false,
}
}
fn is_word_bound(unit: Option<u16>) -> bool {
matches!(unit, Some(0x20 | 0x0A | 0x0D | 0x09))
}
fn is_punctuation(unit: Option<u16>) -> bool {
match unit {
Some(u) => in_ranges(
u32::from(u),
&[
(0x21, 0x2F),
(0x3A, 0x40),
(0x5B, 0x60),
(0x7B, 0x7E),
(0x3000, 0x303F), (0xFF00, 0xFFEF), ],
),
None => false,
}
}
#[must_use]
pub fn reading_time(text: &str) -> ReadingTime {
reading_time_with(text, 200)
}
#[must_use]
pub fn reading_time_with(text: &str, words_per_minute: usize) -> ReadingTime {
let words_per_minute = if words_per_minute == 0 {
200
} else {
words_per_minute
};
let units: Vec<u16> = text.encode_utf16().collect();
let length = units.len();
let get = |i: isize| -> Option<u16> {
usize::try_from(i).ok().and_then(|i| {
if i == length {
Some(0x0A) } else {
units.get(i).copied()
}
})
};
let mut start = 0isize;
while (start as usize) < length && is_word_bound(get(start)) {
start += 1;
}
let mut end = length as isize - 1;
while end >= 0 && is_word_bound(get(end)) {
end -= 1;
}
let mut words = 0usize;
let mut i = start;
while i <= end {
let current = get(i);
let next = get(i + 1);
if is_cjk(current) || (!is_word_bound(current) && (is_word_bound(next) || is_cjk(next))) {
words += 1;
}
if is_cjk(current) {
while i <= end && (is_punctuation(get(i + 1)) || is_word_bound(get(i + 1))) {
i += 1;
}
}
i += 1;
}
let minutes = words as f64 / words_per_minute as f64;
let time = (minutes * 60.0 * 1000.0).round() as i64;
let displayed = ((minutes * 100.0).round() / 100.0).ceil() as i64;
ReadingTime {
text: format!("{displayed} min read"),
minutes,
time,
words,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic() {
let r = reading_time("Hello world, this is a test.");
assert_eq!(r.words, 6);
assert_eq!(r.minutes, 0.03);
assert_eq!(r.time, 1800);
assert_eq!(r.text, "1 min read");
}
#[test]
fn word_count_boundaries() {
assert_eq!(reading_time("word ".repeat(200).trim()).words, 200);
assert_eq!(reading_time("word ".repeat(200).trim()).text, "1 min read");
assert_eq!(
reading_time("w ".repeat(201).trim()).text,
"1 min read"
); assert_eq!(
reading_time("w ".repeat(300).trim()).text,
"2 min read"
);
assert_eq!(reading_time(" leading and trailing ").words, 3);
assert_eq!(reading_time("a\nb\tc\rd").words, 4);
}
#[test]
fn cjk() {
assert_eq!(reading_time("中文字符测试一二三").words, 9);
assert_eq!(reading_time("hello 中文 world").words, 4);
assert_eq!(reading_time("ありがとう").words, 5); assert_eq!(reading_time("こんにちは世界").words, 7);
assert_eq!(reading_time("中。文,字!").words, 3);
}
#[test]
fn empty_and_wpm() {
let r = reading_time("");
assert_eq!(
(r.words, r.minutes, r.time, r.text.as_str()),
(0, 0.0, 0, "0 min read")
);
assert_eq!(reading_time_with("word", 0).minutes, 0.005); assert_eq!(reading_time_with("one two three", 100).minutes, 0.03);
}
}