collective

Browse / github.com/storytold/gridcraft / patch

Cap text at 32,767 characters like Excel (#2635)

Cell text over 32,767 characters gets in through text files, cell.set and & and is saved to a .xlsx that Excel can't open **Summary** - Text files, `cell.set` and the `&` operator can each put more than 32,767 characters into one cell. - GridCraft saves such a cell to `.xlsx` without a warning, and Excel then can't open the file. - Excel itself never lets a cell hold more than 32,767 characters. - #2532 covers `.xlsx` files that already contain such text: the text is kept whole and a warning is shown. #2539 covers what Save should do after such an open. Neither covers the three ways listed here, which create the long text without any warning. **Measured** GridCraft at `main` f6b0cec6 (#2559 head 47e40a5f for t

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2656, open)
Base
11bb67386c9bb19b0717e21e98adc608162ae2e5
Size
+115 −26 in 11 files
Review
pending
Shared
2026-10-28
Id
e4bee9914d5c037a4694f1c4

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/calc/src/eval/array.rs crates/calc/src/tests.rs crates/core/src/lib.rs crates/core/src/value.rs crates/engine/src/cmd/edit/cell.rs crates/engine/src/cmd/edit/tests.rs crates/engine/src/tests.rs crates/functions/src/text.rs crates/xlsx/src/csv.rs crates/xlsx/src/tests/csv_tests.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | What landed | |---|---|+| 2026-10-26 | Text past 32,767 characters behaves like Excel (#2635): `&` and CONCATENATE cut their result there, CONCAT and TEXTJOIN give `#CALC!`, and a typed or CSV-read cell is cut, so GridCraft never writes a cell Excel refuses to open | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs--- a/crates/calc/src/eval/array.rs+++ b/crates/calc/src/eval/array.rs@@ -160,13 +160,7 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste             }         }),         BinOp::Concat => match (a.to_text(), b.to_text()) {-            (Ok(x), Ok(y)) => {-                if x.len() + y.len() > 32767 * 4 {-                    Value::Error(CellError::Value)-                } else {-                    Value::text(x + &y)-                }-            }+            (Ok(x), Ok(y)) => Value::text(gridcraft_core::cap_text(x + &y)),             (Err(e), _) | (_, Err(e)) => Value::Error(e),         },         BinOp::Eq => Value::Bool(compare(a, b) == Equal),diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs--- a/crates/calc/src/tests.rs+++ b/crates/calc/src/tests.rs@@ -2274,3 +2274,13 @@ fn loan_schedule_residue_keeps_excels_sign() {     }     assert_eq!(t.get("P7"), Value::Text("".into())); }++#[test]+fn concat_operator_cuts_at_the_cell_limit() {+    // Excel: LEN(REPT("x",20000)&REPT("x",20000)) is 32767, and REPT("x",32767)&"y" is 32767.+    let mut t = T::new();+    t.set("A1", "=LEN(REPT(\"x\",20000)&REPT(\"x\",20000))");+    t.set("A2", "=LEN(REPT(\"x\",32767)&\"y\")");+    t.set("A3", "=LEN(\"é\"&REPT(\"é\",32767))");+    assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (32767.0, 32767.0, 32767.0));+}diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs--- a/crates/core/src/lib.rs+++ b/crates/core/src/lib.rs@@ -14,4 +14,4 @@ pub use addr::{     split_sheet, unquote_sheet, }; pub use date::DateSystem;-pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};+pub use value::{Array, CellError, MAX_TEXT_CHARS, Value, cap_text, cap_value, compare, compare_numbers, compare_text, number_to_text, sort_compare};diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs--- a/crates/core/src/value.rs+++ b/crates/core/src/value.rs@@ -389,6 +389,27 @@ pub fn sci_text(n: f64, sig: usize) -> String {     format!("{}E{}{:02}", mant, if e < 0 { '-' } else { '+' }, e.abs()) } +/// Longest text a cell can hold, in characters.+pub const MAX_TEXT_CHARS: usize = 32_767;++/// `s` cut to [`MAX_TEXT_CHARS`] characters, as Excel cuts typed text and `&` results.+pub fn cap_text(mut s: String) -> String {+    if s.len() > MAX_TEXT_CHARS+        && let Some((i, _)) = s.char_indices().nth(MAX_TEXT_CHARS)+    {+        s.truncate(i);+    }+    s+}++/// `v` with text cut to [`MAX_TEXT_CHARS`] characters; other values pass through.+pub fn cap_value(v: Value) -> Value {+    match v {+        Value::Text(t) if t.len() > MAX_TEXT_CHARS => Value::text(cap_text(t.to_string())),+        v => v,+    }+}+ #[cfg(test)] mod tests {     use super::*;diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs--- a/crates/engine/src/cmd/edit/cell.rs+++ b/crates/engine/src/cmd/edit/cell.rs@@ -13,7 +13,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri     }     let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";     if fmt_is_text {-        return Ok(Some(Cell { value: Value::text(input), formula: None, style }));+        return Ok(Some(Cell { value: gridcraft_core::cap_value(Value::text(input)), formula: None, style }));     }     let is_formula = input.starts_with('=')         || (input.len() > 1@@ -50,6 +50,8 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri         return Ok(Some(cell));     }     let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);+    // Excel cuts typed text to 32,767 characters (after any leading apostrophe is dropped).+    parsed.value = gridcraft_core::cap_value(parsed.value);     // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already     // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.     if parsed.format.is_none()@@ -224,7 +226,7 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {                         if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {                             style = cx.wb.styles.derive(style, |s| s.align.wrap = true);                         }-                        Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })+                        Some(Cell { value: gridcraft_core::cap_value(Value::Text(text.as_str().into())), formula: None, style })                     }                     _ => input_to_cell(&input, sheet, at, &mut cx.wb)?,                 };diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs--- a/crates/engine/src/cmd/edit/tests.rs+++ b/crates/engine/src/cmd/edit/tests.rs@@ -126,6 +126,25 @@ fn go_to_keeps_the_references_entered_newest_first() {     assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}"); } +#[test]+fn cell_set_cuts_text_to_32767_characters() {+    // Excel cuts a 40,000-character entry to 32,767; a formula is left alone.+    let mut s = Session::new();+    s.new_workbook();+    s.execute("cell.set", json!({"cell": "A1", "input": "é".repeat(40_000)})).unwrap();+    s.execute("cell.set", json!({"cell": "A2", "input": "=LEN(A1)"})).unwrap();+    // An apostrophe prefix isn't text, and a text-formatted cell takes `=` as text.+    s.execute("cell.set", json!({"cell": "B1", "input": format!("'{}", "x".repeat(40_000))})).unwrap();+    s.execute("home.numberFormat", json!({"range": "C1", "code": "@"})).unwrap();+    s.execute("cell.set", json!({"cell": "C1", "input": format!("={}", "x".repeat(40_000))})).unwrap();+    let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();+    for at in ["B1", "C1"] {+        assert_eq!(get(&mut s, at).as_str().map(|t| t.chars().count()), Some(32_767), "{at}");+    }+    assert_eq!(get(&mut s, "A1").as_str().map(|t| t.chars().count()), Some(32_767));+    assert_eq!(get(&mut s, "A2"), json!(32767.0));+}+ #[test] fn cell_set_takes_numbers_for_input_and_value() {     // #697: a JSON number for `input` used to clear the cell instead of entering it.@@ -186,6 +205,11 @@ fn set_values_can_store_strings_as_the_text_they_are() {     assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());     // A line break still turns on Wrap Text.     s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();+    // Text stored as given still stops at Excel's 32,767-character cell limit.+    let long = "x".repeat(40_000);+    s.execute("range.setValues", json!({"range": "A10", "parse": false, "values": [[long]]})).unwrap();+    let len = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A10").unwrap()).map(|c| c.value.as_text().map(|t| t.chars().count()));+    assert_eq!(len.flatten(), Some(32_767));     assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));     let d = s.doc().unwrap();     let sh = d.wb.active().unwrap();diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -675,11 +675,13 @@ fn rich_clipboard_omits_html_for_large_ranges_without_truncating_text() { #[test] fn rich_clipboard_omits_html_when_escaping_exceeds_byte_budget() {     let mut s = s();-    let text = "&".repeat(1_000_000);-    s.execute("range.setValues", json!({"range": "A1", "values": [[text]]})).unwrap();-    let copied = s.execute("edit.copy", json!({"range": "A1", "html": true})).unwrap();+    // Cells hold at most 32,767 characters, so 30 full cells of `&` (5 bytes each once escaped)+    // pass the 4 MiB HTML budget while the plain text stays far below it.+    let cell = "&".repeat(32_767);+    s.execute("range.setValues", json!({"range": "A1", "values": [vec![cell; 30]]})).unwrap();+    let copied = s.execute("edit.copy", json!({"range": "A1:AD1", "html": true})).unwrap();     assert!(copied.get("html").is_none(), "an oversized escaped value must not produce partial HTML");-    assert_eq!(copied["text"].as_str().unwrap().len(), 1_000_001);+    assert_eq!(copied["text"].as_str().unwrap().len(), 30 * 32_767 + 30);     assert!(copied["text"].as_str().unwrap().ends_with("&\n")); } diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs--- a/crates/functions/src/text.rs+++ b/crates/functions/src/text.rs@@ -27,30 +27,42 @@ fn check_len(s: String) -> R<Value> { }  /// Pushes `piece` onto `out`, failing once the result exceeds the cell limit.-fn push_capped(out: &mut String, piece: &str) -> R<()> {+fn push_capped(out: &mut String, piece: &str, over: CellError) -> R<()> {     out.push_str(piece);     if out.len() > MAX_TEXT * 4 {-        return Err(CellError::Value);+        return Err(over);     }     Ok(()) } +/// CONCATENATE cuts at the cell limit like `&`; CONCAT and TEXTJOIN give `#CALC!` past it. fn concatenate(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {     let mut out = String::new();     for x in a {-        push_capped(&mut out, &scalar(x).to_text()?)?;+        // Past the budget, later arguments are still converted so their errors propagate.+        let piece = scalar(x).to_text()?;+        if out.len() <= MAX_TEXT * 4 {+            out.push_str(&piece);+        }     }-    check_len(out)+    text_val(gridcraft_core::cap_text(out))+}++fn check_calc(s: String) -> R<Value> {+    if s.len() > MAX_TEXT && s.chars().count() > MAX_TEXT {+        return Err(CellError::Calc);+    }+    text_val(s) }  fn concat(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {     let mut out = String::new();     for x in a {         for v in as_array(&x.value).iter() {-            push_capped(&mut out, &v.to_text()?)?;+            push_capped(&mut out, &v.to_text()?, CellError::Calc)?;         }     }-    check_len(out)+    check_calc(out) }  fn textjoin(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {@@ -69,11 +81,11 @@ fn textjoin(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {     let mut out = String::new();     for (i, s) in items.iter().enumerate() {         if i > 0 && !delims.is_empty() {-            push_capped(&mut out, &delims[(i - 1) % delims.len()])?;+            push_capped(&mut out, &delims[(i - 1) % delims.len()], CellError::Calc)?;         }-        push_capped(&mut out, s)?;+        push_capped(&mut out, s, CellError::Calc)?;     }-    check_len(out)+    check_calc(out) }  fn count_arg(a: &[Arg], i: usize, default: f64) -> R<usize> {@@ -680,7 +692,7 @@ fn arraytotext(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {             } else if c > 0 {                 out.push_str(if strict { "," } else { ", " });             }-            push_capped(&mut out, &value_text(arr.get(r, c).unwrap_or(&Value::Empty), strict))?;+            push_capped(&mut out, &value_text(arr.get(r, c).unwrap_or(&Value::Empty), strict), CellError::Value)?;         }     }     if strict {@@ -1094,6 +1106,16 @@ mod tests {         is_text(ev("TEXTJOIN", vec![t("-"), b(false), av(arr(vec![vec![tv("a"), tv(""), tv("b")]]))]), "a--b");         is_text(ev("TEXTJOIN", vec![av(row_t(&[",", ";"])), b(true), t("a"), t("b"), t("c")]), "a,b;c");         is_err(ev("CONCAT", vec![t("a"), e(CellError::NA)]), CellError::NA);+        // Past 32,767 characters: CONCAT and TEXTJOIN are #CALC!, CONCATENATE cuts like `&`+        // (checked in Excel: LEN(...) is #CALC!, #CALC! and 32767).+        let x = || t(&"x".repeat(20_000));+        is_err(ev("CONCAT", vec![x(), x()]), CellError::Calc);+        is_err(ev("TEXTJOIN", vec![t(","), b(false), x(), x()]), CellError::Calc);+        assert_eq!(ev("CONCATENATE", vec![x(), x()]), Value::text("x".repeat(32_767)));+        assert_eq!(ev("CONCAT", vec![x()]), Value::text("x".repeat(20_000)));+        // An error after the cut still propagates.+        let big = || t(&"x".repeat(32_767));+        is_err(ev("CONCATENATE", vec![big(), big(), big(), big(), big(), e(CellError::NA)]), CellError::NA);         // Lifting: CONCATENATE over an array.         assert_eq!(rows_of(&ev("CONCATENATE", vec![av(row_t(&["a", "b"])), t("x")])), vec![vec![tv("ax"), tv("bx")]]);     }diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs--- a/crates/xlsx/src/csv.rs+++ b/crates/xlsx/src/csv.rs@@ -288,9 +288,9 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {                     },                     None => StyleId::DEFAULT,                 };-                Cell { value: p.value, formula: None, style }+                Cell { value: gridcraft_core::cap_value(p.value), formula: None, style }             } else {-                Cell::value(Value::Text(Arc::from(f)))+                Cell::value(Value::Text(Arc::from(gridcraft_core::cap_text(f))))             };             sheet.cells.set(pos, cell);         }diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs--- a/crates/xlsx/src/tests/csv_tests.rs+++ b/crates/xlsx/src/tests/csv_tests.rs@@ -326,3 +326,16 @@ fn parser_stops_collecting_fields_at_max_cols() {     });     assert_eq!(lens, vec![MAX_COLS as usize]); }++#[test]+fn a_field_over_32767_characters_is_cut() {+    let src = format!("{},{},'{}", "x".repeat(40_000), "y".repeat(32_768), "z".repeat(40_000));+    for parse_values in [true, false] {+        let wb = read_csv(src.as_bytes(), &CsvOptions { parse_values, ..Default::default() }).unwrap();+        assert_eq!(v(&wb, 0, 0), Value::text("x".repeat(32_767)));+        assert_eq!(v(&wb, 0, 1), Value::text("y".repeat(32_767)));+    }+    // The apostrophe is dropped before the cut when values are parsed.+    let wb = read_csv(src.as_bytes(), &CsvOptions::default()).unwrap();+    assert_eq!(v(&wb, 0, 2), Value::text("z".repeat(32_767)));+}