Merge PR #2207: clipboard: bound HTML colspan/rowspan expansion (#2171)

This commit is contained in:
aiongg
2026-10-22 02:36:12 +00:00
3 changed files with 183 additions and 44 deletions
+166 -42
View File
@@ -11,6 +11,11 @@
const MAX_HTML: usize = 16 * 1024 * 1024;
/// Widest `colspan` honoured (the sheet's column count).
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
/// Tallest `rowspan` honoured (the sheet's row count).
const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
/// Most cells, span placeholders included, one paste reads from the HTML. Past it the rest of the
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
const MAX_HTML_CELLS: usize = 1_000_000;
/// One table cell: its visible text and the number the source recorded for it, if any.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -19,25 +24,28 @@ pub(crate) struct HtmlCell {
pub num: Option<f64>,
}
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
/// `max_cols` cells (the pasted text's shape, itself clamped to the sheet) and [`MAX_HTML_CELLS`]
/// in all. A cell spanning columns is followed by empty cells, and a column covered by a `rowspan`
/// from above holds an empty cell, so positions line up with the tab-separated text. Empty when
/// there is no table.
pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<Vec<HtmlCell>> {
let mut grid = Grid::new(max_rows, max_cols);
if html.len() > MAX_HTML {
return rows;
return grid.rows;
}
let mut cell: Option<(HtmlCell, usize)> = None;
let mut cell: Option<OpenCell> = None;
let mut in_table = false;
let mut rest = html;
while !rest.is_empty() {
while !rest.is_empty() && !grid.full {
let Some(lt) = rest.find('<') else {
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest);
if let Some(c) = cell.as_mut() {
c.cell.text.push_str(rest);
}
break;
};
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest.get(..lt).unwrap_or_default());
if let Some(c) = cell.as_mut() {
c.cell.text.push_str(rest.get(..lt).unwrap_or_default());
}
rest = rest.get(lt..).unwrap_or_default();
if let Some(after) = rest.strip_prefix("<!--") {
@@ -65,49 +73,131 @@ pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
in_table = true;
}
("table", true) => {
finish(&mut rows, &mut cell);
if !rows.is_empty() {
grid.finish(&mut cell);
if !grid.rows.is_empty() {
break;
}
in_table = false;
}
("tr", false) if in_table => {
finish(&mut rows, &mut cell);
rows.push(Vec::new());
grid.finish(&mut cell);
grid.start_row();
}
("td" | "th", false) if in_table => {
finish(&mut rows, &mut cell);
if rows.is_empty() {
rows.push(Vec::new());
}
grid.finish(&mut cell);
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
let span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
cell = Some((HtmlCell { text: String::new(), num }, span));
let colspan = span(attrs, "colspan", MAX_SPAN);
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
}
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
("br", _) => {
if let Some((c, _)) = cell.as_mut() {
c.text.push(' ');
if let Some(c) = cell.as_mut() {
c.cell.text.push(' ');
}
}
_ => {}
}
}
finish(&mut rows, &mut cell);
rows
grid.finish(&mut cell);
grid.rows
}
/// Closes the open cell (if any) into the last row, decoding its text.
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
let Some((mut c, span)) = cell.take() else { return };
c.text = decode(&c.text);
if rows.is_empty() {
rows.push(Vec::new());
/// A `colspan`/`rowspan` attribute: at least 1, at most `max` (the sheet's extent), so a hostile
/// "999999999999" is clamped before anything is expanded.
fn span(attrs: &str, name: &str, max: usize) -> usize {
attr(attrs, name).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, max)
}
/// A cell whose closing tag hasn't been seen yet.
struct OpenCell {
cell: HtmlCell,
colspan: usize,
rowspan: usize,
}
/// The table being read, with its size limits.
struct Grid {
rows: Vec<Vec<HtmlCell>>,
max_rows: usize,
max_cols: usize,
/// Cells still allowed before the table is cut short.
budget: usize,
/// Per column: the row index (exclusive) down to which a `rowspan` from above covers it.
covered_until: Vec<usize>,
/// The row or cell budget ran out: nothing more is read.
full: bool,
}
impl Grid {
fn new(max_rows: usize, max_cols: usize) -> Self {
let max_rows = max_rows.min(MAX_ROW_SPAN);
let max_cols = max_cols.min(MAX_SPAN);
let budget = MAX_HTML_CELLS.min(max_rows.saturating_mul(max_cols));
Grid { rows: Vec::new(), max_rows, max_cols, budget, covered_until: Vec::new(), full: false }
}
if let Some(row) = rows.last_mut() {
row.push(c);
let room = MAX_SPAN.saturating_sub(row.len());
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
/// Opens a new row, or marks the grid full when the pasted text has no more rows.
fn start_row(&mut self) {
if self.rows.len() >= self.max_rows {
self.full = true;
} else {
self.rows.push(Vec::new());
}
}
/// Appends one cell to the current row; false when the row or the cell budget is used up.
fn push(&mut self, cell: HtmlCell) -> bool {
let Some(row) = self.rows.last_mut() else { return false };
if row.len() >= self.max_cols {
return false;
}
let Some(left) = self.budget.checked_sub(1) else {
self.full = true;
return false;
};
self.budget = left;
row.push(cell);
true
}
/// Closes the open cell (if any) into the current row, decoding its text: first past the
/// columns a `rowspan` above still covers, then the cell, then a placeholder per extra column.
fn finish(&mut self, open: &mut Option<OpenCell>) {
let Some(OpenCell { mut cell, colspan, rowspan }) = open.take() else { return };
if self.rows.is_empty() {
self.start_row();
}
let Some(r) = self.rows.len().checked_sub(1) else { return };
while self.covered_until.get(self.col()).is_some_and(|&until| until > r) {
if !self.push(HtmlCell::default()) {
return;
}
}
let start = self.col();
cell.text = decode(&cell.text);
if !self.push(cell) {
return;
}
for _ in 1..colspan {
if !self.push(HtmlCell::default()) {
break;
}
}
if rowspan > 1 {
let (end, until) = (self.col(), r.saturating_add(rowspan));
if self.covered_until.len() < end {
self.covered_until.resize(end, 0);
}
for u in self.covered_until.get_mut(start..end).into_iter().flatten() {
*u = (*u).max(until);
}
}
}
/// The column the next cell of the current row lands in.
fn col(&self) -> usize {
self.rows.last().map_or(0, Vec::len)
}
}
@@ -276,7 +366,7 @@ mod tests {
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
<tr><td colspan=2 x:num="2.5">2.50 &amp; up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
let rows = table_cells(excel);
let rows = table_cells(excel, 2, 3);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
@@ -286,10 +376,10 @@ mod tests {
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
@@ -306,10 +396,44 @@ mod tests {
"<style>",
"<table><table><td>",
] {
let _ = table_cells(html);
let _ = table_cells(html, MAX_ROW_SPAN, MAX_SPAN);
}
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>", 1, 1)[0][0].num, None);
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>", 1, usize::MAX)[0].len() <= MAX_SPAN);
}
#[test]
fn huge_spans_stay_within_the_cell_budget() {
// #2171: 100,000 rows of `colspan=16384` once expanded to ~1.6 billion cells (OOM). Even
// with the sheet's full extent as the limit, at most MAX_HTML_CELLS are materialized.
let row = "<tr><td colspan=16384 rowspan=99999999999 x:num='1'>1</td><td colspan=4294967296>2</td></tr>";
let html = format!("<table>{}</table>", row.repeat(100_000));
let rows = table_cells(&html, usize::MAX, usize::MAX);
let cells: usize = rows.iter().map(Vec::len).sum();
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
// Bounded by the pasted text's shape, the same markup yields just that shape.
let rows = table_cells(&html, 3, 2);
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [2, 2, 2]);
assert_eq!(rows[0][0].num, Some(1.0));
assert!(table_cells(&html, 0, 5).is_empty());
assert!(table_cells(&html, 4, 0).iter().all(Vec::is_empty));
}
#[test]
fn rowspan_keeps_later_rows_in_their_columns() {
// A merged A1:A2 is one `rowspan=2` cell; row 2 omits it, and its text has an empty field there.
let html = r#"<table><tr><td rowspan=2 colspan=2>Merged</td><td x:num="1">1</td></tr>
<tr><td x:num="2">2</td></tr><tr><td x:num="3">3</td><td>c</td></tr></table>"#;
let rows = table_cells(html, 3, 3);
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [3, 3, 2]);
assert_eq!(rows[0][2].num, Some(1.0));
assert_eq!((rows[1][0].clone(), rows[1][1].clone()), (HtmlCell::default(), HtmlCell::default()));
assert_eq!(rows[1][2].num, Some(2.0));
assert_eq!(rows[2][0].num, Some(3.0), "the span ends after its second row");
}
#[test]
+3 -2
View File
@@ -815,10 +815,11 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
// only as far as the text's own shape, so spans in the markup can't expand without bound.
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
+14
View File
@@ -78,6 +78,20 @@ fn pasted_currency_text_keeps_the_number_from_the_clipboard_html() {
assert_eq!(v(&s, "A4"), Value::text("Rp10,000.00"));
}
#[test]
fn pasting_html_with_huge_spans_reads_only_the_text_shape() {
// #2171: a web page's clipboard HTML with enormous colspan/rowspan crashed paste (OOM). The
// HTML is now read only as far as the tab-separated text reaches; its numbers still apply.
let mut s = s();
let row = "<tr><td colspan=16384 rowspan=1048576 x:num='5'>Rp5</td><td colspan=999999999999>x</td></tr>";
let html = format!("<table>{}</table>", row.repeat(100_000));
let r = s.execute("edit.paste", json!({"at": "B2", "text": "Rp5\tnext\nlast", "html": html})).unwrap();
assert_eq!(r, json!({"rows": 2, "cols": 2}));
assert_eq!(v(&s, "B2"), Value::number(5.0));
assert_eq!(v(&s, "C2"), Value::text("next"));
assert_eq!(v(&s, "B3"), Value::text("last"));
}
#[test]
fn editing_a_name_renames_it_in_formulas() {
// Name Manager › Edit…: `oldName` edits the name in place; formulas that use it follow.