This commit is contained in:
Brandon Thomas
2026-10-23 10:11:12 -04:00
7 changed files with 159 additions and 13 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
@@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
+3
View File
@@ -93,3 +93,6 @@ sync_github_name = false
[people.bilalmughal]
sync_github_name = true
[people.tyrel]
real_name = "Tyrel Haveman"
+17 -2
View File
@@ -167,7 +167,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
}
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept.
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
/// finite number (#2163).
fn plain_number(s: &str, frac: bool) -> Option<f64> {
let (int, dec) = match s.split_once('.') {
Some((i, d)) if frac => (i, Some(d)),
@@ -177,7 +178,7 @@ fn plain_number(s: &str, frac: bool) -> Option<f64> {
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
return None;
}
s.parse().ok()
s.parse().ok().filter(|x: &f64| x.is_finite())
}
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
@@ -192,6 +193,9 @@ fn parse_fraction(t: &str) -> Option<f64> {
return None;
}
let v = w + a / b;
if !v.is_finite() {
return None;
}
Some(if neg { -v } else { v })
}
@@ -366,6 +370,17 @@ mod tests {
parse_input(s, DateSystem::D1900)
}
/// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
#[test]
fn overlong_digits_stay_text() {
let big = "9".repeat(400);
for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
}
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
+3 -2
View File
@@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
match v {
Value::Number(n) => {
let i = n.trunc();
if i < 1.0 || i > db.cols as f64 {
// NaN too: it would pass both comparisons and become index 0 (#2163).
if i.is_nan() || i < 1.0 || i > db.cols as f64 {
return Err(CellError::Value);
}
Ok(Some(i as usize - 1))
Ok(Some((i as usize).saturating_sub(1)))
}
Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value),
Value::Bool(_) => Err(CellError::Value),
+34
View File
@@ -264,6 +264,37 @@ mod tests {
}
}
/// #2163: a NaN column index is refused (it used to become 0 and underflow to `usize::MAX`).
#[test]
fn nan_index_is_a_value_error() {
use crate::util::testutil::*;
use gridcraft_core::CellError;
let table = rf(arr(vec![vec![tv("Field"), tv("Other")], vec![nv(1.0), nv(2.0)]]));
let nan = || av(Value::Number(f64::NAN));
assert_eq!(ev("VLOOKUP", vec![n(1.0), table.clone(), nan()]), Value::Error(CellError::Value));
assert_eq!(ev("HLOOKUP", vec![t("Field"), table.clone(), nan()]), Value::Error(CellError::Value));
assert_eq!(ev("DSUM", vec![table.clone(), nan(), table.clone()]), Value::Error(CellError::Value));
assert_eq!(ev("VLOOKUP", vec![n(1.0), table, n(2.0)]), Value::Number(2.0));
}
/// #2162: REGEXREPLACE gives up on a too-long result while building it, and otherwise replaces
/// as before (every match, `$n` groups).
#[test]
fn regexreplace_bounds_its_result() {
use crate::util::testutil::*;
use gridcraft_core::CellError;
let many = "a".repeat(32_767);
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("."), t(&"b".repeat(32_767))]), Value::Error(CellError::Value));
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("a"), t("b")]), Value::text("b".repeat(32_767)));
assert_eq!(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), Value::text("a#b#c"));
assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026"));
assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-"));
assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#"));
// One match whose replacement repeats it 10,000 times: refused before it is built.
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value));
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value));
}
#[test]
fn hostile_inputs_never_panic() {
use crate::util::testutil::*;
@@ -279,6 +310,9 @@ mod tests {
n(-1e308),
n(1e10),
n(2.5),
// Numbers no formula should make, but code can (#2163).
av(Value::Number(f64::NAN)),
av(Value::Number(f64::INFINITY)),
t(""),
t("abc"),
t("*?~"),
+3 -2
View File
@@ -134,10 +134,11 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R<Value> {
return Err(*e);
}
let idx = num(args, 2)?;
if idx < 1.0 {
// NaN too: it would pass `idx < 1.0` and become index 0 (#2163).
if idx.is_nan() || idx < 1.0 {
return Err(CellError::Value);
}
let idx = idx.trunc() as usize - 1;
let idx = (idx.trunc() as usize).saturating_sub(1);
let approx = opt_bool(args, 3, true)?;
let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) };
if idx >= len {
+96 -6
View File
@@ -763,23 +763,99 @@ fn convert_replacement(r: &str) -> String {
out
}
/// A piece of a replacement: literal text or a capture group (by number or name).
enum Piece {
Text(String),
Index(usize),
Name(String),
}
/// A replacement split into pieces as `regex::Captures::expand` reads it: `$$` is `$`, `$name` takes
/// the longest run of `[_0-9A-Za-z]`, `${name}` ends at `}` (`${}` is empty), a name of digits is a
/// group number, and a `$` that starts no reference is itself.
fn template(rep: &str) -> Vec<Piece> {
let mut out = Vec::new();
let mut lit = String::new();
let mut rest = rep;
while let Some(i) = rest.find('$') {
lit.push_str(&rest[..i]);
rest = &rest[i + 1..];
if let Some(r) = rest.strip_prefix('$') {
lit.push('$');
rest = r;
continue;
}
// A braced name is a reference even when empty (it expands to nothing).
let braced = rest.strip_prefix('{').and_then(|b| b.find('}').map(|j| (&b[..j], &b[j + 1..])));
let (name, after) = braced.unwrap_or_else(|| {
let j = rest.find(|c: char| !(c == '_' || c.is_ascii_alphanumeric())).unwrap_or(rest.len());
(&rest[..j], &rest[j..])
});
if braced.is_none() && name.is_empty() {
lit.push('$');
continue;
}
if !lit.is_empty() {
out.push(Piece::Text(std::mem::take(&mut lit)));
}
out.push(match name.parse::<usize>() {
Ok(n) => Piece::Index(n),
Err(_) => Piece::Name(name.to_string()),
});
rest = after;
}
lit.push_str(rest);
if !lit.is_empty() {
out.push(Piece::Text(lit));
}
out
}
/// Append a match's replacement to `out`, piece by piece; `None` once `out` would pass `limit`
/// bytes, before anything that long is built (#2162).
fn expand_capped(cap: &regex::Captures, pieces: &[Piece], out: &mut String, limit: usize) -> Option<()> {
for p in pieces {
let s = match p {
Piece::Text(t) => t.as_str(),
Piece::Index(i) => cap.get(*i).map_or("", |m| m.as_str()),
Piece::Name(n) => cap.name(n).map_or("", |m| m.as_str()),
};
if out.len().saturating_add(s.len()) > limit {
return None;
}
out.push_str(s);
}
Some(())
}
fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = text(a, 0)?;
let rep = convert_replacement(&text(a, 2)?);
let rep = template(&convert_replacement(&text(a, 2)?));
let occ = opt_int(a, 3, 0)?;
let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?;
// Built match by match within a bound past which the text can only be too long (a character
// is at most four bytes), instead of building every replacement first (#2162).
let limit = MAX_TEXT * 4;
let mut out = String::new();
if occ == 0 {
return check_len(re.replace_all(&s, rep.as_str()).into_owned());
let mut last = 0;
for cap in re.captures_iter(&s) {
let Some(m) = cap.get(0) else { continue };
out.push_str(s.get(last..m.start()).unwrap_or_default());
expand_capped(&cap, &rep, &mut out, limit).ok_or(CellError::Value)?;
last = m.end();
}
out.push_str(s.get(last..).unwrap_or_default());
return check_len(out);
}
let caps: Vec<regex::Captures> = re.captures_iter(&s).collect();
let k = occ.unsigned_abs() as usize;
let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) };
let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) };
let Some(m) = cap.get(0) else { return text_val(s) };
let mut out = String::new();
out.push_str(&s[..m.start()]);
cap.expand(&rep, &mut out);
out.push_str(&s[m.end()..]);
out.push_str(s.get(..m.start()).unwrap_or_default());
expand_capped(cap, &rep, &mut out, limit).ok_or(CellError::Value)?;
out.push_str(s.get(m.end()..).unwrap_or_default());
check_len(out)
}
@@ -957,6 +1033,20 @@ pub(crate) fn specs() -> Vec<FnSpec> {
mod tests {
use gridcraft_core::{CellError, Value};
/// The pieces expand exactly as `regex::Captures::expand` does.
#[test]
fn template_expands_like_regex() {
let re = regex::Regex::new(r"(?<y>\d+)-(\d+)").unwrap();
let cap = re.captures("x 2026-10 y").unwrap();
for rep in ["$1", "${1}", "$1a", "${1}a", "$y", "${y}!", "$$", "$$1", "a$", "$}", "${", "${}", "${y", "$0|$2|$9", "$-1", "€$1€", ""] {
let mut want = String::new();
cap.expand(rep, &mut want);
let mut got = String::new();
assert!(super::expand_capped(&cap, &super::template(rep), &mut got, usize::MAX).is_some());
assert_eq!(got, want, "{rep:?}");
}
}
use crate::util::testutil::*;
#[test]