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)));
+}
