1use ego_tree::NodeRef;
30use quarb_text::{Block, Cell, 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::Open { kind, lemma } => {
54 flush(&mut run, &mut out);
55 out.push(Block::Open { kind, lemma });
56 }
57 Work::Close { hypograph } => {
58 flush(&mut run, &mut out);
59 out.push(Block::Close { hypograph });
60 }
61 Work::El(el) => element(el, &mut run, &mut out, &mut stack),
62 }
63 }
64 flush(&mut run, &mut out);
65 out
66}
67
68enum Work<'a> {
69 El(ElementRef<'a>),
70 Text(String),
71 Flush,
72 Open { kind: Container, lemma: Option<String> },
73 Close { hypograph: Option<String> },
74}
75
76const SKIP: &[&str] = &[
78 "head", "script", "style", "noscript", "template", "nav", "header", "footer", "aside", "form",
79 "button", "select", "input", "textarea", "label", "iframe", "svg", "math", "img", "picture",
80 "video", "audio", "canvas", "object", "map", "colgroup", "col",
81];
82
83const CHROME_ROLES: &[&str] = &[
86 "navigation",
87 "banner",
88 "contentinfo",
89 "search",
90 "complementary",
91 "menu",
92 "menubar",
93 "toolbar",
94 "presentation",
95 "none",
96];
97
98fn aria_chrome(el: ElementRef) -> bool {
101 if el.value().attr("aria-hidden") == Some("true") {
102 return true;
103 }
104 el.value()
105 .attr("role")
106 .is_some_and(|r| CHROME_ROLES.contains(&r))
107}
108
109const TRANSPARENT: &[&str] = &[
113 "html", "body", "div", "section", "article", "main", "hgroup", "details", "dialog", "address",
114 "fieldset", "center", "tbody", "thead", "tfoot", "tr", "td", "th",
115];
116
117const P_LIKE: &[&str] = &["p", "figcaption", "dt", "dd", "summary", "legend", "caption"];
119
120fn element<'a>(
121 el: ElementRef<'a>,
122 run: &mut String,
123 out: &mut Vec<Block>,
124 stack: &mut Vec<Work<'a>>,
125) {
126 let tag = el.value().name();
127 match tag {
128 _ if SKIP.contains(&tag) || aria_chrome(el) => {}
129 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
130 flush(run, out);
131 out.push(Block::Heading {
132 level: tag[1..].parse().unwrap(),
133 lemma: text_of(el),
134 });
135 }
136 _ if P_LIKE.contains(&tag) => {
137 flush(run, out);
138 out.push(Block::Paragraph { text: text_of(el) });
139 }
140 "blockquote" => {
141 flush(run, out);
142 out.push(Block::Open {
143 kind: Container::Blockquote,
144 lemma: None,
145 });
146 let (children, hypograph) = quote_content(el);
147 stack.push(Work::Close { hypograph });
148 push_children(children, stack);
149 }
150 "figure" => {
151 flush(run, out);
152 let quote = child_by_tag(el, "blockquote");
153 let caption = child_by_tag(el, "figcaption");
154 match (quote, caption) {
155 (Some(quote), Some(caption)) => {
156 out.push(Block::Open {
159 kind: Container::Blockquote,
160 lemma: None,
161 });
162 let (children, inner) = quote_content(quote);
163 stack.push(Work::Close {
164 hypograph: inner.or(Some(text_of(caption))),
165 });
166 push_children(children, stack);
167 }
168 _ => {
169 stack.push(Work::Flush);
170 push_children(el.children().collect(), stack);
171 }
172 }
173 }
174 "ul" => open_list(el, Container::UnorderedList, stack, run, out),
175 "ol" => {
176 let start = el
177 .value()
178 .attr("start")
179 .and_then(|s| s.parse().ok())
180 .unwrap_or(1);
181 open_list(el, Container::OrderedList { start }, stack, run, out);
182 }
183 "dl" => {
184 flush(run, out);
185 out.push(Block::Open {
186 kind: Container::UnorderedList,
187 lemma: None,
188 });
189 stack.push(Work::Close { hypograph: None });
190 for (terms, dds) in dl_groups(el).into_iter().rev() {
191 stack.push(Work::Close { hypograph: None });
192 for dd in dds.into_iter().rev() {
193 push_children(dd, stack);
194 stack.push(Work::Flush);
195 }
196 stack.push(Work::Open {
197 kind: Container::Item,
198 lemma: Some(terms),
199 });
200 }
201 }
202 "li" => {
203 flush(run, out);
204 out.push(Block::Open {
205 kind: Container::Item,
206 lemma: None,
207 });
208 stack.push(Work::Close { hypograph: None });
209 push_children(el.children().collect(), stack);
210 }
211 "pre" => {
212 flush(run, out);
213 out.push(Block::Verbatim {
214 lang: verbatim_lang(el),
215 text: text_of_raw(el),
216 });
217 }
218 "table" => {
219 flush(run, out);
220 out.push(table_block(el));
221 }
222 "hr" => flush(run, out),
223 "br" => run.push(' '),
224 _ if TRANSPARENT.contains(&tag) => {
225 stack.push(Work::Flush);
226 push_children(el.children().collect(), stack);
227 }
228 _ => run.push_str(&text_of_raw(el)),
230 }
231}
232
233fn push_children<'a>(children: Vec<NodeRef<'a, DomNode>>, stack: &mut Vec<Work<'a>>) {
236 for child in children.into_iter().rev() {
237 if let Some(el) = ElementRef::wrap(child) {
238 stack.push(Work::El(el));
239 } else if let DomNode::Text(text) = child.value() {
240 stack.push(Work::Text(text.to_string()));
241 }
242 }
243}
244
245type DdBatches<'a> = Vec<Vec<NodeRef<'a, DomNode>>>;
250fn dl_groups<'a>(el: ElementRef<'a>) -> Vec<(String, DdBatches<'a>)> {
251 let mut groups: Vec<(String, DdBatches<'a>)> = Vec::new();
252 let mut terms: Vec<String> = Vec::new();
253 for child in el.children() {
254 let Some(cel) = ElementRef::wrap(child) else {
255 continue;
256 };
257 match cel.value().name() {
258 "dt" => {
259 if !groups.is_empty()
260 && terms.is_empty()
261 && groups.last().is_some_and(|(_, dds)| dds.is_empty())
262 {
263 }
265 terms.push(text_of(cel));
266 }
267 "dd" => {
268 if !terms.is_empty() {
269 groups.push((terms.join(", "), Vec::new()));
270 terms.clear();
271 }
272 if let Some((_, dds)) = groups.last_mut() {
273 dds.push(cel.children().collect());
274 }
275 }
276 "div" => {
278 for inner in cel.children() {
279 if let Some(iel) = ElementRef::wrap(inner) {
280 match iel.value().name() {
281 "dt" => terms.push(text_of(iel)),
282 "dd" => {
283 if !terms.is_empty() {
284 groups.push((terms.join(", "), Vec::new()));
285 terms.clear();
286 }
287 if let Some((_, dds)) = groups.last_mut() {
288 dds.push(iel.children().collect());
289 }
290 }
291 _ => {}
292 }
293 }
294 }
295 }
296 _ => {}
297 }
298 }
299 if !terms.is_empty() {
300 groups.push((terms.join(", "), Vec::new()));
301 }
302 groups
303}
304
305fn child_by_tag<'a>(el: ElementRef<'a>, tag: &str) -> Option<ElementRef<'a>> {
307 el.children()
308 .filter_map(ElementRef::wrap)
309 .find(|c| c.value().name() == tag)
310}
311
312fn quote_content<'a>(el: ElementRef<'a>) -> (Vec<NodeRef<'a, DomNode>>, Option<String>) {
315 let mut hypograph = None;
316 let mut attribution_id = None;
317 for child in el.children() {
318 if let Some(c) = ElementRef::wrap(child)
319 && matches!(c.value().name(), "cite" | "footer") {
320 hypograph = Some(text_of(c));
321 attribution_id = Some(child.id());
322 }
323 }
324 let children = el
325 .children()
326 .filter(|c| Some(c.id()) != attribution_id)
327 .collect();
328 (children, hypograph)
329}
330
331fn open_list<'a>(
332 el: ElementRef<'a>,
333 kind: Container,
334 stack: &mut Vec<Work<'a>>,
335 run: &mut String,
336 out: &mut Vec<Block>,
337) {
338 flush(run, out);
339 out.push(Block::Open { kind, lemma: None });
340 stack.push(Work::Close { hypograph: None });
341 push_children(el.children().collect(), stack);
342}
343
344fn verbatim_lang(el: ElementRef) -> Option<String> {
347 let mut candidates = vec![el];
348 candidates.extend(el.children().filter_map(ElementRef::wrap));
349 for c in candidates {
350 if let Some(class) = c.value().attr("class") {
351 for word in class.split_whitespace() {
352 if let Some(lang) = word.strip_prefix("language-")
353 && !lang.is_empty() {
354 return Some(lang.to_string());
355 }
356 }
357 }
358 }
359 None
360}
361
362fn table_block(el: ElementRef) -> Block {
377 let mut lemma = None;
378 let mut headers: Option<Vec<String>> = None;
379 let mut rows: Vec<Vec<Cell>> = Vec::new();
380
381 let mut table_rows: Vec<(ElementRef, bool)> = Vec::new();
382 for child in el.children().filter_map(ElementRef::wrap) {
383 match child.value().name() {
384 "caption" => lemma = Some(text_of(child)),
385 "tr" => table_rows.push((child, false)),
386 "thead" | "tbody" | "tfoot" => {
387 let in_head = child.value().name() == "thead";
388 for tr in child.children().filter_map(ElementRef::wrap) {
389 if tr.value().name() == "tr" {
390 table_rows.push((tr, in_head));
391 }
392 }
393 }
394 _ => {}
395 }
396 }
397
398 let mut first = true;
399 for (tr, in_head) in table_rows {
400 let cells: Vec<(bool, String)> = tr
402 .children()
403 .filter_map(ElementRef::wrap)
404 .filter_map(|cell| match cell.value().name() {
405 "th" => Some((true, text_of(cell))),
406 "td" => Some((false, text_of(cell))),
407 _ => None,
408 })
409 .collect();
410 if cells.is_empty() {
411 continue;
412 }
413 let all_th = cells.iter().all(|(th, _)| *th);
414 if first && all_th && cells.len() == 1 {
416 if lemma.is_none() {
417 lemma = Some(cells[0].1.clone());
418 } else {
419 rows.push(vec![Cell {
420 label: None,
421 text: cells[0].1.clone(),
422 }]);
423 }
424 first = false;
425 continue;
426 }
427 first = false;
428 if all_th && cells.len() > 1 && headers.is_none() && rows.is_empty() {
431 headers = Some(cells.into_iter().map(|(_, t)| t).collect());
432 continue;
433 }
434 if in_head && headers.is_none() && rows.is_empty() {
435 headers = Some(cells.into_iter().map(|(_, t)| t).collect());
436 continue;
437 }
438 if cells.len() > 1 && cells[0].0 && cells[1..].iter().all(|(th, _)| !th) {
442 let label = &cells[0].1;
443 let mut out = Vec::new();
444 for (i, (_, t)) in cells[1..].iter().enumerate() {
445 if i == 0 && !label.is_empty() && !t.is_empty() {
446 out.push(Cell {
447 label: Some(label.clone()),
448 text: t.clone(),
449 });
450 } else if i == 0 && !label.is_empty() {
451 out.push(Cell {
452 label: None,
453 text: label.clone(),
454 });
455 } else {
456 out.push(Cell {
457 label: None,
458 text: t.clone(),
459 });
460 }
461 }
462 rows.push(out);
463 continue;
464 }
465 rows.push(
466 cells
467 .into_iter()
468 .map(|(_, t)| Cell {
469 label: None,
470 text: t,
471 })
472 .collect(),
473 );
474 }
475
476 Block::Table {
477 lemma,
478 headers,
479 rows,
480 }
481}
482
483fn text_of(el: ElementRef) -> String {
485 quarb_text::normalize_ws(&text_of_raw(el))
486}
487
488fn text_of_raw(el: ElementRef) -> String {
494 let mut out = String::new();
495 let mut stack: Vec<NodeRef<DomNode>> = el.children().rev().collect();
496 while let Some(node) = stack.pop() {
497 if let Some(child) = ElementRef::wrap(node) {
498 if SKIP.contains(&child.value().name()) || aria_chrome(child) {
499 continue;
500 }
501 for c in child.children().rev() {
502 stack.push(c);
503 }
504 } else if let DomNode::Text(text) = node.value() {
505 out.push_str(text);
506 }
507 }
508 out
509}
510
511fn flush(run: &mut String, out: &mut Vec<Block>) {
512 if !run.trim().is_empty() {
513 out.push(Block::Text { text: run.clone() });
514 }
515 run.clear();
516}