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
@@ -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`) |@@ -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),@@ -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));+}@@ -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};@@ -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::*;@@ -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)?, };@@ -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();@@ -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")); } @@ -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")]]); }@@ -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); }@@ -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)));+}