1use ego_tree::NodeRef;
30use quarb_text::{Block, Container, TextModel};
31use scraper::{ElementRef, Html, Node as DomNode};
32
33pub fn parse(html: &str) -> TextModel {
35 TextModel::build(blocks(html))
36}
37
38pub fn blocks(html: &str) -> Vec<Block> {
41 let document = Html::parse_document(html);
42 let mut out = Vec::new();
43 let mut run = String::new();
44
45 let mut stack: Vec<Work> = vec![Work::El(document.root_element())];
49 while let Some(work) = stack.pop() {
50 match work {
51 Work::Text(text) => run.push_str(&text),
52 Work::Flush => flush(&mut run, &mut out),
53 Work::Close { hypograph } => {
54 flush(&mut run, &mut out);
55 out.push(Block::Close { hypograph });
56 }
57 Work::El(el) => element(el, &mut run, &mut out, &mut stack),
58 }
59 }
60 flush(&mut run, &mut out);
61 out
62}
63
64enum Work<'a> {
65 El(ElementRef<'a>),
66 Text(String),
67 Flush,
68 Close { hypograph: Option<String> },
69}
70
71const SKIP: &[&str] = &[
73 "head", "script", "style", "noscript", "template", "nav", "header", "footer", "aside", "form",
74 "button", "select", "input", "textarea", "label", "iframe", "svg", "math", "img", "picture",
75 "video", "audio", "canvas", "object", "map", "colgroup", "col",
76];
77
78const CHROME_ROLES: &[&str] = &[
81 "navigation",
82 "banner",
83 "contentinfo",
84 "search",
85 "complementary",
86 "menu",
87 "menubar",
88 "toolbar",
89 "presentation",
90 "none",
91];
92
93fn aria_chrome(el: ElementRef) -> bool {
96 if el.value().attr("aria-hidden") == Some("true") {
97 return true;
98 }
99 el.value()
100 .attr("role")
101 .is_some_and(|r| CHROME_ROLES.contains(&r))
102}
103
104const TRANSPARENT: &[&str] = &[
108 "html", "body", "div", "section", "article", "main", "hgroup", "details", "dialog", "address",
109 "fieldset", "center", "dl", "tbody", "thead", "tfoot", "tr", "td", "th",
110];
111
112const P_LIKE: &[&str] = &["p", "figcaption", "dt", "dd", "summary", "legend", "caption"];
114
115fn element<'a>(
116 el: ElementRef<'a>,
117 run: &mut String,
118 out: &mut Vec<Block>,
119 stack: &mut Vec<Work<'a>>,
120) {
121 let tag = el.value().name();
122 match tag {
123 _ if SKIP.contains(&tag) || aria_chrome(el) => {}
124 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
125 flush(run, out);
126 out.push(Block::Heading {
127 level: tag[1..].parse().unwrap(),
128 lemma: text_of(el),
129 });
130 }
131 _ if P_LIKE.contains(&tag) => {
132 flush(run, out);
133 out.push(Block::Paragraph { text: text_of(el) });
134 }
135 "blockquote" => {
136 flush(run, out);
137 out.push(Block::Open {
138 kind: Container::Blockquote,
139 lemma: None,
140 });
141 let (children, hypograph) = quote_content(el);
142 stack.push(Work::Close { hypograph });
143 push_children(children, stack);
144 }
145 "figure" => {
146 flush(run, out);
147 let quote = child_by_tag(el, "blockquote");
148 let caption = child_by_tag(el, "figcaption");
149 match (quote, caption) {
150 (Some(quote), Some(caption)) => {
151 out.push(Block::Open {
154 kind: Container::Blockquote,
155 lemma: None,
156 });
157 let (children, inner) = quote_content(quote);
158 stack.push(Work::Close {
159 hypograph: inner.or(Some(text_of(caption))),
160 });
161 push_children(children, stack);
162 }
163 _ => {
164 stack.push(Work::Flush);
165 push_children(el.children().collect(), stack);
166 }
167 }
168 }
169 "ul" => open_list(el, Container::UnorderedList, stack, run, out),
170 "ol" => {
171 let start = el
172 .value()
173 .attr("start")
174 .and_then(|s| s.parse().ok())
175 .unwrap_or(1);
176 open_list(el, Container::OrderedList { start }, stack, run, out);
177 }
178 "li" => {
179 flush(run, out);
180 out.push(Block::Open {
181 kind: Container::Item,
182 lemma: None,
183 });
184 stack.push(Work::Close { hypograph: None });
185 push_children(el.children().collect(), stack);
186 }
187 "pre" => {
188 flush(run, out);
189 out.push(Block::Verbatim {
190 lang: verbatim_lang(el),
191 text: text_of_raw(el),
192 });
193 }
194 "table" => {
195 flush(run, out);
196 out.push(table_block(el));
197 }
198 "hr" => flush(run, out),
199 "br" => run.push(' '),
200 _ if TRANSPARENT.contains(&tag) => {
201 stack.push(Work::Flush);
202 push_children(el.children().collect(), stack);
203 }
204 _ => run.push_str(&text_of_raw(el)),
206 }
207}
208
209fn push_children<'a>(children: Vec<NodeRef<'a, DomNode>>, stack: &mut Vec<Work<'a>>) {
212 for child in children.into_iter().rev() {
213 if let Some(el) = ElementRef::wrap(child) {
214 stack.push(Work::El(el));
215 } else if let DomNode::Text(text) = child.value() {
216 stack.push(Work::Text(text.to_string()));
217 }
218 }
219}
220
221fn child_by_tag<'a>(el: ElementRef<'a>, tag: &str) -> Option<ElementRef<'a>> {
223 el.children()
224 .filter_map(ElementRef::wrap)
225 .find(|c| c.value().name() == tag)
226}
227
228fn quote_content<'a>(el: ElementRef<'a>) -> (Vec<NodeRef<'a, DomNode>>, Option<String>) {
231 let mut hypograph = None;
232 let mut attribution_id = None;
233 for child in el.children() {
234 if let Some(c) = ElementRef::wrap(child)
235 && matches!(c.value().name(), "cite" | "footer") {
236 hypograph = Some(text_of(c));
237 attribution_id = Some(child.id());
238 }
239 }
240 let children = el
241 .children()
242 .filter(|c| Some(c.id()) != attribution_id)
243 .collect();
244 (children, hypograph)
245}
246
247fn open_list<'a>(
248 el: ElementRef<'a>,
249 kind: Container,
250 stack: &mut Vec<Work<'a>>,
251 run: &mut String,
252 out: &mut Vec<Block>,
253) {
254 flush(run, out);
255 out.push(Block::Open { kind, lemma: None });
256 stack.push(Work::Close { hypograph: None });
257 push_children(el.children().collect(), stack);
258}
259
260fn verbatim_lang(el: ElementRef) -> Option<String> {
263 let mut candidates = vec![el];
264 candidates.extend(el.children().filter_map(ElementRef::wrap));
265 for c in candidates {
266 if let Some(class) = c.value().attr("class") {
267 for word in class.split_whitespace() {
268 if let Some(lang) = word.strip_prefix("language-")
269 && !lang.is_empty() {
270 return Some(lang.to_string());
271 }
272 }
273 }
274 }
275 None
276}
277
278fn table_block(el: ElementRef) -> Block {
293 let mut lemma = None;
294 let mut headers: Option<Vec<String>> = None;
295 let mut rows: Vec<Vec<String>> = Vec::new();
296
297 let mut table_rows: Vec<(ElementRef, bool)> = Vec::new();
298 for child in el.children().filter_map(ElementRef::wrap) {
299 match child.value().name() {
300 "caption" => lemma = Some(text_of(child)),
301 "tr" => table_rows.push((child, false)),
302 "thead" | "tbody" | "tfoot" => {
303 let in_head = child.value().name() == "thead";
304 for tr in child.children().filter_map(ElementRef::wrap) {
305 if tr.value().name() == "tr" {
306 table_rows.push((tr, in_head));
307 }
308 }
309 }
310 _ => {}
311 }
312 }
313
314 let mut first = true;
315 for (tr, in_head) in table_rows {
316 let cells: Vec<(bool, String)> = tr
318 .children()
319 .filter_map(ElementRef::wrap)
320 .filter_map(|cell| match cell.value().name() {
321 "th" => Some((true, text_of(cell))),
322 "td" => Some((false, text_of(cell))),
323 _ => None,
324 })
325 .collect();
326 if cells.is_empty() {
327 continue;
328 }
329 let all_th = cells.iter().all(|(th, _)| *th);
330 if first && all_th && cells.len() == 1 {
332 if lemma.is_none() {
333 lemma = Some(cells[0].1.clone());
334 } else {
335 rows.push(vec![cells[0].1.clone()]);
336 }
337 first = false;
338 continue;
339 }
340 first = false;
341 if all_th && cells.len() > 1 && headers.is_none() && rows.is_empty() {
344 headers = Some(cells.into_iter().map(|(_, t)| t).collect());
345 continue;
346 }
347 if in_head && headers.is_none() && rows.is_empty() {
348 headers = Some(cells.into_iter().map(|(_, t)| t).collect());
349 continue;
350 }
351 if cells.len() > 1 && cells[0].0 && cells[1..].iter().all(|(th, _)| !th) {
354 let label = &cells[0].1;
355 let mut out = Vec::new();
356 for (i, (_, t)) in cells[1..].iter().enumerate() {
357 if i == 0 && !label.is_empty() && !t.is_empty() {
358 out.push(format!("{label}: {t}"));
359 } else if i == 0 && !label.is_empty() {
360 out.push(label.clone());
361 } else {
362 out.push(t.clone());
363 }
364 }
365 rows.push(out);
366 continue;
367 }
368 rows.push(cells.into_iter().map(|(_, t)| t).collect());
369 }
370
371 Block::Table {
372 lemma,
373 headers,
374 rows,
375 }
376}
377
378fn text_of(el: ElementRef) -> String {
380 quarb_text::normalize_ws(&text_of_raw(el))
381}
382
383fn text_of_raw(el: ElementRef) -> String {
389 let mut out = String::new();
390 let mut stack: Vec<NodeRef<DomNode>> = el.children().rev().collect();
391 while let Some(node) = stack.pop() {
392 if let Some(child) = ElementRef::wrap(node) {
393 if SKIP.contains(&child.value().name()) || aria_chrome(child) {
394 continue;
395 }
396 for c in child.children().rev() {
397 stack.push(c);
398 }
399 } else if let DomNode::Text(text) = node.value() {
400 out.push_str(text);
401 }
402 }
403 out
404}
405
406fn flush(run: &mut String, out: &mut Vec<Block>) {
407 if !run.trim().is_empty() {
408 out.push(Block::Text { text: run.clone() });
409 }
410 run.clear();
411}