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}