mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 06:06:48 -06:00
+3
-1
@@ -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-23 · **Change:** minor (INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: 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-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: 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-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
|
||||
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
|
||||
| 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 |
|
||||
@@ -223,6 +224,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
|
||||
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
|
||||
| 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 |
|
||||
|
||||
@@ -427,6 +427,21 @@ fn lambda_helpers_check_shapes_and_parameters() {
|
||||
assert_eq!(t.num("B7"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_negative_text_coerces() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "'$(1,234)");
|
||||
t.set("B1", "=A1+0");
|
||||
t.set("C1", "=VALUE(A1)");
|
||||
t.set("D1", "=--\"$ (5)\"");
|
||||
t.set("E1", "=\"(5)%\"*100");
|
||||
t.set("F1", "=\"(-5)\"+0");
|
||||
t.set("G1", "$(1,234)");
|
||||
assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0));
|
||||
assert_eq!(t.get("F1"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.num("G1"), -1234.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
|
||||
+120
-16
@@ -61,16 +61,16 @@ pub fn number_stored_as_text(s: &str) -> Option<f64> {
|
||||
}
|
||||
|
||||
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
|
||||
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
|
||||
let mut t = t;
|
||||
let mut neg = false;
|
||||
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
t = inner.trim();
|
||||
neg = !neg;
|
||||
// Parenthesised negatives: (100) = -100, and the accounting forms `$(100)` and `(5)%`.
|
||||
if let Some(inner) = parenthesized(t) {
|
||||
// Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`.
|
||||
if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) {
|
||||
return None;
|
||||
}
|
||||
let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?"))));
|
||||
return r.map(|(n, f)| (-n, f));
|
||||
}
|
||||
let (n, f) = parse_unparenthesised(t, sys)?;
|
||||
Some((if neg { -n } else { n }, f))
|
||||
parse_unparenthesised(t, sys)
|
||||
}
|
||||
|
||||
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
@@ -89,6 +89,22 @@ fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'stat
|
||||
None
|
||||
}
|
||||
|
||||
/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may
|
||||
/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as
|
||||
/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it.
|
||||
fn parenthesized(t: &str) -> Option<String> {
|
||||
let (currency, rest) = match t.strip_prefix('$') {
|
||||
Some(r) => ("$", r.trim_start()),
|
||||
None => ("", t),
|
||||
};
|
||||
let rest = rest.strip_prefix('(')?;
|
||||
let (body, percent) = match rest.strip_suffix(')') {
|
||||
Some(b) => (b, ""),
|
||||
None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"),
|
||||
};
|
||||
Some(format!("{currency}{}{percent}", body.trim()))
|
||||
}
|
||||
|
||||
/// Signs, currency, thousands separators, percent, exponent.
|
||||
fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
let mut s = t;
|
||||
@@ -113,7 +129,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
percent = true;
|
||||
s = r.trim_end();
|
||||
}
|
||||
if s.is_empty() {
|
||||
// Excel reads `$5` and `5%` but not `$5%`.
|
||||
if s.is_empty() || currency && percent {
|
||||
return None;
|
||||
}
|
||||
let has_sep = s.contains(',');
|
||||
@@ -422,20 +439,107 @@ mod tests {
|
||||
assert_eq!(p(".5").value, Value::Number(0.5));
|
||||
}
|
||||
|
||||
/// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree.
|
||||
#[test]
|
||||
fn accounting_parentheses() {
|
||||
let accepted: [(&str, f64, Option<&str>); 22] = [
|
||||
("$(5)", -5.0, Some("\"$\"#,##0")),
|
||||
("$ (5)", -5.0, Some("\"$\"#,##0")),
|
||||
("$( 5 )", -5.0, Some("\"$\"#,##0")),
|
||||
("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")),
|
||||
("$(1,234)", -1234.0, Some("\"$\"#,##0")),
|
||||
("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")),
|
||||
("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")),
|
||||
("$(.5)", -0.5, Some("\"$\"#,##0.00")),
|
||||
("$(0)", 0.0, Some("\"$\"#,##0")),
|
||||
(" $(5) ", -5.0, Some("\"$\"#,##0")),
|
||||
("($5)", -5.0, Some("\"$\"#,##0")),
|
||||
("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")),
|
||||
("(5)", -5.0, None),
|
||||
("( 5 )", -5.0, None),
|
||||
("(1,234.5)", -1234.5, Some("#,##0.00")),
|
||||
("(1e-3)", -0.001, Some("0.00E+00")),
|
||||
("(5)%", -0.05, Some("0%")),
|
||||
("( 5 ) %", -0.05, Some("0%")),
|
||||
("(5%)", -0.05, Some("0%")),
|
||||
("(5.)%", -0.05, Some("0.00%")),
|
||||
("(1,234)%", -12.34, Some("0%")),
|
||||
("(1 1/2)", -1.5, Some("# ?/?")),
|
||||
];
|
||||
for (s, n, f) in accepted {
|
||||
assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}");
|
||||
assert_eq!(parse_number_text(s), Some(n), "{s}");
|
||||
}
|
||||
for s in [
|
||||
"$5%",
|
||||
"$(5%)",
|
||||
"$(5)%",
|
||||
"($5%)",
|
||||
"($5)%",
|
||||
"$ ( 5 ) %",
|
||||
"(5)%%",
|
||||
"(5%)%",
|
||||
"((5))",
|
||||
"(-5)",
|
||||
"(+5)",
|
||||
"($-5)",
|
||||
"($ -5)",
|
||||
"($+5)",
|
||||
"(-$5)",
|
||||
"$(-5)",
|
||||
"$ (-5)",
|
||||
"$(+5)",
|
||||
"-(5)",
|
||||
"-$(5)",
|
||||
"+$(5)",
|
||||
"$+(5)",
|
||||
"$$(5)",
|
||||
"$ $(5)",
|
||||
"$((5))",
|
||||
"($(5))",
|
||||
"(-1 1/2)",
|
||||
"()",
|
||||
"$()",
|
||||
"(5)$",
|
||||
"$(5)$",
|
||||
"(5)(5)",
|
||||
"$(5)0",
|
||||
"(5)e3",
|
||||
"(5)-",
|
||||
"(5-)",
|
||||
"$(5-)",
|
||||
"$(5",
|
||||
"$5)",
|
||||
"(5",
|
||||
"$(1,23)",
|
||||
"$(,5)",
|
||||
"(5e)",
|
||||
"(1/2)",
|
||||
"$(1/2)",
|
||||
"(3/4/2026)",
|
||||
"$(3/4/2026)",
|
||||
"(10:30)",
|
||||
"$(10:30)",
|
||||
] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
assert_eq!(parse_number_text(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_parentheses_do_not_overflow() {
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack. Excel reads
|
||||
// one pair only, so nesting is text now, and it is rejected without recursing.
|
||||
let depth = 100_000;
|
||||
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
|
||||
assert_eq!(parse_number_text(&s), Some(1.0));
|
||||
assert_eq!(parse_number_text(&s), None);
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()));
|
||||
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
|
||||
assert_eq!(parse_number_text(&odd), Some(-1.0));
|
||||
assert_eq!(parse_number_text(&odd), None);
|
||||
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
|
||||
assert_eq!(parse_number_text(&unbalanced), None);
|
||||
assert_eq!(p(&s).value, Value::Number(1.0));
|
||||
// Existing results are unchanged.
|
||||
// A single pair still reads.
|
||||
assert_eq!(parse_number_text("(100)"), Some(-100.0));
|
||||
assert_eq!(parse_number_text("((100))"), Some(100.0));
|
||||
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
|
||||
assert_eq!(parse_number_text("()"), None);
|
||||
}
|
||||
|
||||
@@ -1211,6 +1211,13 @@ mod tests {
|
||||
close(ev("VALUE", vec![t("12:00")]), 0.5);
|
||||
close(ev("VALUE", vec![t("2020-01-01")]), 43831.0);
|
||||
is_err(ev("VALUE", vec![t("abc")]), CellError::Value);
|
||||
// Accounting negatives, as Excel 365 reads them.
|
||||
close(ev("VALUE", vec![t("$(5)")]), -5.0);
|
||||
close(ev("VALUE", vec![t("$ (1,000.50)")]), -1000.5);
|
||||
close(ev("VALUE", vec![t("(5)%")]), -0.05);
|
||||
for s in ["$(5)%", "$(5%)", "$5%", "(-5)", "((5))", "$(-5)", "(1/2)"] {
|
||||
is_err(ev("VALUE", vec![t(s)]), CellError::Value);
|
||||
}
|
||||
close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27);
|
||||
close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035);
|
||||
close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Function parity: worksheet functions and formulas
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** minor (INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** minor (number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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).
|
||||
@@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 |
|
||||
| 2026-10-23 | minor | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns |
|
||||
| 2026-10-21 | minor | SUBTOTAL and AGGREGATE charge every argument against the evaluator's 16-million-cell limit before scanning; a value in XFD1048576 no longer makes `SUBTOTAL(9,A:XFD)` read the whole sheet (#2175) |
|
||||
| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) |
|
||||
|
||||
Reference in New Issue
Block a user