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)"
diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml
deleted file mode 100644
index cd8f12ab3..000000000
--- a/.github/workflows/flatpak-pages.yml
+++ /dev/null
@@ -1,176 +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:
- 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" <
-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
diff --git a/Cargo.lock b/Cargo.lock
index 8678a9f99..af5f0aff2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1496,7 +1496,7 @@ dependencies = [
[[package]]
name = "gridcraft"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"arboard",
"craft-print",
@@ -1520,7 +1520,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1532,7 +1532,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1543,7 +1543,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1555,7 +1555,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"js-sys",
"serde",
@@ -1565,7 +1565,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -1595,7 +1595,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -1603,7 +1603,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -1612,7 +1612,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1624,18 +1624,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
-version = "0.6.1"
+version = "0.8.0"
[[package]]
name = "gridcraft-ui-egui"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"craft-ui",
"egui",
@@ -1657,7 +1657,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -1674,7 +1674,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"aes",
"gridcraft-core",
@@ -4528,7 +4528,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.6.1"
+version = "0.8.0"
dependencies = [
"gridcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 0bfee246d..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.6.1"
+version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
diff --git a/README.md b/README.md
index 678495f1d..3837b9176 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,9 @@
+
+
+
diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs
index ab2046e3c..60e44da8a 100644
--- a/apps/gridcraft/src/control_server.rs
+++ b/apps/gridcraft/src/control_server.rs
@@ -4,6 +4,10 @@
//! The first line of a connection must authenticate with the app's token (#777; see
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
//! JSON, is answered once and closed.
+//!
+//! 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, Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -40,6 +44,13 @@ fn make_token(port: u16, chosen: Option) -> std::io::Result {
pub fn start(port: u16, chosen_token: Option, ctx: egui::Context) -> std::io::Result> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let token: Arc = make_token(port, chosen_token)?.into();
+ let rx = serve_listener(listener, token, 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, token: Arc, 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() {
@@ -49,7 +60,6 @@ pub fn start(port: u16, chosen_token: Option, ctx: egui::Context) -> std
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
}
})?;
- log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
@@ -70,6 +80,74 @@ fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &
allowed
}
+/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
+/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
+fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, 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, token: &str, tx: Sender, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
@@ -77,40 +155,39 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::
if !authenticate(&mut reader, &mut out, token) {
return;
}
- 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}")}));
- break; // Not a client of ours; don't keep reading.
- }
+ 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;
- }
- // 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;
- }
+ // The connection closes either way; a peer that can't read the reason loses nothing.
+ let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
+ break;
}
}
@@ -121,6 +198,80 @@ mod tests {
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
+ /// Authenticate a new connection, send `input`, and read reply lines (after the
+ /// authentication reply) 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(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
+ c.write_all(input).unwrap();
+ // A read timeout ends the list too: the server kept the connection open.
+ let mut replies: Vec = BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect();
+ assert_eq!(replies.first().map(|r| r["ok"].clone()), Some(json!(true)), "{replies:?}");
+ replies.remove(0);
+ replies
+ }
+
+ 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, TOKEN.into(), 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(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
+ c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
+ let mut r = BufReader::new(c);
+ let mut auth_reply = String::new();
+ r.read_line(&mut auth_reply).unwrap();
+ 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}");
+ }
+ 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);
+ }
+ 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");
+ }
+
/// A server on a loopback port with a stand-in UI that echoes each method.
fn spawn() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
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/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/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/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/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);
+}
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/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/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/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 97b787ef3..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),
@@ -2573,7 +2577,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 +2773,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 +2827,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 +2854,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;
}
@@ -3391,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;
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
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");
+}
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/control-protocol.md b/docs/control-protocol.md
index 39a3e5d67..449598760 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -39,6 +39,10 @@ Then send newline-delimited JSON to `127.0.0.1:7979` (after the `auth` line). Ev
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 |
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) |
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 760ffd6ac..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-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-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).
@@ -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 |
@@ -100,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).
@@ -108,6 +114,8 @@ 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) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |