1use std::borrow::Cow;
2
3use textlane_core::hast::{Element, Node};
4
5use crate::helpers::value::{find_value, is_truthy};
6
7const 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
19enum Piece<'n> {
21 Text(Cow<'n, str>),
22 Breaks(i64),
23}
24
25pub fn read_text(element: &Element<'_>) -> String {
27 collect_text_as(element, Whitespace::Normal)
28}
29
30#[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
74fn collect_children<'n>(
77 children: &'n [Node<'_>],
78 whitespace: Whitespace,
79 before: bool,
80 after: bool,
81 pieces: &mut Vec<Piece<'n>>,
82) {
83 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
196fn 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
261fn 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}