Skip to main content

quarb_text_html/
lib.rs

1//! HTML producer for the Quarb text level: reduces a page to what
2//! it *says* — headings, paragraphs, quotes, lists, verbatim
3//! blocks, tables — and drops the markup soup. The DOM-faithful
4//! view is `quarb-html`; this crate lowers the same substrate into
5//! the shared `quarb-text` vocabulary, so `//section[::lemma ...]`
6//! and `//paragraph` read the same over a page as over any other
7//! text substrate.
8//!
9//! Producer rules (the HTML-specific knowledge lives here):
10//!
11//! - `h1`–`h6` become flat [`Block::Heading`]s; `quarb-text`
12//!   derives the enclosing section tree.
13//! - Soup is dropped: `script`, `style`, `nav`, `header`,
14//!   `footer`, `aside`, `form`, media elements, the whole
15//!   `head` — and, whatever the element, ARIA chrome (landmark
16//!   roles like `navigation`/`banner`, or `aria-hidden="true"`).
17//! - Structural wrappers (`div`, `section`, `article`, `main`, …)
18//!   are transparent: their flow content is walked, the wrapper
19//!   itself leaves no node.
20//! - A `blockquote`'s trailing `cite`/`footer` child — or the
21//!   `figcaption` of a `figure`-wrapped quote — becomes the
22//!   quote's hypograph (attribution).
23//! - `pre` becomes a verbatim block, language from a
24//!   `language-*` class; `table` becomes a [`Block::Table`]
25//!   (caption from `<caption>`, headers from `thead`/`th` cells),
26//!   denormalized to nested lists by `quarb-text`.
27//! - Inline markup flattens to its text.
28
29use ego_tree::NodeRef;
30use quarb_text::{Block, Container, TextModel};
31use scraper::{ElementRef, Html, Node as DomNode};
32
33/// Parse `html` and lower it to a text-level document.
34pub fn parse(html: &str) -> TextModel {
35    TextModel::build(blocks(html))
36}
37
38/// The event stream `parse` builds from — exposed for testing and
39/// composition.
40pub 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    // Explicit work stack (children pushed reversed, popped in
46    // document order) so pathologically deep markup cannot
47    // overflow the call stack.
48    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
71/// Elements whose entire subtree is soup at the text level.
72const 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
78/// ARIA landmark roles that mark chrome, whatever the element —
79/// the standards-level equivalent of the SKIP tags.
80const 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
93/// Whether an element is chrome by its ARIA surface: a chrome
94/// landmark role, or hidden from assistive readers outright.
95fn 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
104/// Structural wrappers walked transparently: their flow content is
105/// kept, the wrapper leaves no node. A wrapper boundary breaks an
106/// inline run.
107const 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
112/// Block elements read as plain paragraphs.
113const 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                    // A figure-wrapped quotation: the figcaption is
152                    // the attribution.
153                    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        // Everything else is inline: flatten to text.
205        _ => run.push_str(&text_of_raw(el)),
206    }
207}
208
209/// Push DOM children (elements and text nodes) reversed, so they
210/// pop in document order.
211fn 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
221/// The first direct child element with `tag`, if any.
222fn 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
228/// A blockquote's content children and its attribution: the last
229/// direct `cite`/`footer` child, removed from the content.
230fn 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
260/// The language of a `pre` block, from a `language-*` class on the
261/// `pre` itself or a direct `code` child.
262fn 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
278/// Lower a `table` element: caption, a header row (from `thead` or
279/// a leading all-`th` row of two or more cells), and the data
280/// rows. Two shapes beyond the plain grid are recognized:
281///
282/// - a **leading single-`th` row** is the table's title (a
283///   Wikipedia-infobox convention): it becomes the lemma when no
284///   `<caption>` claimed it;
285/// - a **row-label row** (`th` first, then `td`s) carries its
286///   label onto the first value — `Date: 2 November 1932` — the
287///   row-wise mirror of the column-header prefix; a lone mid-table
288///   `th` stays a bare line (a subheading within the table).
289///
290/// Cell text is flattened; the nested-list denormalization is
291/// `quarb-text`'s.
292fn 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        // (is_th, text) per cell, in order.
317        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        // A leading lone th is the table's title.
331        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        // A th row of two or more cells before any data row is the
342        // header row.
343        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        // A row-label row: th first, td values follow — the label
352        // prefixes the first value.
353        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
378/// Subtree text, whitespace-normalized.
379fn text_of(el: ElementRef) -> String {
380    quarb_text::normalize_ws(&text_of_raw(el))
381}
382
383/// Subtree text as authored (verbatim blocks, inline runs — runs
384/// are normalized at flush). Unlike `ElementRef::text`, text under
385/// soup descendants is excluded — an inline `<style>` inside a
386/// wrapper, an `aria-hidden` tooltip — so flattened prose carries
387/// only what the reader sees.
388fn 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}