From b817c04a9bacf9c0d3a9d548002c88d52594b4ff Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 21:06:51 -0400 Subject: [PATCH 1/2] clipboard: bound HTML colspan/rowspan expansion (#2171) Clipboard HTML is read only as far as the pasted text's shape (clamped to the sheet) and at most 1,000,000 cells; colspan/rowspan are clamped to the sheet extent before expanding, and rowspan now keeps later rows in their columns. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_html.rs | 210 ++++++++++++++++++++++++++------- crates/engine/src/cmd/edit.rs | 5 +- crates/engine/src/tests.rs | 14 +++ 3 files changed, 185 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index ca7667694..044421a9d 100644 --- a/crates/engine/src/clip_html.rs +++ b/crates/engine/src/clip_html.rs @@ -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, } -/// 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> { - let mut rows: Vec> = 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> { + 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 = 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("Rp10,000.00Name 2.50 & upx"#; - 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#"
Rp10,000.00
"#; - 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#"
1500
"#; - 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,46 @@ mod tests { "