diff --git a/ROADMAP.md b/ROADMAP.md index b78eeed77..8426126ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| +| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | | **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index de6c88258..b58586aef 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -166,14 +166,28 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { Some((n, fmt)) } +/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects +/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept. +fn plain_number(s: &str, frac: bool) -> Option { + let (int, dec) = match s.split_once('.') { + Some((i, d)) if frac => (i, Some(d)), + _ => (s, None), + }; + let digits = |x: &str| !x.is_empty() && x.bytes().all(|c| c.is_ascii_digit()); + if !digits(int) || dec.is_some_and(|d| !digits(d)) { + return None; + } + s.parse().ok() +} + /// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`. fn parse_fraction(t: &str) -> Option { let (whole, frac) = t.split_once(' ')?; let neg = whole.starts_with('-'); - let w: f64 = whole.trim_start_matches('-').parse().ok()?; + let w = plain_number(whole.trim_start_matches('-'), false)?; let (a, b) = frac.trim().split_once('/')?; - let a: f64 = a.parse().ok()?; - let b: f64 = b.parse().ok()?; + let a = plain_number(a, false)?; + let b = plain_number(b, false)?; if b == 0.0 || !w.is_finite() { return None; } @@ -204,12 +218,12 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> { if parts.len() == 1 && ampm.is_none() { return None; } - if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) { + if parts.len() > 3 || parts.iter().any(|p| p.is_empty() || p.starts_with('+')) { return None; } - let mut h: f64 = parts.first()?.parse().ok()?; - let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?; - let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?; + let mut h = plain_number(parts.first()?, false)?; + let m = parts.get(1).map_or(Some(0.0), |p| plain_number(p, false))?; + let s = parts.get(2).map_or(Some(0.0), |p| plain_number(p, true))?; if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 { return None; } @@ -231,7 +245,13 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> { fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { let t = t.trim().trim_end_matches(','); - let num = |s: &str| s.trim().parse::().ok(); + let num = |s: &str| { + let s = s.trim(); + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse::().ok() + }; // ISO yyyy-mm-dd or yyyy/mm/dd let seps: &[char] = &['/', '-', '.']; let parts: Vec<&str> = t.split(seps).collect(); @@ -346,6 +366,21 @@ mod tests { parse_input(s, DateSystem::D1900) } + #[test] + fn non_digit_time_and_fraction_parts_stay_text() { + for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] { + assert_eq!(p(s).value, Value::text(s), "{s}"); + } + } + + #[test] + fn signed_date_parts_stay_text() { + for s in ["+3/4/2026", "3/+4/2026", "3/4/+2026", "1/1/2020 +5:00"] { + assert_eq!(p(s).value, Value::text(s), "{s}"); + } + assert_eq!(p("3/4/2026").value, Value::Number(46085.0)); + } + #[test] fn month_name_short_year() { for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] { diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index ca7667694..d208155df 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,44 @@ mod tests { "