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            if let Some(stripped_local_name) = local_name.strip_prefix('h') {
55                match stripped_local_name.parse::<u8>() {
56                    Ok(level) if (1..=6).contains(&level) => HeadingKind::Header {
57                        level,
58                        text: String::new(),
59                    },
60                    _ => return None,
61                }
62            } else {
63                return None;
64            }
65        },
66        6 if local_name.eq("hgroup") => HeadingKind::Group(Vec::with_capacity(2)),
67        _ => return None,
68    };
69
70    match &mut kind {
71        HeadingKind::Header {
72            text, ..
73        } => {
74            *text = create_text(element, depth, max_depth);
75        },
76        HeadingKind::Group(headings) => {
77            // This keeps the crate's older hgroup behavior by joining heading children instead of choosing only one heading.
78            for child in element.child_elements() {
79                if let Some(heading) = create_heading(child, depth + 1, max_depth) {
80                    headings.push(heading);
81                }
82            }
83
84            if headings.is_empty() {
85                return None;
86            }
87        },
88    }
89
90    Some(Heading(kind))
91}
92
93#[inline]
94pub(crate) fn create_text(element: ElementRef<'_>, depth: usize, max_depth: usize) -> String {
95    if depth > max_depth {
96        return String::new();
97    }
98
99    let mut raw_text = String::new();
100    let mut stack = Vec::new();
101
102    // The explicit stack keeps text extraction iterative and preserves the same order as a recursive depth-first walk.
103    for child in element.children().rev() {
104        stack.push((depth + 1, child));
105    }
106
107    while let Some((node_depth, node)) = stack.pop() {
108        if node_depth > max_depth {
109            continue;
110        }
111
112        match node.value() {
113            Node::Text(text) => raw_text.push_str(text),
114            Node::Document | Node::Fragment | Node::Element(_) => {
115                for child in node.children().rev() {
116                    stack.push((node_depth + 1, child));
117                }
118            },
119            _ => {},
120        }
121    }
122
123    collapse_whitespace(&raw_text)
124}
125
126impl From<Heading> for String {
127    #[inline]
128    fn from(heading: Heading) -> String {
129        match heading.0 {
130            HeadingKind::Header {
131                text, ..
132            } => text,
133            HeadingKind::Group(headings) => {
134                let mut iter = headings.into_iter();
135
136                let mut text: String = iter.next().unwrap().into();
137
138                for heading in iter {
139                    text.push_str(" — ");
140
141                    let t: String = heading.into();
142                    text.push_str(&t);
143                }
144
145                text
146            },
147        }
148    }
149}
150
151fn collapse_whitespace(raw_text: &str) -> String {
152    let mut text = String::new();
153
154    for word in raw_text.split_whitespace() {
155        if !text.is_empty() {
156            text.push(' ');
157        }
158
159        text.push_str(word);
160    }
161
162    text
163}