Skip to main content

html_outliner/
heading.rs

1use scraper::{ElementRef, Node};
2
3/// A heading found in the parsed HTML tree.
4#[derive(Debug, Clone)]
5pub struct Heading(HeadingKind);
6
7/// The internal representation of a heading, kept private so a `Heading` cannot be constructed outside this crate.
8#[derive(Debug, Clone)]
9enum HeadingKind {
10    /// A normal `h1` through `h6` heading.
11    Header { level: u8, text: String },
12    /// A compatibility group created from heading children inside `hgroup`.
13    Group(Vec<Heading>),
14}
15
16impl Heading {
17    /// Returns the heading level at the end of this heading, following the last child heading when this is a group.
18    #[inline]
19    pub fn get_end_level(&self) -> u8 {
20        match &self.0 {
21            HeadingKind::Header {
22                level, ..
23            } => *level,
24            HeadingKind::Group(headings) => headings[headings.len() - 1].get_end_level(),
25        }
26    }
27
28    /// Returns the heading level at the start of this heading, following the first child heading when this is a group.
29    #[inline]
30    pub fn get_start_level(&self) -> u8 {
31        match &self.0 {
32            HeadingKind::Header {
33                level, ..
34            } => *level,
35            HeadingKind::Group(headings) => headings[0].get_start_level(),
36        }
37    }
38}
39
40pub(crate) fn create_heading(
41    element: ElementRef<'_>,
42    depth: usize,
43    max_depth: usize,
44) -> Option<Heading> {
45    if depth > max_depth {
46        return None;
47    }
48
49    let local_name = element.value().name();
50    let local_name_length = local_name.len();
51
52    let mut kind = match local_name_length {
53        2 => {
54            let stripped_local_name = local_name.strip_prefix('h')?;
55
56            match stripped_local_name.parse::<u8>() {
57                Ok(level) if (1..=6).contains(&level) => HeadingKind::Header {
58                    level,
59                    text: String::new(),
60                },
61                _ => return None,
62            }
63        },
64        6 if local_name.eq("hgroup") => HeadingKind::Group(Vec::with_capacity(2)),
65        _ => return None,
66    };
67
68    match &mut kind {
69        HeadingKind::Header {
70            text, ..
71        } => {
72            *text = create_text(element, depth, max_depth);
73        },
74        HeadingKind::Group(headings) => {
75            // This keeps the crate's older hgroup behavior by joining heading children instead of choosing only one heading.
76            for child in element.child_elements() {
77                if let Some(heading) = create_heading(child, depth + 1, max_depth) {
78                    headings.push(heading);
79                }
80            }
81
82            if headings.is_empty() {
83                return None;
84            }
85        },
86    }
87
88    Some(Heading(kind))
89}
90
91#[inline]
92pub(crate) fn create_text(element: ElementRef<'_>, depth: usize, max_depth: usize) -> String {
93    if depth > max_depth {
94        return String::new();
95    }
96
97    let mut raw_text = String::new();
98    let mut stack = Vec::new();
99
100    // The explicit stack keeps text extraction iterative and preserves the same order as a recursive depth-first walk.
101    for child in element.children().rev() {
102        stack.push((depth + 1, child));
103    }
104
105    while let Some((node_depth, node)) = stack.pop() {
106        if node_depth > max_depth {
107            continue;
108        }
109
110        match node.value() {
111            Node::Text(text) => raw_text.push_str(text),
112            Node::Document | Node::Fragment | Node::Element(_) => {
113                for child in node.children().rev() {
114                    stack.push((node_depth + 1, child));
115                }
116            },
117            _ => {},
118        }
119    }
120
121    collapse_whitespace(&raw_text)
122}
123
124impl From<Heading> for String {
125    #[inline]
126    fn from(heading: Heading) -> String {
127        match heading.0 {
128            HeadingKind::Header {
129                text, ..
130            } => text,
131            HeadingKind::Group(headings) => {
132                let mut iter = headings.into_iter();
133
134                let mut text: String = iter.next().unwrap().into();
135
136                for heading in iter {
137                    text.push_str(" — ");
138
139                    let t: String = heading.into();
140                    text.push_str(&t);
141                }
142
143                text
144            },
145        }
146    }
147}
148
149fn collapse_whitespace(raw_text: &str) -> String {
150    let mut text = String::new();
151
152    for word in raw_text.split_whitespace() {
153        if !text.is_empty() {
154            text.push(' ');
155        }
156
157        text.push_str(word);
158    }
159
160    text
161}