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, Cell, 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::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
76/// Elements whose entire subtree is soup at the text level.
77const 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
83/// ARIA landmark roles that mark chrome, whatever the element —
84/// the standards-level equivalent of the SKIP tags.
85const 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
98/// Whether an element is chrome by its ARIA surface: a chrome
99/// landmark role, or hidden from assistive readers outright.
100fn 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
109/// Structural wrappers walked transparently: their flow content is
110/// kept, the wrapper leaves no node. A wrapper boundary breaks an
111/// inline run.
112const TRANSPARENT: &[&str] = &[
113    "html", "body", "div", "section", "article", "main", "hgroup", "details", "dialog", "address",
114    "fieldset", "center", "tbody", "thead", "tfoot", "tr", "td", "th",
115];
116
117/// Block elements read as plain paragraphs.
118const 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                    // A figure-wrapped quotation: the figcaption is
157                    // the attribution.
158                    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        // Everything else is inline: flatten to text.
229        _ => run.push_str(&text_of_raw(el)),
230    }
231}
232
233/// Push DOM children (elements and text nodes) reversed, so they
234/// pop in document order.
235fn 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
245/// A `dl`'s term groups: each run of `dt`s (terms joined with
246/// `, `) paired with its following `dd`s' content children, one
247/// batch per `dd` — HTML's serialization of "items with lemmas",
248/// regrouped.
249type 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                    // consecutive groups without dd stay separate
264                }
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 wrappers around dt/dd groups are legal HTML
277            "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
305/// The first direct child element with `tag`, if any.
306fn 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
312/// A blockquote's content children and its attribution: the last
313/// direct `cite`/`footer` child, removed from the content.
314fn 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
344/// The language of a `pre` block, from a `language-*` class on the
345/// `pre` itself or a direct `code` child.
346fn 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
362/// Lower a `table` element: caption, a header row (from `thead` or
363/// a leading all-`th` row of two or more cells), and the data
364/// rows. Two shapes beyond the plain grid are recognized:
365///
366/// - a **leading single-`th` row** is the table's title (a
367///   Wikipedia-infobox convention): it becomes the lemma when no
368///   `<caption>` claimed it;
369/// - a **row-label row** (`th` first, then `td`s) carries its
370///   label onto the first value — `Date: 2 November 1932` — the
371///   row-wise mirror of the column-header prefix; a lone mid-table
372///   `th` stays a bare line (a subheading within the table).
373///
374/// Cell text is flattened; the nested-list denormalization is
375/// `quarb-text`'s.
376fn 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        // (is_th, text) per cell, in order.
401        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        // A leading lone th is the table's title.
415        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        // A th row of two or more cells before any data row is the
429        // header row.
430        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        // A row-label row: th first, td values follow — the label
439        // becomes the first value's lemma (a lone label with no
440        // value stands as bare text).
441        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
483/// Subtree text, whitespace-normalized.
484fn text_of(el: ElementRef) -> String {
485    quarb_text::normalize_ws(&text_of_raw(el))
486}
487
488/// Subtree text as authored (verbatim blocks, inline runs — runs
489/// are normalized at flush). Unlike `ElementRef::text`, text under
490/// soup descendants is excluded — an inline `<style>` inside a
491/// wrapper, an `aria-hidden` tooltip — so flattened prose carries
492/// only what the reader sees.
493fn 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}