Skip to main content

domtree/extract/
table.rs

1//! Tables as data: [`NodeRef::table_rows`] and [`to_csv`].
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use crate::node::{NodeId, NodeKind};
7use crate::serialize::walk;
8use crate::tags::{self, Display};
9use crate::tree::{Dom, NodeRef};
10
11/// `colspan` and `rowspan` above this are clamped (HTML clamps `colspan` at
12/// 1000 too).
13const MAX_SPAN: usize = 1000;
14
15/// Cells that spans (and the padding a `rowspan` forces into a short row) may
16/// add beyond the table's own cells: this plus [`SPAN_FACTOR`] per cell.
17const SPAN_CELLS: usize = 100_000;
18
19/// Bytes of cell text that spans may duplicate: this plus [`SPAN_FACTOR`]
20/// times the table's own text.
21const SPAN_BYTES: usize = 1 << 22;
22
23const SPAN_FACTOR: usize = 4;
24
25impl NodeRef<'_> {
26    /// The rows of a `<table>` as cell text, with `colspan` and `rowspan`
27    /// expanded — ready for CSV ([`to_csv`]), a data frame or an LLM prompt.
28    ///
29    /// Rows come from the table's `<thead>`, `<tbody>`, `<tfoot>` and direct
30    /// `<tr>`s in document order (stray cells outside any row form a row of
31    /// their own). Each cell is its readable text ([`NodeRef::to_text`])
32    /// **without** the content of tables nested in it — such a table reads as
33    /// a paragraph break — since a nested table has rows of its own (call
34    /// `table_rows` on it). This keeps extracting every table of a page, as
35    /// `domtree --csv` does, linear in the page's size. Returns an empty list
36    /// if this node is not a `<table>`.
37    ///
38    /// A cell spanning several columns or rows is **repeated** in every slot
39    /// it covers, so each row lists the value under each column. Span values
40    /// follow HTML's rules for non-negative integers (`" 2"`, `"+2"` and
41    /// `"2px"` are 2; an invalid or missing value is 1). `rowspan=0` spans the
42    /// rest of the row group; spans stop at the end of their row group, as in
43    /// HTML. Rows are not padded to equal length, except where a `rowspan` from
44    /// an earlier row reaches past a row's own cells.
45    ///
46    /// Bounded work on hostile input: spans are clamped at 1000, and once
47    /// the spans of a table have added about four times the table's own
48    /// cells and text (plus a fixed allowance of 100,000 cells and 4 MiB),
49    /// further spans count as 1.
50    ///
51    /// ```
52    /// let dom = domtree::parse(
53    ///     "<table>
54    ///        <thead><tr><th>Region</th><th>Q1</th><th>Q2</th></tr></thead>
55    ///        <tbody>
56    ///          <tr><td rowspan=2>EU</td><td>10</td><td>12</td></tr>
57    ///          <tr><td colspan=2>n/a</td></tr>
58    ///        </tbody>
59    ///      </table>",
60    /// );
61    /// let table = dom.find_by_tag("table").next().unwrap();
62    /// assert_eq!(table.table_rows(), [
63    ///     ["Region", "Q1", "Q2"],
64    ///     ["EU", "10", "12"],
65    ///     ["EU", "n/a", "n/a"],
66    /// ]);
67    /// ```
68    pub fn table_rows(&self) -> Vec<Vec<String>> {
69        if self.tag_name() != Some("table") {
70            return Vec::new();
71        }
72        let rows = collect_rows(*self);
73
74        let mut texts: Vec<Vec<String>> = Vec::with_capacity(rows.len());
75        let (mut cells, mut bytes) = (0usize, 0usize);
76        // Holds copies of the cells that contain nested tables, made without
77        // those tables; created on first use.
78        let mut scratch: Option<Dom> = None;
79        for row in &rows {
80            let row_texts: Vec<String> = row
81                .cells
82                .iter()
83                .map(|&cell| cell_text(cell, &mut scratch))
84                .collect();
85            cells = cells.saturating_add(row_texts.len());
86            bytes = row_texts
87                .iter()
88                .fold(bytes, |b, t| b.saturating_add(t.len()));
89            texts.push(row_texts);
90        }
91        let mut budget = Budget {
92            cells: SPAN_CELLS.saturating_add(cells.saturating_mul(SPAN_FACTOR)),
93            bytes: SPAN_BYTES.saturating_add(bytes.saturating_mul(SPAN_FACTOR)),
94        };
95
96        // Where each row's group ends (for `rowspan=0` and span cut-off).
97        let mut group_end = alloc::vec![rows.len(); rows.len()];
98        for i in (0..rows.len().saturating_sub(1)).rev() {
99            let same = rows.get(i).map(|r| r.group) == rows.get(i + 1).map(|r| r.group);
100            let next = group_end.get(i + 1).copied().unwrap_or(rows.len());
101            if let Some(end) = group_end.get_mut(i) {
102                *end = if same { next } else { i + 1 };
103            }
104        }
105
106        let mut out: Vec<Vec<String>> = Vec::with_capacity(rows.len());
107        // Per column: rows still covered by a `rowspan`, and the cell's text.
108        let mut pending: Vec<Option<(usize, String)>> = Vec::new();
109        let mut group = None;
110        for (i, (row, row_texts)) in rows.iter().zip(texts).enumerate() {
111            if group != Some(row.group) {
112                pending.clear();
113                group = Some(row.group);
114            }
115            let rows_left = group_end.get(i).copied().unwrap_or(i + 1).saturating_sub(i);
116            let mut line: Vec<String> = Vec::with_capacity(row_texts.len());
117            for (cell, text) in row.cells.iter().zip(row_texts) {
118                take_pending(&mut line, &mut pending);
119                let colspan = span(cell.attr("colspan")).clamp(1, MAX_SPAN);
120                let rowspan = match span(cell.attr("rowspan")) {
121                    0 => rows_left,
122                    n => n.min(rows_left),
123                }
124                .clamp(1, MAX_SPAN);
125                let (colspan, rowspan) = budget.allow(colspan, rowspan, text.len());
126                for _ in 0..colspan {
127                    let col = line.len();
128                    if pending.len() <= col && rowspan > 1 {
129                        pending.resize(col + 1, None);
130                    }
131                    // A cell overlapping an earlier `rowspan` takes the column.
132                    if let Some(slot) = pending.get_mut(col) {
133                        *slot = (rowspan > 1).then(|| (rowspan - 1, text.clone()));
134                    }
135                    line.push(text.clone());
136                }
137            }
138            take_pending(&mut line, &mut pending);
139            // A `rowspan` beyond this row's own cells: pad the gap.
140            if let Some(last) = pending.iter().rposition(Option::is_some) {
141                while line.len() <= last {
142                    if pending.get(line.len()).is_some_and(Option::is_some) {
143                        take_pending(&mut line, &mut pending);
144                    } else if budget.cells > 0 {
145                        budget.cells -= 1;
146                        line.push(String::new());
147                    } else {
148                        pending.clear();
149                        break;
150                    }
151                }
152            }
153            let used = pending
154                .iter()
155                .rposition(Option::is_some)
156                .map_or(0, |i| i + 1);
157            pending.truncate(used);
158            out.push(line);
159        }
160        out
161    }
162}
163
164/// Format rows as CSV ([RFC 4180](https://www.rfc-editor.org/rfc/rfc4180)):
165/// fields separated by commas, a field quoted (with `"` doubled) when it
166/// contains a comma, a quote or a line break, and every record ended by
167/// `\r\n`. Rows may have different lengths.
168///
169/// Takes anything iterable of iterables of strings — such as the
170/// [`NodeRef::table_rows`] of a table, by value or by reference.
171///
172/// ```
173/// let dom = domtree::parse(
174///     r#"<table><tr><th>Name<th>Note<tr><td>Ann<td>says "hi", twice</table>"#,
175/// );
176/// let rows = dom.find_by_tag("table").next().unwrap().table_rows();
177/// assert_eq!(
178///     domtree::to_csv(&rows),
179///     "Name,Note\r\nAnn,\"says \"\"hi\"\", twice\"\r\n",
180/// );
181/// assert_eq!(domtree::to_csv([["a", "b\nc"]]), "a,\"b\nc\"\r\n");
182/// ```
183pub fn to_csv<R, C, S>(rows: R) -> String
184where
185    R: IntoIterator<Item = C>,
186    C: IntoIterator<Item = S>,
187    S: AsRef<str>,
188{
189    let mut out = String::new();
190    for row in rows {
191        for (i, field) in row.into_iter().enumerate() {
192            if i > 0 {
193                out.push(',');
194            }
195            let field = field.as_ref();
196            if field.contains([',', '"', '\r', '\n']) {
197                out.push('"');
198                for c in field.chars() {
199                    if c == '"' {
200                        out.push('"');
201                    }
202                    out.push(c);
203                }
204                out.push('"');
205            } else {
206                out.push_str(field);
207            }
208        }
209        out.push_str("\r\n");
210    }
211    out
212}
213
214/// One `<tr>` (or run of stray cells): its row group and its cells.
215struct Row<'a> {
216    group: NodeId,
217    cells: Vec<NodeRef<'a>>,
218}
219
220/// The table's rows in document order, without descending into nested tables
221/// or into cells.
222fn collect_rows(table: NodeRef<'_>) -> Vec<Row<'_>> {
223    let mut rows: Vec<Row<'_>> = Vec::new();
224    // Open `<thead>`/`<tbody>`/`<tfoot>` and `<tr>` elements (`tr` with the
225    // index of its row).
226    let mut groups: Vec<NodeId> = Vec::new();
227    let mut open: Vec<(NodeId, usize)> = Vec::new();
228    // The implicit row collecting stray cells, if one is open.
229    let mut stray: Option<usize> = None;
230    walk(table, |node, enter| {
231        let Some(tag) = node.tag_name() else {
232            return false;
233        };
234        if node.id() == table.id() {
235            return true;
236        }
237        let group = groups.last().copied().unwrap_or(table.id());
238        if !enter {
239            match tag {
240                "tr" if open.last().is_some_and(|&(id, _)| id == node.id()) => {
241                    open.pop();
242                }
243                "thead" | "tbody" | "tfoot" if groups.last() == Some(&node.id()) => {
244                    groups.pop();
245                    stray = None;
246                }
247                _ => {}
248            }
249            return false;
250        }
251        match tag {
252            "table" => false,
253            "tr" => {
254                open.push((node.id(), rows.len()));
255                rows.push(Row {
256                    group,
257                    cells: Vec::new(),
258                });
259                stray = None;
260                true
261            }
262            "thead" | "tbody" | "tfoot" => {
263                groups.push(node.id());
264                stray = None;
265                true
266            }
267            "td" | "th" => {
268                let index = match open.last() {
269                    Some(&(_, index)) => index,
270                    None => *stray.get_or_insert_with(|| {
271                        rows.push(Row {
272                            group,
273                            cells: Vec::new(),
274                        });
275                        rows.len() - 1
276                    }),
277                };
278                if let Some(row) = rows.get_mut(index) {
279                    row.cells.push(node);
280                }
281                false
282            }
283            _ => tags::display(tag) != Display::Hidden,
284        }
285    });
286    rows
287}
288
289/// A cell's readable text ([`NodeRef::to_text`]) without the content of the
290/// tables nested in it. Linear in the cell's own content: a cell holding no
291/// table is read directly; one that does is first copied into `scratch`
292/// without the tables' content, then read.
293fn cell_text(cell: NodeRef<'_>, scratch: &mut Option<Dom>) -> String {
294    if !has_nested_table(cell) {
295        return cell.to_text();
296    }
297    let dom = scratch.get_or_insert_with(|| Dom::with_capacity(0));
298    copy_without_tables(dom, cell)
299        .and_then(|copy| dom.get(copy))
300        .map(|copy| copy.to_text())
301        .unwrap_or_default()
302}
303
304/// Whether a `<table>` sits inside `cell`, outside hidden elements (whose
305/// text does not count anyway). Stops descending at the first one.
306fn has_nested_table(cell: NodeRef<'_>) -> bool {
307    let mut found = false;
308    walk(cell, |node, enter| match node.tag_name() {
309        _ if !enter || found => false,
310        Some("table") => {
311            found = true;
312            false
313        }
314        Some(tag) => tags::display(tag) != Display::Hidden,
315        None => false,
316    });
317    found
318}
319
320/// Copy `cell`'s subtree into `dom` as a detached tree, with each nested
321/// `<table>` (and each hidden element, which `to_text` skips anyway) left
322/// empty, so the copy's text is the cell's own. Built bottom-up — a node is
323/// created once its children are — so every `append_child` is O(1).
324fn copy_without_tables(dom: &mut Dom, cell: NodeRef<'_>) -> Option<NodeId> {
325    // The copied children of each open element, innermost last.
326    let mut open: Vec<Vec<NodeId>> = Vec::new();
327    let mut root = None;
328    walk(cell, |node, enter| {
329        let copy = match node.kind() {
330            NodeKind::Text(text) => dom.create_text(text),
331            NodeKind::Element { tag } if enter => {
332                if tag != "table" && tags::display(tag) != Display::Hidden {
333                    open.push(Vec::new());
334                    return true;
335                }
336                match element(dom, tag) {
337                    Some(copy) => copy,
338                    None => return false,
339                }
340            }
341            NodeKind::Element { tag } => {
342                let children = open.pop().unwrap_or_default();
343                let Some(copy) = element(dom, tag) else {
344                    return false;
345                };
346                for child in children {
347                    dom.append_child(copy, child);
348                }
349                copy
350            }
351            NodeKind::Comment(_) | NodeKind::Doctype(_) => return false,
352        };
353        match open.last_mut() {
354            Some(children) => children.push(copy),
355            None => root = Some(copy),
356        }
357        false
358    });
359    root
360}
361
362/// A detached copy of an element named `tag`. Parsed names the editing API
363/// refuses (`<a"b>`) become a `<span>`, which reads the same as text.
364fn element(dom: &mut Dom, tag: &str) -> Option<NodeId> {
365    dom.create_element(tag)
366        .or_else(|| dom.create_element("span"))
367}
368
369/// Fill `line` from the `rowspan`s covering its next columns.
370fn take_pending(line: &mut Vec<String>, pending: &mut [Option<(usize, String)>]) {
371    while let Some(slot) = pending.get_mut(line.len()) {
372        match slot {
373            Some((left, text)) if *left > 1 => {
374                *left -= 1;
375                line.push(text.clone());
376            }
377            Some(_) => {
378                if let Some((_, text)) = slot.take() {
379                    line.push(text);
380                }
381            }
382            None => break,
383        }
384    }
385}
386
387/// A `colspan`/`rowspan` value, by the HTML rules for parsing non-negative
388/// integers: leading ASCII whitespace is skipped, then an optional `+` (or a
389/// `-`, valid only before zero), then the leading digits (`"2px"` is 2).
390/// Missing or invalid: 1.
391fn span(value: Option<&str>) -> usize {
392    let Some(value) = value else {
393        return 1;
394    };
395    let value = value.trim_start_matches(['\t', '\n', '\x0C', '\r', ' ']);
396    let (negative, digits) = match value.strip_prefix('-') {
397        Some(rest) => (true, rest),
398        None => (false, value.strip_prefix('+').unwrap_or(value)),
399    };
400    let mut n: usize = 0;
401    let mut any = false;
402    for d in digits.bytes().take_while(u8::is_ascii_digit) {
403        any = true;
404        n = n.saturating_mul(10).saturating_add(usize::from(d - b'0'));
405    }
406    if any && (n == 0 || !negative) { n } else { 1 }
407}
408
409/// What spans may still add to a table.
410struct Budget {
411    cells: usize,
412    bytes: usize,
413}
414
415impl Budget {
416    /// The spans to apply for a cell with `len` bytes of text: as asked, or
417    /// `(1, 1)` once the budget cannot cover the copies.
418    fn allow(&mut self, colspan: usize, rowspan: usize, len: usize) -> (usize, usize) {
419        let extra = colspan.saturating_mul(rowspan).saturating_sub(1);
420        let bytes = extra.saturating_mul(len);
421        if extra <= self.cells && bytes <= self.bytes {
422            self.cells -= extra;
423            self.bytes -= bytes;
424            (colspan, rowspan)
425        } else {
426            (1, 1)
427        }
428    }
429}