#![allow(clippy::collapsible_if)]
use crate::constants::regexps;
use crate::document::Document;
use crate::dom::{Dom, NodeId, Tag, build_match_string};
use crate::options::ReaderableOptions;
use crate::scoring::is_probably_visible;
pub fn is_probably_readerable(html: &str, options: Option<ReaderableOptions>) -> bool {
Document::new(html).is_probably_readerable(options)
}
#[derive(Clone, Copy, Default)]
struct TextStats {
total_length: usize,
leading_whitespace: usize,
trailing_whitespace: usize,
has_non_whitespace: bool,
}
#[derive(Clone, Copy, Default)]
struct ReaderableNodeState {
text: TextStats,
under_list_item: bool,
seen: bool,
}
struct ReaderableState {
entries: Vec<ReaderableNodeState>,
}
impl ReaderableState {
fn new(dom: &Dom) -> Self {
let mut entries = vec![ReaderableNodeState::default(); dom.len()];
let mut nodes = Vec::with_capacity(dom.len());
nodes.push(dom.root());
nodes.extend(dom.descendants(dom.root()));
for &id in nodes.iter().rev() {
let mut text = dom.text_node(id).map(text_stats).unwrap_or_default();
for child in dom.children(id) {
append_text_stats(&mut text, entries[child.index()].text);
}
entries[id.index()].text = text;
}
for &id in &nodes {
let under_list_item = dom.parent(id).is_some_and(|parent| {
dom.tag(parent) == Some(Tag::Li) || entries[parent.index()].under_list_item
});
entries[id.index()].under_list_item = under_list_item;
}
Self { entries }
}
#[inline]
fn text_length(&self, id: NodeId) -> usize {
let text = self.entries[id.index()].text;
text.total_length
.saturating_sub(text.leading_whitespace)
.saturating_sub(text.trailing_whitespace)
}
#[inline]
fn mark_seen(&mut self, id: NodeId) -> bool {
let seen = &mut self.entries[id.index()].seen;
if *seen {
false
} else {
*seen = true;
true
}
}
}
fn text_stats(text: &str) -> TextStats {
let mut stats = TextStats::default();
if text.is_ascii() {
for &byte in text.as_bytes() {
stats.total_length += 1;
if byte.is_ascii_whitespace() {
if !stats.has_non_whitespace {
stats.leading_whitespace += 1;
}
stats.trailing_whitespace += 1;
} else {
stats.has_non_whitespace = true;
stats.trailing_whitespace = 0;
}
}
} else {
for c in text.chars() {
let length = c.len_utf16();
stats.total_length += length;
if c.is_whitespace() {
if !stats.has_non_whitespace {
stats.leading_whitespace += length;
}
stats.trailing_whitespace += length;
} else {
stats.has_non_whitespace = true;
stats.trailing_whitespace = 0;
}
}
}
stats
}
fn append_text_stats(a: &mut TextStats, b: TextStats) {
if !b.has_non_whitespace {
a.total_length += b.total_length;
if !a.has_non_whitespace {
a.leading_whitespace += b.total_length;
}
a.trailing_whitespace += b.total_length;
return;
}
if !a.has_non_whitespace {
a.leading_whitespace += b.leading_whitespace;
}
a.total_length += b.total_length;
a.trailing_whitespace = b.trailing_whitespace;
a.has_non_whitespace = true;
}
pub(crate) fn is_probably_readerable_doc(dom: &Dom, options: Option<ReaderableOptions>) -> bool {
let options = options.unwrap_or_default();
let mut state = ReaderableState::new(dom);
let mut score = 0.0;
let mut buf = String::with_capacity(128);
for id in dom.descendants(dom.root()) {
if !dom.is_element(id) {
continue;
}
match dom.tag(id) {
Some(Tag::P | Tag::Pre | Tag::Article) => {
if score_node(dom, id, &options, &mut score, &mut buf, &state) {
return true;
}
}
Some(Tag::Br) => {
if let Some(p) = dom.parent(id)
&& dom.tag(p) == Some(Tag::Div)
&& state.mark_seen(p)
&& score_node(dom, p, &options, &mut score, &mut buf, &state)
{
return true;
}
}
_ => {}
}
}
false
}
fn score_node(
dom: &Dom,
id: NodeId,
o: &ReaderableOptions,
score: &mut f64,
buf: &mut String,
state: &ReaderableState,
) -> bool {
if !is_probably_visible(dom, id) {
return false;
}
build_match_string(dom, id, buf);
let m = regexps::CANDIDATE_FILTER_SET.matches(buf);
if m.matched(0) && !m.matched(1) {
return false;
}
if dom.tag(id) == Some(Tag::P) && state.entries[id.index()].under_list_item {
return false;
}
let len = state.text_length(id);
if len < o.min_content_length {
return false;
}
*score += ((len - o.min_content_length) as f64).sqrt();
*score > o.min_score
}
#[cfg(test)]
mod tests {
use super::*;
fn reference_trimmed_text_utf16_len(dom: &Dom, root: NodeId) -> usize {
let mut count = 0;
let mut pending_whitespace = 0;
for id in std::iter::once(root).chain(dom.descendants(root)) {
let Some(text) = dom.text_node(id) else {
continue;
};
if text.is_ascii() {
for &byte in text.as_bytes() {
if byte.is_ascii_whitespace() {
if count > 0 {
pending_whitespace += 1;
}
} else {
count += pending_whitespace + 1;
pending_whitespace = 0;
}
}
} else {
for c in text.chars() {
if c.is_whitespace() {
if count > 0 {
pending_whitespace += c.len_utf16();
}
} else {
count += pending_whitespace + c.len_utf16();
pending_whitespace = 0;
}
}
}
}
count
}
#[test]
fn counts_surrogate_pairs_as_two_readerable_characters() {
let options = ReaderableOptions::new()
.min_content_length(2)
.min_score(-0.1);
assert!(is_probably_readerable("<p>😀</p>", Some(options)));
}
#[test]
fn caches_trimmed_text_across_nested_candidates() {
let dom = Dom::parse_document("<article> <div>one</div> </article>").unwrap();
let state = ReaderableState::new(&dom);
let article = dom
.descendants(dom.root())
.find(|&id| dom.tag(id) == Some(Tag::Article))
.unwrap();
assert_eq!(state.text_length(article), 3);
}
#[test]
fn aggregates_whitespace_across_sibling_text_nodes() {
let dom = Dom::parse_document(
"<article> \n<div>\u{2003}one</div>\t<span>two\u{a0}</span> </article>",
)
.unwrap();
let state = ReaderableState::new(&dom);
let article = dom
.descendants(dom.root())
.find(|&id| dom.tag(id) == Some(Tag::Article))
.unwrap();
assert_eq!(state.text_length(article), 7);
}
#[test]
fn cached_text_lengths_match_the_subtree_scan() {
for html in [
"<article> leading <div>nested</div><!-- comment --> trailing</article>",
"<article>one<div>two</article> three",
"<article>\u{2003}one<span>😀</span>\u{a0}</article>",
] {
let dom = Dom::parse_document(html).unwrap();
let state = ReaderableState::new(&dom);
let ids: Vec<_> = std::iter::once(dom.root())
.chain(dom.descendants(dom.root()))
.collect();
for id in ids {
assert_eq!(
state.text_length(id),
reference_trimmed_text_utf16_len(&dom, id),
"mismatch for {html:?} at {id:?}",
);
}
}
}
#[test]
fn excludes_paragraphs_under_list_items_without_an_ancestor_scan() {
let dom = Dom::parse_document("<li><div><p>text</p></div></li>").unwrap();
let state = ReaderableState::new(&dom);
let paragraph = dom
.descendants(dom.root())
.find(|&id| dom.tag(id) == Some(Tag::P))
.unwrap();
assert!(state.entries[paragraph.index()].under_list_item);
}
}