use ego_tree::NodeRef;
use quarb_text::{Block, Cell, Container, TextModel};
use scraper::{ElementRef, Html, Node as DomNode};
pub fn parse(html: &str) -> TextModel {
TextModel::build(blocks(html))
}
pub fn blocks(html: &str) -> Vec<Block> {
let document = Html::parse_document(html);
let mut out = Vec::new();
let mut run = String::new();
let mut stack: Vec<Work> = vec![Work::El(document.root_element())];
while let Some(work) = stack.pop() {
match work {
Work::Text(text) => run.push_str(&text),
Work::Flush => flush(&mut run, &mut out),
Work::Open { kind, lemma } => {
flush(&mut run, &mut out);
out.push(Block::Open { kind, lemma });
}
Work::Close { hypograph } => {
flush(&mut run, &mut out);
out.push(Block::Close { hypograph });
}
Work::El(el) => element(el, &mut run, &mut out, &mut stack),
}
}
flush(&mut run, &mut out);
out
}
enum Work<'a> {
El(ElementRef<'a>),
Text(String),
Flush,
Open { kind: Container, lemma: Option<String> },
Close { hypograph: Option<String> },
}
const SKIP: &[&str] = &[
"head", "script", "style", "noscript", "template", "nav", "header", "footer", "aside", "form",
"button", "select", "input", "textarea", "label", "iframe", "svg", "math", "img", "picture",
"video", "audio", "canvas", "object", "map", "colgroup", "col",
];
const CHROME_ROLES: &[&str] = &[
"navigation",
"banner",
"contentinfo",
"search",
"complementary",
"menu",
"menubar",
"toolbar",
"presentation",
"none",
];
fn aria_chrome(el: ElementRef) -> bool {
if el.value().attr("aria-hidden") == Some("true") {
return true;
}
el.value()
.attr("role")
.is_some_and(|r| CHROME_ROLES.contains(&r))
}
const TRANSPARENT: &[&str] = &[
"html", "body", "div", "section", "article", "main", "hgroup", "details", "dialog", "address",
"fieldset", "center", "tbody", "thead", "tfoot", "tr", "td", "th",
];
const P_LIKE: &[&str] = &["p", "figcaption", "dt", "dd", "summary", "legend", "caption"];
fn element<'a>(
el: ElementRef<'a>,
run: &mut String,
out: &mut Vec<Block>,
stack: &mut Vec<Work<'a>>,
) {
let tag = el.value().name();
match tag {
_ if SKIP.contains(&tag) || aria_chrome(el) => {}
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
flush(run, out);
out.push(Block::Heading {
level: tag[1..].parse().unwrap(),
lemma: text_of(el),
});
}
_ if P_LIKE.contains(&tag) => {
flush(run, out);
out.push(Block::Paragraph { text: text_of(el) });
}
"blockquote" => {
flush(run, out);
out.push(Block::Open {
kind: Container::Blockquote,
lemma: None,
});
let (children, hypograph) = quote_content(el);
stack.push(Work::Close { hypograph });
push_children(children, stack);
}
"figure" => {
flush(run, out);
let quote = child_by_tag(el, "blockquote");
let caption = child_by_tag(el, "figcaption");
match (quote, caption) {
(Some(quote), Some(caption)) => {
out.push(Block::Open {
kind: Container::Blockquote,
lemma: None,
});
let (children, inner) = quote_content(quote);
stack.push(Work::Close {
hypograph: inner.or(Some(text_of(caption))),
});
push_children(children, stack);
}
_ => {
stack.push(Work::Flush);
push_children(el.children().collect(), stack);
}
}
}
"ul" => open_list(el, Container::UnorderedList, stack, run, out),
"ol" => {
let start = el
.value()
.attr("start")
.and_then(|s| s.parse().ok())
.unwrap_or(1);
open_list(el, Container::OrderedList { start }, stack, run, out);
}
"dl" => {
flush(run, out);
out.push(Block::Open {
kind: Container::UnorderedList,
lemma: None,
});
stack.push(Work::Close { hypograph: None });
for (terms, dds) in dl_groups(el).into_iter().rev() {
stack.push(Work::Close { hypograph: None });
for dd in dds.into_iter().rev() {
push_children(dd, stack);
stack.push(Work::Flush);
}
stack.push(Work::Open {
kind: Container::Item,
lemma: Some(terms),
});
}
}
"li" => {
flush(run, out);
out.push(Block::Open {
kind: Container::Item,
lemma: None,
});
stack.push(Work::Close { hypograph: None });
push_children(el.children().collect(), stack);
}
"pre" => {
flush(run, out);
out.push(Block::Verbatim {
lang: verbatim_lang(el),
text: text_of_raw(el),
});
}
"table" => {
flush(run, out);
out.push(table_block(el));
}
"hr" => flush(run, out),
"br" => run.push(' '),
_ if TRANSPARENT.contains(&tag) => {
stack.push(Work::Flush);
push_children(el.children().collect(), stack);
}
_ => run.push_str(&text_of_raw(el)),
}
}
fn push_children<'a>(children: Vec<NodeRef<'a, DomNode>>, stack: &mut Vec<Work<'a>>) {
for child in children.into_iter().rev() {
if let Some(el) = ElementRef::wrap(child) {
stack.push(Work::El(el));
} else if let DomNode::Text(text) = child.value() {
stack.push(Work::Text(text.to_string()));
}
}
}
type DdBatches<'a> = Vec<Vec<NodeRef<'a, DomNode>>>;
fn dl_groups<'a>(el: ElementRef<'a>) -> Vec<(String, DdBatches<'a>)> {
let mut groups: Vec<(String, DdBatches<'a>)> = Vec::new();
let mut terms: Vec<String> = Vec::new();
for child in el.children() {
let Some(cel) = ElementRef::wrap(child) else {
continue;
};
match cel.value().name() {
"dt" => {
if !groups.is_empty()
&& terms.is_empty()
&& groups.last().is_some_and(|(_, dds)| dds.is_empty())
{
}
terms.push(text_of(cel));
}
"dd" => {
if !terms.is_empty() {
groups.push((terms.join(", "), Vec::new()));
terms.clear();
}
if let Some((_, dds)) = groups.last_mut() {
dds.push(cel.children().collect());
}
}
"div" => {
for inner in cel.children() {
if let Some(iel) = ElementRef::wrap(inner) {
match iel.value().name() {
"dt" => terms.push(text_of(iel)),
"dd" => {
if !terms.is_empty() {
groups.push((terms.join(", "), Vec::new()));
terms.clear();
}
if let Some((_, dds)) = groups.last_mut() {
dds.push(iel.children().collect());
}
}
_ => {}
}
}
}
}
_ => {}
}
}
if !terms.is_empty() {
groups.push((terms.join(", "), Vec::new()));
}
groups
}
fn child_by_tag<'a>(el: ElementRef<'a>, tag: &str) -> Option<ElementRef<'a>> {
el.children()
.filter_map(ElementRef::wrap)
.find(|c| c.value().name() == tag)
}
fn quote_content<'a>(el: ElementRef<'a>) -> (Vec<NodeRef<'a, DomNode>>, Option<String>) {
let mut hypograph = None;
let mut attribution_id = None;
for child in el.children() {
if let Some(c) = ElementRef::wrap(child)
&& matches!(c.value().name(), "cite" | "footer") {
hypograph = Some(text_of(c));
attribution_id = Some(child.id());
}
}
let children = el
.children()
.filter(|c| Some(c.id()) != attribution_id)
.collect();
(children, hypograph)
}
fn open_list<'a>(
el: ElementRef<'a>,
kind: Container,
stack: &mut Vec<Work<'a>>,
run: &mut String,
out: &mut Vec<Block>,
) {
flush(run, out);
out.push(Block::Open { kind, lemma: None });
stack.push(Work::Close { hypograph: None });
push_children(el.children().collect(), stack);
}
fn verbatim_lang(el: ElementRef) -> Option<String> {
let mut candidates = vec![el];
candidates.extend(el.children().filter_map(ElementRef::wrap));
for c in candidates {
if let Some(class) = c.value().attr("class") {
for word in class.split_whitespace() {
if let Some(lang) = word.strip_prefix("language-")
&& !lang.is_empty() {
return Some(lang.to_string());
}
}
}
}
None
}
fn table_block(el: ElementRef) -> Block {
let mut lemma = None;
let mut headers: Option<Vec<String>> = None;
let mut rows: Vec<Vec<Cell>> = Vec::new();
let mut table_rows: Vec<(ElementRef, bool)> = Vec::new();
for child in el.children().filter_map(ElementRef::wrap) {
match child.value().name() {
"caption" => lemma = Some(text_of(child)),
"tr" => table_rows.push((child, false)),
"thead" | "tbody" | "tfoot" => {
let in_head = child.value().name() == "thead";
for tr in child.children().filter_map(ElementRef::wrap) {
if tr.value().name() == "tr" {
table_rows.push((tr, in_head));
}
}
}
_ => {}
}
}
let mut first = true;
for (tr, in_head) in table_rows {
let cells: Vec<(bool, String)> = tr
.children()
.filter_map(ElementRef::wrap)
.filter_map(|cell| match cell.value().name() {
"th" => Some((true, text_of(cell))),
"td" => Some((false, text_of(cell))),
_ => None,
})
.collect();
if cells.is_empty() {
continue;
}
let all_th = cells.iter().all(|(th, _)| *th);
if first && all_th && cells.len() == 1 {
if lemma.is_none() {
lemma = Some(cells[0].1.clone());
} else {
rows.push(vec![Cell {
label: None,
text: cells[0].1.clone(),
}]);
}
first = false;
continue;
}
first = false;
if all_th && cells.len() > 1 && headers.is_none() && rows.is_empty() {
headers = Some(cells.into_iter().map(|(_, t)| t).collect());
continue;
}
if in_head && headers.is_none() && rows.is_empty() {
headers = Some(cells.into_iter().map(|(_, t)| t).collect());
continue;
}
if cells.len() > 1 && cells[0].0 && cells[1..].iter().all(|(th, _)| !th) {
let label = &cells[0].1;
let mut out = Vec::new();
for (i, (_, t)) in cells[1..].iter().enumerate() {
if i == 0 && !label.is_empty() && !t.is_empty() {
out.push(Cell {
label: Some(label.clone()),
text: t.clone(),
});
} else if i == 0 && !label.is_empty() {
out.push(Cell {
label: None,
text: label.clone(),
});
} else {
out.push(Cell {
label: None,
text: t.clone(),
});
}
}
rows.push(out);
continue;
}
rows.push(
cells
.into_iter()
.map(|(_, t)| Cell {
label: None,
text: t,
})
.collect(),
);
}
Block::Table {
lemma,
headers,
rows,
}
}
fn text_of(el: ElementRef) -> String {
quarb_text::normalize_ws(&text_of_raw(el))
}
fn text_of_raw(el: ElementRef) -> String {
let mut out = String::new();
let mut stack: Vec<NodeRef<DomNode>> = el.children().rev().collect();
while let Some(node) = stack.pop() {
if let Some(child) = ElementRef::wrap(node) {
if SKIP.contains(&child.value().name()) || aria_chrome(child) {
continue;
}
for c in child.children().rev() {
stack.push(c);
}
} else if let DomNode::Text(text) = node.value() {
out.push_str(text);
}
}
out
}
fn flush(run: &mut String, out: &mut Vec<Block>) {
if !run.trim().is_empty() {
out.push(Block::Text { text: run.clone() });
}
run.clear();
}