From d9d09f0baa912e88c5111107903fc8c640214b57 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 09:33:30 -0400 Subject: [PATCH 01/14] Release 0.7.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1815ac7e7..6d618a02a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.6.1" +version = "0.7.0" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.6.1" +version = "0.7.0" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.6.1" +version = "0.7.0" [[package]] name = "gridcraft-ui-egui" -version = "0.6.1" +version = "0.7.0" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.6.1" +version = "0.7.0" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.6.1" +version = "0.7.0" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0bfee246d..98dd572e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.6.1" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From e6b6b349708ab4978cb3f061f6bd6ecbc25667cb Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Wed, 14 Oct 2026 21:16:03 -0600 Subject: [PATCH 02/14] ci: add comfortable runtime limits to unbounded jobs (#1418) Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- .github/workflows/flatpak-pages.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml index cd8f12ab3..d08ac430a 100644 --- a/.github/workflows/flatpak-pages.yml +++ b/.github/workflows/flatpak-pages.yml @@ -19,6 +19,7 @@ concurrency: jobs: build: + timeout-minutes: 60 name: Build Flatpak repository # Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped # here, and the validation step below refuses a manual tag with a pre-release suffix. @@ -161,6 +162,7 @@ jobs: include-hidden-files: true deploy: + timeout-minutes: 60 name: Deploy Pages needs: build runs-on: ubuntu-24.04 From 79e85c1b806c6581ab2ab4b873a8ae204d9b7644 Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:38:13 -0400 Subject: [PATCH 03/14] fix(edit): recalculate every cell changed by large clears (#1032) (#1128) --- crates/engine/src/cmd/edit.rs | 25 +++++- .../engine/tests/sparse_clear_invalidation.rs | 87 +++++++++++++++++++ docs/function-parity.md | 4 +- 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/sparse_clear_invalidation.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..c69bac208 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1246,6 +1246,9 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }; let cells: Vec<(CellRef, Cell)> = cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default(); + // Report every stored cell we touch, even beyond a million coordinates in a sparse + // selection. Removed formula anchors must also reach calc so their spills disappear. + cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c))); let mut cells = cells; if what == "contents" { for (_, cell) in cells.iter_mut() { @@ -1284,10 +1287,24 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { sh.hyperlinks.retain(|c, _| !r.contains(*c)); } if matches!(what, "all" | "formats") { - sh.merges.retain(|m| !r.contains_range(m)); - } - for c in r.iter().take(1_000_000) { - cx.changed.push((sheet, c)); + let mut unmerged = false; + sh.merges.retain(|m| { + let keep = !r.contains_range(m); + unmerged |= !keep; + keep + }); + if unmerged { + // An empty merge can block a spill without storing any cells in this range. + // As in Unmerge, retry blocked formula anchors that could reach the freed area. + let reach = RangeRef::new(CellRef::new(0, 0), r.end); + let blocked: Vec<_> = sh + .cells + .iter_range(reach) + .filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill)) + .map(|(c, _)| (sheet, c)) + .collect(); + cx.changed.extend(blocked); + } } } Ok(Json::Null) diff --git a/crates/engine/tests/sparse_clear_invalidation.rs b/crates/engine/tests/sparse_clear_invalidation.rs new file mode 100644 index 000000000..3ac8f77e3 --- /dev/null +++ b/crates/engine/tests/sparse_clear_invalidation.rs @@ -0,0 +1,87 @@ +//! Clearing a large sparse selection must invalidate every changed cell (issue #1032). + +use gridcraft_core::{CellError, CellRef, Value}; +use gridcraft_engine::Session; +use serde_json::json; + +fn value(s: &Session, address: &str) -> Value { + s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(address).unwrap()) +} + +#[test] +fn large_clear_updates_distant_direct_and_transitive_dependents() { + for command in ["edit.clearContents", "edit.clearAll"] { + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1000001", "7"), ("B1", "=A1000001"), ("C1", "=B1*2")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0))); + + // B1 stretches the used range to row 1; the changed source lies just past the old cap. + s.execute(command, json!({"range": "A1:A1000001"})).unwrap(); + assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}"); + assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command}"); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(value(&s, "A1000001"), Value::Number(7.0), "{command} undo"); + assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)), "{command} undo"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(value(&s, "A1000001"), Value::Empty, "{command} redo"); + assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command} redo"); + } +} + +#[test] +fn large_clear_removes_a_distant_formulas_spill_and_updates_its_dependent() { + for command in ["edit.clearContents", "edit.clearAll"] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1000001", "input": "=SEQUENCE(2)"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=A1000002"})).unwrap(); + assert_eq!((value(&s, "A1000002"), value(&s, "B1")), (Value::Number(2.0), Value::Number(2.0))); + + // The spill lies outside the selection, but must disappear when its anchor is cleared. + s.execute(command, json!({"range": "A1:A1000001"})).unwrap(); + assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}"); + assert_eq!(value(&s, "A1000002"), Value::Empty, "{command}"); + assert_eq!(value(&s, "B1"), Value::Number(0.0), "{command}"); + } +} + +#[test] +fn clearing_an_empty_merge_unblocks_a_spill() { + for command in ["edit.clearFormats", "edit.clearAll"] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap(); + // Keep the empty merged area inside the used range after the spill becomes blocked. + s.execute("cell.set", json!({"cell": "C4", "input": "1"})).unwrap(); + s.execute("home.mergeCells", json!({"range": "A2:B3"})).unwrap(); + assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill)); + + s.execute(command, json!({"range": "A2:B3"})).unwrap(); + assert_eq!((value(&s, "A1"), value(&s, "A3")), (Value::Number(1.0), Value::Number(3.0)), "{command}"); + assert!(s.doc().unwrap().wb.active().unwrap().merges.is_empty(), "{command}"); + } +} + +#[test] +fn clearing_a_distant_styled_blocker_unblocks_a_spill() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(100)"})).unwrap(); + s.execute("cell.set", json!({"cell": "A90", "input": "x"})).unwrap(); + s.execute("home.bold", json!({"range": "A90", "on": true})).unwrap(); + assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill)); + + // The cleared blocker stays stored for its style and lies beyond the local 64-row scan. + s.execute("edit.clearContents", json!({"range": "A2:A100"})).unwrap(); + assert_eq!(value(&s, "A1"), Value::Number(1.0)); + assert_eq!(value(&s, "A90"), Value::Number(90.0)); + assert_eq!(value(&s, "A100"), Value::Number(100.0)); + let wb = &s.doc().unwrap().wb; + let cell = wb.active().unwrap().cell(CellRef::parse("A90").unwrap()).unwrap(); + assert_eq!(cell.value, Value::Empty); + assert!(wb.styles.get(cell.style).font.bold); +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..274800699 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -76,6 +76,7 @@ PIVOTBY, REGEXTEST…) are all present. | Structured references (`Table1[Col]`, `[#Totals]`, `@`; the unbracketed `[[#This Row],Col]` some writers emit) | done | | Defined names (workbook and sheet scope), Create from Selection | done | | Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) | +| Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) | | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | | Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too | | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done | @@ -108,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | From 82ee02aac475e6e217b0dba3914d5b7f8081f6f1 Mon Sep 17 00:00:00 2001 From: Nihilist Narwhal Date: Thu, 15 Oct 2026 14:39:28 -0500 Subject: [PATCH 04/14] Formula parser: Excel's 8,192-character limit and postfix nesting (never crash) (#1330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hostile file could abort any process that opens it: a cell formula of a million `%` (`1%%%…`) or a 200,000-term sum parsed into trees deep enough to overflow the stack when printed, saved, evaluated or dropped, even in release builds. Postfix operators (`%`, invoking a result) built their nesting in a loop that the 256-level limit did not see. - `parse` refuses formula text longer than Excel's 8,192 characters (`MAX_FORMULA_LEN`, counted in characters, the leading `=` excluded). This also bounds the depth of the left-deep trees long operator chains build. - Each postfix `%` and each call of a result counts towards the nesting limit. - A file's formula that is refused keeps its text and cached value, as other unparsable formulas do. Regression tests: formula (small-stack thread: long `%`, invoke and operator chains are errors; the limit counts characters, not bytes) and xlsx (`hostile_formula_text_does_not_overflow_the_stack`: open, save and drop a workbook with such formulas and a defined name on a 1 MiB stack). Both abort with a stack overflow without the fix. Co-authored-by: Claude Opus 5.5 --- crates/formula/src/lib.rs | 2 +- crates/formula/src/parser.rs | 19 ++++++++++++++++ crates/formula/src/tests.rs | 35 ++++++++++++++++++++++++++++++ crates/xlsx/src/tests/malformed.rs | 29 +++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index e193c3fb4..88d09a8af 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -12,7 +12,7 @@ pub mod printer; pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp}; pub use input::{FormulaLocale, parse_input, print_input}; -pub use parser::{ParseError, normalize_function_name, parse}; +pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse}; pub use printer::{print, print_r1c1, quote_sheet}; #[cfg(test)] diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 3ea97085e..555717be9 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -17,6 +17,11 @@ pub struct ParseError { pub(crate) const MAX_DEPTH: usize = 256; +/// Longest formula accepted, in characters: Excel's limit for formula contents. It also bounds +/// the depth of the trees long operator chains build (`1+1+…`), which dropping and evaluating +/// walk recursively; without it a formula of a few hundred kilobytes in a file overflows the stack. +pub const MAX_FORMULA_LEN: usize = 8_192; + /// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped /// for `=`. pub fn parse(src: &str) -> Result { @@ -25,6 +30,9 @@ pub fn parse(src: &str) -> Result { pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result { let body = src.strip_prefix('=').unwrap_or(src); + if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN { + return Err(ParseError { msg: format!("formula is longer than {MAX_FORMULA_LEN} characters"), pos: 0 }); + } let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; let toks = significant_spaces(toks); let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es }; @@ -160,11 +168,18 @@ impl Parser { return Err(self.err("formula is nested too deeply")); } let mut lhs = self.prefix(allow_union)?; + // Postfix operators (`%`, invoking a result) wrap `lhs` without recursing: they count + // towards the nesting limit, so `1%%%…` can't build a tree deeper than it. + let mut postfix = 0; loop { if *self.peek() == Tok::Op("%") { if 70 < min_bp { break; } + postfix += 1; + if self.depth + postfix > MAX_DEPTH { + return Err(self.err("formula is nested too deeply")); + } self.next(); lhs = Expr::Unary(UnOp::Percent, Box::new(lhs)); continue; @@ -174,6 +189,10 @@ impl Parser { if let Expr::Name(_) = lhs { // A name followed by '(' was lexed as Func; this is `(name)(…)` only. } + postfix += 1; + if self.depth + postfix > MAX_DEPTH { + return Err(self.err("formula is nested too deeply")); + } self.next(); let args = self.args()?; lhs = Expr::Invoke(Box::new(lhs), args); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 103a26bd1..d23eba0e2 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -366,3 +366,38 @@ fn prints_long_flat_chain_without_deep_recursion() { assert_eq!(text, vec!["1"; 20001].join("+")); assert_eq!(r1c1, text); } + +#[test] +fn hostile_formulas_are_errors_not_stack_overflows() { + // A small stack: parsing must fail cleanly instead of building trees deeper than the nesting + // limit, which dropping, printing and evaluating walk recursively. + let handle = std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(|| { + let percents = format!("1{}", "%".repeat(1_000)); + let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000)); + let long_chain = format!("1{}", "+1".repeat(100_000)); + let huge_percents = format!("1{}", "%".repeat(1_000_000)); + (parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err()) + }) + .expect("spawn"); + assert_eq!(handle.join().expect("parsing must not overflow the stack"), (true, true, true, true)); + // Within the limits these still parse. + assert!(parse(&format!("1{}", "%".repeat(100))).is_ok()); + assert!(parse("LAMBDA(x,x)(1)(2)").is_ok()); +} + +#[test] +fn formulas_longer_than_excel_allows_are_refused() { + let at_limit = format!("1{}", "+1".repeat((crate::MAX_FORMULA_LEN - 1) / 2)); + assert_eq!(at_limit.chars().count(), crate::MAX_FORMULA_LEN - 1); + assert!(parse(&at_limit).is_ok()); + assert!(parse(&format!("={at_limit}")).is_ok(), "the leading = does not count"); + let over = format!("{at_limit}+1"); + let err = parse(&over).unwrap_err(); + assert!(err.msg.contains("8192"), "{}", err.msg); + // Characters, not bytes: 8,000 two-byte characters in a text literal are within the limit. + let text = format!("\"{}\"", "é".repeat(8_000)); + assert!(text.len() > crate::MAX_FORMULA_LEN); + assert!(parse(&text).is_ok()); +} diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 5e47c1f4b..d029acaa4 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -227,3 +227,32 @@ fn hyperlinks_past_the_cell_cap_are_reported() { assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 3); assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); } + +/// Formula text built to overflow a recursive parser or tree walk (a million `%`, a 200,000-term +/// sum): the cells keep their formula text and cached value, nothing overflows the stack. +#[test] +fn hostile_formula_text_does_not_overflow_the_stack() { + let percents = format!("1{}", "%".repeat(1_000_000)); + let chain = format!("1{}", "+1".repeat(100_000)); + let body = format!(r#"{percents}3{chain}4"#); + let names = format!(r#"{percents}"#); + let bytes = minimal(&body, &[], "", &names); + let handle = std::thread::Builder::new() + .stack_size(1024 * 1024) + .spawn(move || { + let (wb, _) = read_xlsx(&bytes).expect("readable"); + let s = wb.sheet(0).expect("sheet"); + let a1 = s.cell(gridcraft_core::CellRef::new(0, 0)).and_then(|c| c.formula.clone()).expect("formula kept"); + let b1 = s.cell(gridcraft_core::CellRef::new(0, 1)).and_then(|c| c.formula.clone()).expect("formula kept"); + let kept = (a1.expr.is_none(), b1.expr.is_none(), a1.text.len(), s.value(gridcraft_core::CellRef::new(0, 0))); + assert_eq!(wb.names.first().map(|n| n.formula.len()), Some(1_000_001)); + let out = write_xlsx(&wb).expect("writable"); + drop(wb); + (kept, !out.is_empty()) + }) + .expect("spawn"); + let ((a_unparsed, b_unparsed, a_len, a_value), written) = handle.join().expect("no stack overflow"); + assert!(a_unparsed && b_unparsed && written); + assert_eq!(a_len, 1_000_001); + assert_eq!(a_value, gridcraft_core::Value::Number(3.0)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 988472414..158149fb3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | From fda8e7b703dbe24f9fc09fc98c00ef1307577e7f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 01:04:00 +0200 Subject: [PATCH 05/14] AutoFilter: Enter in the filter menu applies the filter and closes it (fixes #1516) --- crates/ui-egui/src/grid.rs | 25 +++++-- crates/ui-egui/tests/filter_menu_enter.rs | 87 +++++++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/filter_menu_enter.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..00e0c18be 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2573,7 +2573,8 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: resp.request_focus(); } let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); - if editing || app.dialog.is_some() || other_focus || app.message.is_some() { + // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). + if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() { return; } if ctx.memory(|m| m.focused()).is_none() { @@ -2768,16 +2769,24 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); let key = egui::Id::new(("filter_sel", col)); let search_key = egui::Id::new(("filter_search", col)); + let buttons_key = egui::Id::new(("filter_buttons", col)); + // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its + // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. + let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); + let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); + let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); let mut close = false; let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); let response = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(230.0); - if ui.button("↑ Sort Ascending").clicked() { + let ascending = ui.button("↑ Sort Ascending"); + if ascending.clicked() { let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)})); close = true; } - if ui.button("↓ Sort Descending").clicked() { + let descending = ui.button("↓ Sort Descending"); + if descending.clicked() { let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)})); close = true; } @@ -2814,11 +2823,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); ui.separator(); ui.horizontal(|ui| { - if ui.button("Clear Filter").clicked() { + let clear = ui.button("Clear Filter"); + if clear.clicked() { let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); close = true; } - if ui.button("Apply").clicked() { + // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then. + let any = checks.iter().any(|(_, on)| *on); + let apply = ui.button("Apply"); + ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id])); + if apply.clicked() || (enter && any) { let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on); let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect(); let p = if checks.iter().all(|(_, b)| *b) { @@ -2836,6 +2850,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) ui.ctx().data_mut(|d| { d.remove::>(key); d.remove::(search_key); + d.remove::>(buttons_key); }); app.grid.filter_menu = None; } diff --git a/crates/ui-egui/tests/filter_menu_enter.rs b/crates/ui-egui/tests/filter_menu_enter.rs new file mode 100644 index 000000000..bd45a2cc1 --- /dev/null +++ b/crates/ui-egui/tests/filter_menu_enter.rs @@ -0,0 +1,87 @@ +//! Enter in the AutoFilter menu works like its Apply button, as Excel's OK (issue #1516): from the +//! search box or with a value focused it applies the filter and closes the menu, and the key never +//! reaches the grid underneath. + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// A1:A4 holds a header and three fruits under an AutoFilter, with the menu opened by Alt+Down on A1. +fn filter_menu_open() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Fruit"], ["Apple"], ["Banana"], ["Cherry"]]})).unwrap(); + session.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert!(h.state().grid.filter_menu.is_some(), "Alt+Down opens the filter menu"); + h +} + +fn key(h: &mut Harness, k: Key, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +/// Rows 2..=4 (Apple, Banana, Cherry): which ones the filter hides. +fn hidden(h: &Harness) -> Vec { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + (1..=3).map(|r| sh.is_row_hidden(r)).collect() +} + +fn active(h: &Harness) -> String { + h.state().session.active().unwrap().selection.active.a1() +} + +#[test] +fn enter_in_the_search_box_applies_the_filter() { + let mut h = filter_menu_open(); + h.input_mut().events.push(Event::Text("an".into())); + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(hidden(&h), [true, false, true], "only Banana matches the search"); + assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu"); + assert_eq!(active(&h), "A1", "Enter must not move the active cell"); +} + +#[test] +fn enter_with_a_value_clicked_applies_the_filter() { + let mut h = filter_menu_open(); + let apple = h.get_by_label("Apple").rect().center(); + h.input_mut().events.push(Event::PointerMoved(apple)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: apple, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(2); + assert!(h.state().grid.filter_menu.is_some(), "clicking a value keeps the menu open"); + key(&mut h, Key::Enter, Modifiers::NONE); + // Enter applies the list as it is: it must not toggle the value back on. + assert_eq!(hidden(&h), [true, false, false], "Apple was unchecked"); + assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu"); + assert_eq!(active(&h), "A1", "Enter must not move the active cell"); +} + +#[test] +fn enter_with_nothing_checked_keeps_the_menu_open() { + let mut h = filter_menu_open(); + // A search that matches nothing leaves no value to show, as Excel's OK is then disabled. + h.input_mut().events.push(Event::Text("Bananna".into())); + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(hidden(&h), [false, false, false], "an empty selection must not hide every row"); + assert!(h.state().grid.filter_menu.is_some(), "the menu stays open"); + assert_eq!(active(&h), "A1"); +} From e91e7f7a68c46e61e222291f37e4eefb2781ec30 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 19:37:36 -0400 Subject: [PATCH 06/14] ci: delegate FreeBSD workflow, add nix check, and remove flatpak-pages --- .github/workflows/flatpak-pages.yml | 178 ---------------------------- .github/workflows/freebsd.yml | 94 ++------------- .github/workflows/nix.yml | 33 ++++++ 3 files changed, 46 insertions(+), 259 deletions(-) delete mode 100644 .github/workflows/flatpak-pages.yml create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml deleted file mode 100644 index d08ac430a..000000000 --- a/.github/workflows/flatpak-pages.yml +++ /dev/null @@ -1,178 +0,0 @@ -name: Publish Flatpak repository to Pages - -on: - release: - types: [published] - workflow_dispatch: - inputs: - release_tag: - description: "Published GitHub release tag to publish (for example, v1.2.3)" - required: true - type: string - -permissions: - contents: read - -concurrency: - group: flatpak-pages-${{ github.repository }} - cancel-in-progress: false - -jobs: - build: - timeout-minutes: 60 - name: Build Flatpak repository - # Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped - # here, and the validation step below refuses a manual tag with a pre-release suffix. - if: github.event_name != 'release' || !github.event.release.prerelease - runs-on: ubuntu-24.04 - permissions: - contents: read - # configure-pages only reads the Pages configuration (base_url); deploy needs write. - pages: read - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} - APP_ID: ai.storyteller.gridcraft - steps: - - name: Configure Pages - id: pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Install Flatpak repository tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak ostree - - - name: Download and validate release bundles - shell: bash - run: | - set -euo pipefail - tag="$RELEASE_TAG" - if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" - exit 1 - fi - # A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1). - case "${tag%%+*}" in - *-*) - echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository" - exit 1 - ;; - esac - published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ - 'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')" - if [[ "$published_tag" != "$tag" ]]; then - echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY" - exit 1 - fi - - version="${tag#v}" - mkdir -p bundles - gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ - --pattern SHA256SUMS.txt --dir bundles - for arch in x86_64 aarch64; do - asset="gridcraft-$version-linux-$arch.flatpak" - gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ - --pattern "$asset" --dir bundles - test -s "bundles/$asset" - ( - cd bundles - awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \ - SHA256SUMS.txt | sha256sum --check - - ) - done - test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2 - - for arch in x86_64 aarch64; do - asset="bundles/gridcraft-$version-linux-$arch.flatpak" - validation_repo="$(mktemp -d)" - ostree --repo="$validation_repo" init --mode=archive-z2 - flatpak build-import-bundle "$validation_repo" "$asset" - expected_ref="app/$APP_ID/$arch/stable" - ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || { - echo "::error::$(basename "$asset") does not contain $expected_ref" - exit 1 - } - rm -rf "$validation_repo" - done - - - name: Build repository and site - shell: bash - env: - PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }} - run: | - set -euo pipefail - site="$RUNNER_TEMP/site" - repo_dir="$site/flatpak/repo" - mkdir -p "$repo_dir" - ostree --repo="$repo_dir" init --mode=archive-z2 - - previous_url="${PAGES_BASE_URL%/}/flatpak/repo" - if curl --fail --silent --show-error --location \ - --output /dev/null "$previous_url/summary"; then - ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url" - ostree --repo="$repo_dir" pull --mirror previous - ostree --repo="$repo_dir" remote delete previous - fi - - version="${RELEASE_TAG#v}" - for arch in x86_64 aarch64; do - flatpak build-import-bundle "$repo_dir" \ - "bundles/gridcraft-$version-linux-$arch.flatpak" - done - # Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository - # (re-mirrored and re-deployed on every release) does not grow without bound. - flatpak build-update-repo \ - --prune --prune-depth=3 \ - --title="GridCraft" \ - --comment='Official GridCraft releases' \ - --description='Official Flatpak repository for GridCraft' \ - --homepage="https://github.com/$GITHUB_REPOSITORY" \ - --default-branch=stable \ - "$repo_dir" - - for arch in x86_64 aarch64; do - ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null - done - test -s "$repo_dir/summary" - - base_url="${PAGES_BASE_URL%/}" - repo_url="$base_url/flatpak/repo/" - cat >"$site/flatpak/gridcraft.flatpakrepo" <"$site/flatpak/index.html" <GridCraft Flatpak repository

GridCraft Flatpak repository

Latest published release: $RELEASE_TAG

Add the GridCraft repository

flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
-          flatpak install gridcraft $APP_ID
- HTML - cat >"$site/index.html" <GridCraft downloads

GridCraft downloads

Flatpak repository

Release assets

- HTML - touch "$site/.nojekyll" - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: ${{ runner.temp }}/site - include-hidden-files: true - - deploy: - timeout-minutes: 60 - name: Deploy Pages - needs: build - runs-on: ubuntu-24.04 - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0a82a910f..6cdde6e47 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,95 +1,27 @@ # Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. -# Test builds run only from the `release` branch (a manual run from any other branch is skipped). -# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run -# from a branch builds the tarball too; they need no secrets. -# -# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the -# release binaries and uploads `gridcraft--freebsd-x86_64.tar.gz` as the -# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the -# draft GitHub Release. name: FreeBSD on: + push: + branches: [release] + pull_request: + paths: + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh workflow_dispatch: - inputs: - package: - description: "Build the release tarball instead of running the tests" - type: boolean - default: false - workflow_call: - inputs: - version: - description: "Version for the artifact name (empty: Cargo.toml)" - type: string - default: "" - version_override: - description: "Also write the version into Cargo.toml before building (test runs)" - type: boolean - default: false - package: - type: boolean - default: false concurrency: - # Called from release.yml, github.workflow is the caller's name; keep the groups apart. - group: freebsd-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.workflow == 'FreeBSD' }} + group: freebsd-${{ github.ref }} + cancel-in-progress: true permissions: contents: read jobs: freebsd: - name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }}) - if: github.ref == 'refs/heads/release' || inputs.package - runs-on: ubuntu-latest - timeout-minutes: 300 - env: - GRIDCRAFT_VERSION: ${{ inputs.version }} - GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }} - GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }} - GRIDCRAFT_BUILD_SHA: ${{ github.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Build in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA" - # Only the packaging run brings files back (dist/release). The target dir lives outside - # the synced checkout, because copying it out of the VM fails on large builds. - copyback: ${{ inputs.package }} - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0 - rustc --version - if [ -n "$GRIDCRAFT_PACKAGE" ]; then - if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then - cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - fi - CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh - else - cargo build --workspace --all-targets --locked - CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked - fi - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: inputs.package - with: - name: release-freebsd - path: dist/release/* - if-no-files-found: error + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..18392587a --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: | + if [ -f flake.nix ]; then + nix flake check --no-build --all-systems + else + echo "No flake.nix present in this repository; skipping check." + fi From 27932a2ae4e462d1597fda5395ec18af638aab00 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:26:31 +0000 Subject: [PATCH 07/14] Close control connections on the first line that is not a request A line that is not a JSON object with a string method, including an HTTP request line, bytes that are not UTF-8 or a line over 64 MiB, gets one error reply and the connection closes before anything after it is read. Request lines are read with a bound instead of buffering them whole. --- apps/gridcraft/src/control_server.rs | 216 ++++++++++++++++++++++----- docs/control-protocol.md | 4 + 2 files changed, 186 insertions(+), 34 deletions(-) diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index e9e05a2e6..4c57ccf41 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -1,7 +1,11 @@ //! Localhost JSON-lines control server. Each connection may send many requests; each line //! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`. +//! +//! The server only reads requests: a line that is not a JSON object with a string `method` (an +//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is +//! closed, so nothing sent after it runs. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; @@ -13,6 +17,13 @@ const MAX_LINE: usize = 64 * 1024 * 1024; pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> { let listener = TcpListener::bind(("127.0.0.1", port))?; + let rx = serve_listener(listener, ctx)?; + log::info!("control channel listening on 127.0.0.1:{port}"); + Ok(rx) +} + +/// Accept connections on `listener`; requests arrive on the returned receiver. +fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result> { let (tx, rx) = channel(); std::thread::Builder::new().name("control".into()).spawn(move || { for stream in listener.incoming().flatten() { @@ -21,47 +32,184 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result, max: usize) -> Result { + buf.clear(); + // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + if n == 0 { + return Ok(false); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + return Ok(true); + } + if buf.len() > max { + return Err(format!("request too large (over {max} bytes); closing the connection")); + } + // Last line without a newline, then end of stream. + Ok(true) +} + +/// One request line, parsed. +enum Line { + Request { + id: Json, + method: String, + params: Json, + }, + Blank, + /// Not a request: reply with this error and close the connection. + Reject(String), +} + +fn parse_line(bytes: &[u8]) -> Line { + let Ok(text) = std::str::from_utf8(bytes) else { + return Line::Reject("not a control request (invalid UTF-8); closing the connection".into()); + }; + let text = text.trim(); + if text.is_empty() { + return Line::Blank; + } + if is_http_request_line(text) { + return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into()); + } + let msg = match serde_json::from_str::(text) { + Ok(v) => v, + Err(e) => return Line::Reject(format!("invalid JSON ({e}); expected one JSON request object per line; closing the connection")), + }; + let Some(obj) = msg.as_object() else { + return Line::Reject("not a control request (expected a JSON object); closing the connection".into()); + }; + let Some(method) = obj.get("method").and_then(Json::as_str) else { + return Line::Reject("not a control request (missing string `method`); closing the connection".into()); + }; + Line::Request { + id: obj.get("id").cloned().unwrap_or(Json::Null), + method: method.to_string(), + params: obj.get("params").cloned().unwrap_or(json!({})), + } +} + +/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`). +fn is_http_request_line(text: &str) -> bool { + const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"]; + text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method)) + || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) +} + fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - let reader = BufReader::new(read); - for line in reader.lines() { - let Ok(line) = line else { break }; - if line.trim().is_empty() { - continue; - } - if line.len() > MAX_LINE { - let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"})); - continue; - } - let msg: Json = match serde_json::from_str(&line) { - Ok(v) => v, - Err(e) => { - let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")})); - continue; - } + let mut reader = BufReader::new(read); + let mut buf = Vec::new(); + loop { + let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { + Ok(false) => break, + // The peer is gone before sending a line: nobody to tell. + Err(_) if buf.is_empty() => break, + Err(e) => e, + Ok(true) => match parse_line(&buf) { + Line::Blank => continue, + Line::Reject(e) => e, + Line::Request { id, method, params } => { + let (req, rx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + // Wake the UI thread: egui only runs frames when something happens. + ctx.request_repaint(); + let mut reply = rx + .recv_timeout(Duration::from_secs(60)) + .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})); + if let Some(o) = reply.as_object_mut() { + o.insert("id".into(), id); + } + if writeln!(out, "{reply}").is_err() { + break; + } + continue; + } + }, }; - let id = msg.get("id").cloned().unwrap_or(Json::Null); - let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string(); - let params = msg.get("params").cloned().unwrap_or(json!({})); - let (req, rx) = ControlRequest::new(method, params); - if tx.send(req).is_err() { - break; + // The connection closes either way; a peer that can't read the reason loses nothing. + let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})); + break; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Send `input` on a new connection and read reply lines until the server closes it. + fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec { + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(input).unwrap(); + // A read timeout ends the list too: the server kept the connection open. + BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect() + } + + fn serve_on_loopback() -> (std::net::SocketAddr, Receiver) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + (addr, serve_listener(listener, egui::Context::default()).unwrap()) + } + + /// A browser can POST to the port: its request line closes the connection before the JSON + /// line in its body is read, so nothing is dispatched. + #[test] + fn an_http_request_closes_the_connection_before_its_body_is_dispatched() { + let (addr, rx) = serve_on_loopback(); + let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"x\"}}}\n"; + let replies = exchange(addr, http); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}"); + assert!(rx.try_recv().is_err(), "the body was dispatched"); + } + + #[test] + fn a_line_that_is_not_a_request_closes_the_connection() { + let (addr, rx) = serve_on_loopback(); + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true})); + } + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap(); + let mut r = BufReader::new(c); + for id in [1, 2] { + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], id, "{line}"); } - // Wake the UI thread: egui only runs frames when something happens. - ctx.request_repaint(); - let mut reply = rx - .recv_timeout(Duration::from_secs(60)) - .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})); - if let Some(o) = reply.as_object_mut() { - o.insert("id".into(), id); + for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] { + let mut input = junk.to_vec(); + input.extend_from_slice(b"{\"id\":2,\"method\":\"b\"}\n"); + let replies = exchange(addr, &input); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); } - if writeln!(out, "{reply}").is_err() { - break; - } + assert!(is_http_request_line("OPTIONS * HTTP/1.1")); + assert!(is_http_request_line("PRI * HTTP/2.0")); + assert!(!is_http_request_line("{\"method\":\"GET /\"}")); + } + + #[test] + fn an_overlong_line_is_refused_without_reading_the_rest() { + let mut buf = Vec::new(); + let long = [b'x'; 100]; + let mut input: &[u8] = &long; + assert!(read_bounded_line(&mut input, &mut buf, 10).is_err()); + assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread"); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..e82ae7299 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -15,6 +15,10 @@ Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets on Errors come back as `{"id": …, "ok": false, "error": "…"}`. +A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON, +bytes that aren't UTF-8, a line over 64 MiB) gets one error reply and the server closes the +connection; nothing sent after it runs. Blank lines are skipped. + ## Methods | Method | Params | What it does | From 823c56582d00be91e31b4f7f3680496cf89e84fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 17 Oct 2026 20:03:36 -0400 Subject: [PATCH 08/14] Release 0.8.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d618a02a..17ebf848c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.7.0" +version = "0.8.0" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.7.0" +version = "0.8.0" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.7.0" +version = "0.8.0" [[package]] name = "gridcraft-ui-egui" -version = "0.7.0" +version = "0.8.0" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.7.0" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.7.0" +version = "0.8.0" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 98dd572e0..a967b999f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 217a8907ffe9552c88329cdb693553048c003774 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:34:28 +0200 Subject: [PATCH 09/14] Reject unknown value-field func in insert.pivotTable (#1757) Found while testing crates/engine/src/cmd/pivot.rs:678 (func_param at :248) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/pivot.rs | 8 +++++- crates/engine/tests/pivot_unknown_func.rs | 33 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/pivot_unknown_func.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 8a441fa1b..06050f7e8 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -675,7 +675,13 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?, Json::Object(_) => { let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?; - add_to_area("insert.pivotTable", pt, &src, n, area, func_param(str_param(f, "func")), str_param(f, "name"), None)?; + let func = match str_param(f, "func") { + Some(fname) => { + Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?) + } + None => None, + }; + add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?; let by = group_param(str_param(f, "group")); if let Some(by) = by.filter(|b| *b != PivotDateGroup::None) && matches!(area, "rows" | "columns") diff --git a/crates/engine/tests/pivot_unknown_func.rs b/crates/engine/tests/pivot_unknown_func.rs new file mode 100644 index 000000000..f4d2427f0 --- /dev/null +++ b/crates/engine/tests/pivot_unknown_func.rs @@ -0,0 +1,33 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Region", "Sales"], ["East", "10"], ["East", "2"], ["West", "4"]]})).unwrap(); + s +} + +#[test] +fn insert_pivot_table_rejects_unknown_func() { + for func in ["stdev", "sumx"] { + let mut s = session(); + let r = s.execute( + "insert.pivotTable", + json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":func}]}), + ); + assert!(r.is_err(), "unknown func `{func}` accepted silently"); + assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty()); + } +} + +#[test] +fn insert_pivot_table_accepts_known_func() { + let mut s = session(); + s.execute( + "insert.pivotTable", + json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":"stddev"}]}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 1); +} From 83189c0ccd9138539f576fbaf39d38712e05f5ad Mon Sep 17 00:00:00 2001 From: Dante Bertuzzi <67659136+dantebertuzzi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:35:20 -0300 Subject: [PATCH 10/14] Fonts: probe ChromeOS's /usr/share/fonts/chromeos for script fallbacks (fixes #1762) (#1768) Crostini's cros-host-fonts mounts the host's fonts under /usr/share/fonts/chromeos (noto/, notocjk/, crosextra/), which none of the fixed Linux path lists reached: Thai, Hebrew, CJK and Symbols 2 fell back to egui's Latin-only faces and drew tofu with the fonts installed, and the UI and cell roles missed Noto Sans and Carlito. Add those folders to each list, with a structural test that every list probes them. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/theme.rs | 43 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 485b9a2f3..08097ba27 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -252,7 +252,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"]; + // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). + let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; let mut v: Vec<(String, u32)> = Vec::new(); let mut add = |s: String, i: u32| v.push((s, i)); match role { @@ -498,10 +499,10 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { } /// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, -/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and -/// flat layouts. +/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux +/// container (`chromeos/notocjk`, #1762), plus local and flat layouts. #[cfg(not(target_arch = "wasm32"))] -const LINUX_CJK_DIRS: [&str; 10] = [ +const LINUX_CJK_DIRS: [&str; 11] = [ "/usr/share/fonts/opentype/noto", "/usr/share/fonts/google-noto-cjk", "/usr/share/fonts/noto-cjk", @@ -512,6 +513,7 @@ const LINUX_CJK_DIRS: [&str; 10] = [ "/usr/share/fonts", "/usr/local/share/fonts", "/usr/local/share/fonts/noto-cjk", + "/usr/share/fonts/chromeos/notocjk", ]; /// Japanese Han + Kana faces (Japanese kanji glyph shapes). @@ -581,7 +583,13 @@ fn thai() -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let lin = [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma @@ -603,7 +611,13 @@ fn hebrew() -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let lin = [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\david.ttf"), 0)); // David v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl @@ -657,6 +671,7 @@ fn symbols_more() -> Vec<(String, u32)> { "/usr/share/fonts/google-noto-sans-symbols-2-fonts", "/usr/share/fonts/google-noto", "/usr/local/share/fonts/noto", + "/usr/share/fonts/chromeos/noto", ] { v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); } @@ -888,6 +903,22 @@ mod tests { } } + #[test] + fn chromeos_font_layout_is_probed() { + // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per + // family set; a script whose list misses it renders as tofu with the font installed. + let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); + let cros = "/usr/share/fonts/chromeos"; + assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); + assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); + assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); + assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); + assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); + } + #[test] fn unparseable_font_files_are_rejected_not_fatal() { // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of From 19f4387cca2ff373da2cfcb851b5cb9cead9e14b Mon Sep 17 00:00:00 2001 From: Dante Bertuzzi <67659136+dantebertuzzi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:37:09 -0300 Subject: [PATCH 11/14] Conditional formatting: show a rule's number format (fixes #1163) (#1769) The grid's look() and print's cell_look() layered a rule's fill, font and borders onto the cell style but not its num_fmt, so a cell under a rule with a number format kept its own. Take the rule's code when it isn't General (in a differential style General means "not set", as layer_under treats it). Print also re-formatted every non-General cell from its own style through display::cell_text; format with the look's code instead, through a new display::value_text_as that value_text now calls (CSV/Text output unchanged). Show Formulas keeps cell_text. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/print.rs | 22 +++++++++++++++++++++- crates/engine/src/display.rs | 8 +++++++- crates/ui-egui/src/grid.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46..8350e56d7 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -650,6 +650,10 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option String { /// and no General narrowing (Save As CSV/Text writes this). Formulas give their result. pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let style = wb.styles.get(sheet.style_id(c)); - let f = format(&sheet.value(c), style.num_fmt.as_str(), wb); + value_text_as(wb, &sheet.value(c), style.num_fmt.as_str()) +} + +/// [`value_text`] for a value and a format code given apart from a cell (print takes the code +/// from the cell's effective look, which a conditional format can override). +pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String { + let f = format(v, code, wb); match f.fill { Some((_, pos)) => { let mut t = f.text; diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..90a39cb06 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -482,6 +482,10 @@ fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> ( if st.font.strike { style.font.strike = true; } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } for (dst, src) in [ (&mut style.borders.left, st.borders.left), (&mut style.borders.right, st.borders.right), @@ -3406,6 +3410,28 @@ mod tests { assert_eq!(center_across_end(0, 1, |_| true), 1); } + #[test] + fn conditional_number_format_reaches_the_cell_text() { + // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let wb = session.active().unwrap().wb.clone(); + let sh = wb.sheet(0).unwrap(); + let mut cf = CfCache::new(); + let text = |row: u32, cf: &mut CfCache| { + let c = CellRef::new(row, 0); + let (lk, _) = look(&wb, 0, sh, c, cf); + display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 + }; + assert_eq!(text(0, &mut cf), "1"); + assert_eq!(text(1, &mut cf), "3.00"); + assert_eq!(text(2, &mut cf), "4.00"); + } + #[test] fn filter_menu_uses_the_table_whose_header_was_clicked() { use gridcraft_engine::model::Table; From c30fa3f1cf324603f3bfc0171689b1d4083b40e5 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:13:03 +0200 Subject: [PATCH 12/14] fix(functions): avoid overflow in median midpoints (#1579) (#1585) --- crates/calc/src/tests.rs | 28 ++++++++++++++++++++++++ crates/functions/src/stat.rs | 42 +++++++++++++++++++++++++++++++++++- docs/function-parity.md | 8 ++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..97f7a8350 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,31 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn median_large_values_recalculate_dependents() { + let mut t = T::new(); + t.set("A1", "1e308"); + t.set("A2", "1.6e308"); + t.set("B1", "=MEDIAN(A1:A2)"); + t.set("B2", "=AGGREGATE(12,0,A1:A2)"); + t.set("C1", "=B1/1e308"); + t.set("C2", "=B2/1e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) - 1.3).abs() < 1e-14); + } + t.set("A2", "-1e308"); + for cell in ["B1", "B2", "C1", "C2"] { + assert_eq!(t.num(cell), 0.0); + } + t.set("A1", "-1.6e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) + 1.3).abs() < 1e-14); + } +} diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 7c50abeaa..9d8642e24 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -54,7 +54,7 @@ fn median(v: Vec) -> R { let s = sorted(v); let n = s.len(); let hi = s.get(n / 2).copied().unwrap_or(0.0); - if n % 2 == 1 { Ok(hi) } else { Ok((s.get(n / 2 - 1).copied().unwrap_or(0.0) + hi) / 2.0) } + if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } } /// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. @@ -2255,6 +2255,46 @@ mod tests { av(col(xs)) } + // The two middle values are finite and their midpoint is representable even when + // adding them first would overflow. Exercise both callers of the shared helper. + #[test] + fn median_finite_midpoint_does_not_overflow() { + for (values, expected) in [ + (vec![1e308, 1e308], 1e308), + (vec![-1e308, -1e308], -1e308), + (vec![1.6e308, 1e308], 1.3e308), + (vec![-1e308, -1.6e308], -1.3e308), + (vec![f64::MAX, f64::MAX], f64::MAX), + (vec![-f64::MAX, -f64::MAX], -f64::MAX), + (vec![-f64::MAX, f64::MAX], 0.0), + (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), + ] { + close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); + close(ev("MEDIAN", vec![rf(col(&values))]), expected); + close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); + } + } + + #[test] + fn median_midpoint_keeps_small_values_and_argument_semantics() { + // Halving each operand unconditionally can lose the smallest numbers. Use exact + // equality here: the usual aggregate-test tolerance would accept zero. + for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { + assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); + assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); + } + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); + close(ev("MEDIAN", vec![n(1e308)]), 1e308); + close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); + close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); + is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); + is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); + is_err(aggregate_values(12, &[]), CellError::Num); + is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); + } + #[test] fn averages_and_counts() { close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); diff --git a/docs/function-parity.md b/docs/function-parity.md index 274800699..2343e0de9 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -101,6 +101,11 @@ fraction sections with literal text (`#\:#=/=#`, `#_#/#`), zero fractions, zero- denominators, `?` placeholders padding the thousands separator, and booleans through the text section (`TEXT(TRUE,";;;\@@\@")` = `@TRUE@`). +`MEDIAN` and `AGGREGATE` function number 12 use an overflow-safe midpoint for even-sized +inputs, so large finite middle values do not become `#NUM!` merely because their sum overflows (#1579). +Sorting, argument coercion and error propagation are unchanged; this is a numerical correction +under the documented median rule, not an extreme-value comparison against desktop Excel. + Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above), an oracle corpus of Excel-computed results per function (owner runs Excel on generated workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h). @@ -109,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | From 6e984bb84dfea70ce5c4def29a9fdeb14ffb122c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:31:36 -0400 Subject: [PATCH 13/14] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" From 66ce6be4743f4227047f76f56815520f824e6d81 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 18:14:33 -0400 Subject: [PATCH 14/14] docs: add SonarCloud and CodeRabbit badges to README (#1860) * docs: add SonarCloud and CodeRabbit badges to README * docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 678495f1d..3837b9176 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ Runs on macOS, Windows, Linux, BSD and the web License: MIT OR Apache-2.0 Agent-drivable over MCP +
+ Scanned by SonarCloud + Reviewed by CodeRabbit