mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 12:16:54 -06:00
This commit is contained in:
+166
-42
@@ -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 & 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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user