Skip to main content

textlane_html/helpers/
text.rs

1use std::borrow::Cow;
2
3use textlane_core::hast::{Element, Node};
4
5use crate::helpers::value::{find_value, is_truthy};
6
7// Elements recurse once per level; past the red zone the stack grows on the heap
8const STACK_RED_ZONE: usize = 64 * 1024;
9const STACK_GROWTH: usize = 1024 * 1024;
10
11#[derive(Clone, Copy, PartialEq, Eq)]
12enum Whitespace {
13    Normal,
14    Pre,
15    PreWrap,
16    Nowrap,
17}
18
19// What hast-util-to-text collects: text, or how many line endings may go between texts
20enum Piece<'n> {
21    Text(Cow<'n, str>),
22    Breaks(i64),
23}
24
25/// The text an element renders, as hast-util-to-text's `toText` reads it.
26pub fn read_text(element: &Element<'_>) -> String {
27    collect_text_as(element, Whitespace::Normal)
28}
29
30/// The text of an element as `toText` reads it with `whitespace: 'pre'`.
31#[cfg(feature = "katex")]
32pub(crate) fn read_preformatted_text(element: &Element<'_>) -> String {
33    collect_text_as(element, Whitespace::Pre)
34}
35
36fn collect_text_as(element: &Element<'_>, inherited: Whitespace) -> String {
37    let block = is_block_or_caption(element.tag_name);
38    let whitespace = infer_whitespace(element, inherited);
39    let mut pieces = vec![];
40    collect_children(&element.children, whitespace, block, block, &mut pieces);
41    join_pieces(&pieces)
42}
43
44fn join_pieces(pieces: &[Piece<'_>]) -> String {
45    let mut result = String::new();
46    let mut count: Option<i64> = None;
47    for piece in pieces {
48        match piece {
49            Piece::Breaks(value) => {
50                if let Some(current) = count
51                    && *value > current
52                {
53                    count = Some(*value);
54                }
55            }
56            Piece::Text(value) if !value.is_empty() => {
57                if let Some(current) = count
58                    && current > -1
59                {
60                    match current {
61                        0 => result.push(' '),
62                        _ => (0..current).for_each(|_| result.push('\n')),
63                    }
64                }
65                count = Some(-1);
66                result.push_str(value);
67            }
68            Piece::Text(_) => {}
69        }
70    }
71    result
72}
73
74// The children of a node; `before` and `after` are whether a break sits before the first
75// and after the last
76fn collect_children<'n>(
77    children: &'n [Node<'_>],
78    whitespace: Whitespace,
79    before: bool,
80    after: bool,
81    pieces: &mut Vec<Piece<'n>>,
82) {
83    // Whether a row or cell has a sibling of its kind after it, as `findAfter` checks
84    let mut later_row = vec![false; children.len()];
85    let mut later_cell = vec![false; children.len()];
86    let (mut row, mut cell) = (false, false);
87    for (index, child) in children.iter().enumerate().rev() {
88        later_row[index] = row;
89        later_cell[index] = cell;
90        if let Node::Element(element) = child {
91            row |= element.tag_name == "tr";
92            cell |= is_cell(element.tag_name);
93        }
94    }
95    for (index, child) in children.iter().enumerate() {
96        let break_before = if index == 0 { before } else { false };
97        let break_after = match children.get(index + 1) {
98            Some(next) => is_element(next, "br"),
99            None => after,
100        };
101        match child {
102            Node::Element(element) => {
103                let later = (later_row[index], later_cell[index]);
104                stacker::maybe_grow(STACK_RED_ZONE, STACK_GROWTH, || {
105                    collect_element(element, later, whitespace, pieces);
106                });
107            }
108            Node::Text(text) if whitespace == Whitespace::Normal => {
109                collect_text(text.value, break_before, break_after, pieces);
110            }
111            Node::Text(text) => pieces.push(Piece::Text(Cow::Borrowed(text.value))),
112            _ => {}
113        }
114    }
115}
116
117fn collect_element<'n>(
118    element: &'n Element<'_>,
119    (later_row, later_cell): (bool, bool),
120    whitespace: Whitespace,
121    pieces: &mut Vec<Piece<'n>>,
122) {
123    let whitespace = infer_whitespace(element, whitespace);
124    if is_not_rendered(element) {
125        return;
126    }
127    let tag_name = element.tag_name;
128    let (prefix, suffix) = if tag_name == "br" || (tag_name == "tr" && later_row) {
129        (None, Some(Piece::Text(Cow::Borrowed("\n"))))
130    } else if tag_name == "p" {
131        (Some(2), Some(Piece::Breaks(2)))
132    } else if is_block_or_caption(tag_name) {
133        (Some(1), Some(Piece::Breaks(1)))
134    } else {
135        (None, None)
136    };
137    if let Some(prefix) = prefix {
138        pieces.push(Piece::Breaks(prefix));
139    }
140    collect_children(
141        &element.children,
142        whitespace,
143        prefix.is_some(),
144        suffix.is_some(),
145        pieces,
146    );
147    if is_cell(tag_name) && later_cell {
148        pieces.push(Piece::Text(Cow::Borrowed("\t")));
149    }
150    if let Some(suffix) = suffix {
151        pieces.push(suffix);
152    }
153}
154
155fn collect_text(value: &str, before: bool, after: bool, pieces: &mut Vec<Piece<'_>>) {
156    let mut lines = vec![];
157    let mut start = 0;
158    loop {
159        let end = value[start..]
160            .find('\n')
161            .map_or(value.len(), |end| start + end);
162        let line: String = value[start..end]
163            .chars()
164            .filter(|character| !is_bidi_control(*character))
165            .collect();
166        lines.push(trim_and_collapse(
167            &line,
168            if start == 0 { before } else { true },
169            if end == value.len() { after } else { true },
170        ));
171        if end == value.len() {
172            break;
173        }
174        start = end + 1;
175    }
176    let mut join = false;
177    let last = lines.len() - 1;
178    for index in 0..lines.len() {
179        let zero_width = lines[index].ends_with('\u{200B}')
180            || (index < last && lines[index + 1].starts_with('\u{200B}'));
181        if zero_width {
182            pieces.push(Piece::Text(Cow::Owned(std::mem::take(&mut lines[index]))));
183            join = false;
184        } else if !lines[index].is_empty() {
185            if join {
186                pieces.push(Piece::Breaks(0));
187            }
188            pieces.push(Piece::Text(Cow::Owned(std::mem::take(&mut lines[index]))));
189            join = true;
190        } else if index == 0 || index == last {
191            pieces.push(Piece::Breaks(0));
192        }
193    }
194}
195
196// Collapse runs of spaces and tabs into one space, dropping them at a break
197fn trim_and_collapse(value: &str, before: bool, after: bool) -> String {
198    let bytes = value.as_bytes();
199    let mut result: Vec<&str> = vec![];
200    let mut start = 0;
201    let mut end = None;
202    while start < bytes.len() {
203        let found = bytes[start..]
204            .iter()
205            .position(|byte| matches!(byte, b'\t' | b' '))
206            .map(|offset| start + offset);
207        let current = found.unwrap_or(bytes.len());
208        end = Some(current);
209        if start == 0 && current == 0 && found.is_some() && !before {
210            result.push("");
211        }
212        if start != current {
213            result.push(&value[start..current]);
214        }
215        start = match found {
216            Some(found) => {
217                found
218                    + bytes[found..]
219                        .iter()
220                        .take_while(|byte| matches!(byte, b'\t' | b' '))
221                        .count()
222            }
223            None => current,
224        };
225    }
226    if Some(start) != end && !after {
227        result.push("");
228    }
229    result.join(" ")
230}
231
232fn infer_whitespace(element: &Element<'_>, inherited: Whitespace) -> Whitespace {
233    match element.tag_name {
234        "listing" | "plaintext" | "xmp" => Whitespace::Pre,
235        "nobr" => Whitespace::Nowrap,
236        "pre" if is_truthy(find_value(element, "wrap")) => Whitespace::PreWrap,
237        "pre" => Whitespace::Pre,
238        "td" | "th" if is_truthy(find_value(element, "noWrap")) => Whitespace::Nowrap,
239        "textarea" => Whitespace::PreWrap,
240        _ => inherited,
241    }
242}
243
244fn is_not_rendered(element: &Element<'_>) -> bool {
245    matches!(
246        element.tag_name,
247        "datalist"
248            | "head"
249            | "noembed"
250            | "noframes"
251            | "noscript"
252            | "rp"
253            | "script"
254            | "style"
255            | "template"
256            | "title"
257    ) || is_truthy(find_value(element, "hidden"))
258        || (element.tag_name == "dialog" && !is_truthy(find_value(element, "open")))
259}
260
261// hast-util-to-text's list misspells `form` as `'form,'`, so a form is not a block
262fn is_block_or_caption(tag_name: &str) -> bool {
263    matches!(
264        tag_name,
265        "address"
266            | "article"
267            | "aside"
268            | "blockquote"
269            | "body"
270            | "caption"
271            | "center"
272            | "dd"
273            | "dialog"
274            | "dir"
275            | "dl"
276            | "dt"
277            | "div"
278            | "figure"
279            | "figcaption"
280            | "footer"
281            | "h1"
282            | "h2"
283            | "h3"
284            | "h4"
285            | "h5"
286            | "h6"
287            | "header"
288            | "hgroup"
289            | "hr"
290            | "html"
291            | "legend"
292            | "li"
293            | "listing"
294            | "main"
295            | "menu"
296            | "nav"
297            | "ol"
298            | "p"
299            | "plaintext"
300            | "pre"
301            | "section"
302            | "ul"
303            | "xmp"
304    )
305}
306
307fn is_cell(tag_name: &str) -> bool {
308    tag_name == "td" || tag_name == "th"
309}
310
311fn is_element(node: &Node<'_>, tag_name: &str) -> bool {
312    matches!(node, Node::Element(element) if element.tag_name == tag_name)
313}
314
315fn is_bidi_control(character: char) -> bool {
316    matches!(
317        character,
318        '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
319    )
320}