From 2c052d03d51ba6da710648ca3d7c9babbcc35fa4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:32:15 -0500 Subject: [PATCH 01/51] Read cell formatting from .xls files .xls (Excel 97-2003) import used only each cell's number format. It now also reads the BIFF8 formatting records (Font, XF, Palette and Style), so opened sheets keep their fonts, solid and pattern fills, borders (including diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden protection, the workbook's custom colour palette, its Normal font as the default, and its user-defined cell styles. The decoding lives in a new xls/styles.rs; xls/mod.rs gains the record arms and the build call. Short or malformed formatting records keep the fields present and add one warning instead of refusing the workbook. Tests build the records in code, including truncation and byte mutation. Also merges origin/main, keeping its dialog-sheet, WINDOW2 and XF number-format handling; the XF arm now goes through the new styles module, which covers the number format too. --- ROADMAP.md | 2 + crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/xls_style_tests.rs | 322 +++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 45 ++- crates/xlsx/src/xls/styles.rs | 390 +++++++++++++++++++++++ docs/file-format-parity.md | 7 +- docs/gaps.md | 4 +- 7 files changed, 741 insertions(+), 30 deletions(-) create mode 100644 crates/xlsx/src/tests/xls_style_tests.rs create mode 100644 crates/xlsx/src/xls/styles.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1faf680ca..4c22fe077 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | | 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) | | 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | +| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles | | 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | | 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values | @@ -230,6 +231,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook | | 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) | | 2026-10-16 | minor | Progress log: interface fonts held once instead of twice | +| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting | | 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | | 2026-10-14 | minor | Progress log: spill anchor reading its old spill area | | 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict | diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index c8f199a47..754525f14 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -25,6 +25,7 @@ mod shared_media; mod sheet_names; mod smooth_roundtrip; mod trendline_roundtrip; +mod xls_style_tests; mod xls_tests; mod xlsb_tests; mod zip_order; diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs new file mode 100644 index 000000000..d944e8915 --- /dev/null +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -0,0 +1,322 @@ +//! Legacy `.xls` (BIFF8) cell formatting: Font, XF, Palette and Style records. Every workbook is +//! assembled here from the [MS-XLS] structures; no Excel-produced file is used. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::style::Protection; +use gridcraft_model::{BorderLine, BorderStyle, Color, Fill, HAlign, PatternType, Style, StyleId, Underline, VAlign, VertAlign, Workbook}; + +use super::xls_tests::compound; +use crate::tables::INDEXED; +use crate::{read_xls, read_xlsx, write_xlsx}; + +fn rec(id: u16, payload: &[u8]) -> Vec { + let mut v = id.to_le_bytes().to_vec(); + v.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + v.extend_from_slice(payload); + v +} + +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +fn bof(dt: u16) -> Vec { + let mut p = u16s(&[0x0600, dt, 0x0DBB, 0x07CC]); + p.extend_from_slice(&[0; 8]); + rec(0x0809, &p) +} + +fn eof() -> Vec { + rec(0x000A, &[]) +} + +/// Font: height in twips, flags, colour, weight, script, underline, then a UTF-16 name. +fn font(twips: u16, flags: u16, icv: u16, weight: u16, script: u16, underline: u8, name: &str) -> Vec { + let mut p = u16s(&[twips, flags, icv, weight, script]); + p.extend_from_slice(&[underline, 0, 0, 0]); + p.push(name.encode_utf16().count() as u8); + p.push(1); + p.extend(u16s(&name.encode_utf16().collect::>())); + rec(0x0031, &p) +} + +/// The CellXF fields an XF record carries after ifnt, ifmt and the protection flags. +#[derive(Clone, Copy, Default)] +struct XfData { + alc: u8, + wrap: bool, + alc_v: u8, + trot: u8, + indent: u8, + shrink: bool, + /// dgLeft, dgRight, dgTop, dgBottom, dgDiag. + dg: [u32; 5], + /// icvLeft, icvRight, icvTop, icvBottom, icvDiag. + icv: [u32; 5], + diag: u32, + fls: u32, + fore: u16, + back: u16, +} + +fn xf(ifnt: u16, ifmt: u16, flags: u16, d: XfData) -> Vec { + let mut p = u16s(&[ifnt, ifmt, flags]); + p.push(d.alc | (u8::from(d.wrap) << 3) | (d.alc_v << 4)); + p.push(d.trot); + p.push(d.indent | (u8::from(d.shrink) << 4)); + p.push(0xFC); // fAtr* bits: no bearing on the values read + let b1 = d.dg[0] | (d.dg[1] << 4) | (d.dg[2] << 8) | (d.dg[3] << 12) | (d.icv[0] << 16) | (d.icv[1] << 23) | (d.diag << 30); + let b2 = d.icv[2] | (d.icv[3] << 7) | (d.icv[4] << 14) | (d.dg[4] << 21) | (d.fls << 26); + p.extend_from_slice(&b1.to_le_bytes()); + p.extend_from_slice(&b2.to_le_bytes()); + p.extend_from_slice(&(d.fore | (d.back << 7)).to_le_bytes()); + rec(0x00E0, &p) +} + +/// What Excel writes for unformatted cells: bottom aligned, automatic fill colours. +fn plain() -> XfData { + XfData { alc_v: 2, fore: 0x40, back: 0x41, ..XfData::default() } +} + +/// Locked, a cell XF inheriting from style XF 0. +const CELL: u16 = 0x0001; +/// Locked, a style XF with no parent. +const STYLE: u16 = 0xFFF5; + +fn number(r: u16, c: u16, ixfe: u16, n: f64) -> Vec { + let mut p = u16s(&[r, c, ixfe]); + p.extend_from_slice(&n.to_le_bytes()); + rec(0x0203, &p) +} + +fn blank(r: u16, c: u16, ixfe: u16) -> Vec { + rec(0x0201, &u16s(&[r, c, ixfe])) +} + +/// A one-sheet workbook: `formatting` records in the globals, `cells` in the sheet. +fn workbook(formatting: &[Vec], cells: &[Vec]) -> Vec { + let boundsheet = |pos: u32| { + let mut p = pos.to_le_bytes().to_vec(); + p.extend_from_slice(&[0, 0, 6, 0]); + p.extend_from_slice(b"Sheet1"); + rec(0x0085, &p) + }; + let globals = |pos: u32| [vec![bof(0x0005)], formatting.to_vec(), vec![boundsheet(pos), eof()]].concat().concat(); + let sheet = [vec![bof(0x0010)], cells.to_vec(), vec![eof()]].concat().concat(); + let len = globals(0).len() as u32; + [globals(len), sheet].concat() +} + +fn style_at<'a>(wb: &'a Workbook, a1: &str) -> &'a Style { + wb.styles.get(wb.sheets[0].style_id(CellRef::parse(a1).unwrap())) +} + +/// Four default fonts (Arial 10 and its bold/italic variants), as Excel writes them. +fn default_fonts() -> Vec> { + vec![ + font(200, 0, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 0, 0x7FFF, 700, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 700, 0, 0, "Arial"), + ] +} + +/// Fonts, a palette, style XFs, cell XFs and named styles covering every decoded field. +fn formatted() -> Vec { + let mut f = default_fonts(); + // ifnt 5 (index 4 is never written): 14 pt italic, struck, bold, double underline, + // superscript, red (colour 10). + f.push(font(280, 0x000A, 10, 700, 1, 2, "Courier New")); + // ifnt 6: single accounting underline, subscript, a custom palette colour. + f.push(font(220, 0, 9, 400, 2, 0x21, "Times New Roman")); + // Palette: the first two of the 56 custom entries (colour indexes 8 and 9) replaced. + let mut pal = u16s(&[56]); + for (i, c) in INDEXED.iter().enumerate().skip(8) { + let c = match i { + 8 => 0x123456, + 9 => 0xABCDEF, + _ => *c, + }; + pal.extend_from_slice(&[(c >> 16) as u8, (c >> 8) as u8, c as u8, 0]); + } + f.push(rec(0x0092, &pal)); + f.push(rec(0x041E, &[&164u16.to_le_bytes()[..], &[4u8, 0, 0][..], &b"0.0%"[..]].concat())); + // XF 0: the Normal style. + f.push(xf(0, 0, STYLE, plain())); + // XF 1: everything at once. + f.push(xf( + 5, + 164, + 0x0002, // hidden, not locked + XfData { + alc: 2, + wrap: true, + alc_v: 1, + trot: 135, + indent: 3, + shrink: true, + dg: [1, 0, 7, 2, 3], + icv: [9, 0, 8, 0x40, 10], + diag: 3, + fls: 1, + fore: 8, + back: 0x41, + }, + )); + // XF 2: a 12.5% gray pattern on yellow, font index 4 (invalid: the default font). + f.push(xf(4, 0, CELL, XfData { alc: 7, alc_v: 4, trot: 255, fls: 0x11, fore: 10, back: 13, ..plain() })); + // XF 3: a font index past the end, right aligned, diagonal-up only. + f.push(xf(200, 0, CELL, XfData { alc: 3, alc_v: 0, trot: 90, dg: [0, 13, 0, 0, 5], icv: [0, 12, 0, 0, 16], diag: 2, ..plain() })); + // XF 4: an ordinary cell: the default style. + f.push(xf(0, 0, CELL, plain())); + // XF 5: font ifnt 6. + f.push(xf(6, 0, CELL, plain())); + // Styles: built-in Normal, a user style on XF 1 and a duplicate name (ignored), a built-in + // style (Comma) left to GridCraft's own definition. + f.push(rec(0x0293, &[&u16s(&[0x8000])[..], &[0u8, 0xFF][..]].concat())); + f.push(rec(0x0293, &[&u16s(&[1, 5])[..], &[0u8][..], &b"Fancy"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[2, 5])[..], &[0u8][..], &b"FANCY"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[0x8002])[..], &[3u8, 0xFF][..]].concat())); + workbook( + &f, + &[ + number(0, 0, 1, 0.5), + blank(0, 1, 1), + number(1, 0, 2, 2.0), + number(2, 0, 3, 3.0), + number(3, 0, 4, 4.0), + number(4, 0, 999, 5.0), + number(5, 0, 5, 6.0), + blank(6, 0, 4), + ], + ) +} + +#[test] +fn reads_fonts_fills_borders_alignment_and_protection() { + let (wb, report) = read_xls(&compound("Workbook", &formatted())).unwrap(); + assert!(report.warnings[0].contains("cell formatting"), "{:?}", report.warnings); + assert!(!report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + // The Normal style's font is the workbook default. + let normal = wb.styles.get(StyleId::DEFAULT); + assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false)); + assert_eq!(normal.font.color, Color::Auto); + + let s = style_at(&wb, "A1"); + let f = &s.font; + assert_eq!((f.name.as_str(), f.size), ("Courier New", 14.0)); + assert!(f.bold && f.italic && f.strike); + assert_eq!((f.underline, f.vert, f.color), (Underline::Double, VertAlign::Superscript, Color::Rgb(0xFF0000))); + assert_eq!(s.num_fmt.as_str(), "0.0%"); + assert_eq!(s.protection, Protection { locked: false, hidden: true }); + assert_eq!((s.align.h, s.align.v, s.align.wrap, s.align.shrink), (HAlign::Center, VAlign::Center, true, true)); + assert_eq!((s.align.rotation, s.align.indent), (-45, 3)); + // Colour index 8 and 9 come from the Palette record. + assert_eq!(s.fill, Fill::solid(Color::Rgb(0x123456))); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Rgb(0xABCDEF) }); + assert_eq!(s.borders.right, BorderLine::default()); + assert_eq!(s.borders.top, BorderLine { style: BorderStyle::Hair, color: Color::Rgb(0x123456) }); + assert_eq!(s.borders.bottom, BorderLine { style: BorderStyle::Medium, color: Color::Auto }); + let dashed = BorderLine { style: BorderStyle::Dashed, color: Color::Rgb(0xFF0000) }; + assert_eq!((s.borders.diag_down, s.borders.diag_up), (dashed, dashed)); + // A formatted blank cell keeps its formatting. + assert_eq!(wb.sheets[0].style_id(CellRef::parse("B1").unwrap()), wb.sheets[0].style_id(CellRef::parse("A1").unwrap())); + assert_eq!(wb.sheets[0].value(CellRef::parse("B1").unwrap()), Value::Empty); + + let s = style_at(&wb, "A2"); + assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); + assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Distributed, VAlign::Distributed, 255)); + + let s = style_at(&wb, "A3"); + assert_eq!(s.font, normal.font, "a font index past the end falls back to the default font"); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Right, VAlign::Top, 90)); + assert_eq!(s.borders.right, BorderLine { style: BorderStyle::SlantDashDot, color: Color::Rgb(0x0000FF) }); + assert_eq!(s.borders.diag_up, BorderLine { style: BorderStyle::Thick, color: Color::Rgb(0x800000) }); + assert_eq!(s.borders.diag_down, BorderLine::default()); + + // An ordinary cell XF is the default style; so is an XF index past the end. + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A4").unwrap()), StyleId::DEFAULT); + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A5").unwrap()), StyleId::DEFAULT); + // A blank cell in the default style isn't stored. + assert!(wb.sheets[0].cell(CellRef::parse("A7").unwrap()).is_none()); + + let s = style_at(&wb, "A6"); + assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF))); + assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript)); + + // User-defined styles are kept (first of a name wins); built-in ones are GridCraft's own. + assert_eq!(wb.cell_styles.len(), 1, "{:?}", wb.cell_styles.iter().map(|(n, _)| n).collect::>()); + assert_eq!(wb.cell_styles[0].0, "Fancy"); + assert_eq!(&wb.cell_styles[0].1, style_at(&wb, "A1")); + + // The formatting survives a save as .xlsx. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for a1 in ["A1", "A2", "A3", "A6"] { + assert_eq!(style_at(&back, a1), style_at(&wb, a1), "{a1}"); + } + assert_eq!(back.styles.get(StyleId::DEFAULT).font.name, "Arial"); + assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::>(), ["Fancy"]); +} + +#[test] +fn without_a_palette_the_default_colours_apply() { + let mut f = default_fonts(); + f.push(xf(0, 0, STYLE, plain())); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 63, ..plain() })); + // Out-of-range colour indexes are automatic. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 0x7F, dg: [1, 0, 0, 0, 0], icv: [0x50, 0, 0, 0, 0], ..plain() })); + let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]))).unwrap(); + assert_eq!(style_at(&wb, "A1").fill, Fill::solid(Color::Rgb(0x000000))); + assert_eq!(style_at(&wb, "A2").fill, Fill::solid(Color::Rgb(0x333333))); + let s = style_at(&wb, "A3"); + assert_eq!(s.fill, Fill::solid(Color::Auto)); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Auto }); +} + +#[test] +fn short_formatting_records_keep_what_is_there() { + let mut f = default_fonts(); + // A truncated font (no name): the default font keeps the numbering of the next one. + f.push(rec(0x0031, &u16s(&[240, 0]))); + f.push(font(300, 0, 0x7FFF, 700, 0, 0, "Georgia")); + // A palette claiming more entries than it holds. + f.push(rec(0x0092, &[&u16s(&[56])[..], &[1u8, 2, 3, 0][..]].concat())); + f.push(xf(0, 0, STYLE, plain())); + // XF 1: only ifnt and ifmt and flags: number format kept, no alignment. + f.push(rec(0x00E0, &u16s(&[6, 10, 0x0001]))); + // XF 2: two bytes. + f.push(rec(0x00E0, &u16s(&[6]))); + // XF 3: palette colour 8 (overridden) as a fill. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + // Style records cut short, and a style naming a missing XF. + f.push(rec(0x0293, &u16s(&[0x8000]))); + f.push(rec(0x0293, &[&u16s(&[1, 40])[..], &[0u8][..], &b"Cut"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[900, 4])[..], &[0u8][..], &b"Gone"[..]].concat())); + let book = workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]); + let (wb, report) = read_xls(&compound("Workbook", &book)).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + let s = style_at(&wb, "A1"); + assert_eq!((s.font.name.as_str(), s.font.bold), ("Georgia", true)); + assert_eq!(s.num_fmt.as_str(), "0.00%"); + assert_eq!((s.align.v, s.protection.locked), (VAlign::Bottom, true)); + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A2").unwrap()), StyleId::DEFAULT); + assert_eq!(style_at(&wb, "A3").fill, Fill::solid(Color::Rgb(0x010203))); + assert!(wb.cell_styles.is_empty()); +} + +#[test] +fn hostile_formatting_records_never_panic() { + let stream = formatted(); + for at in 0..stream.len() { + for x in [0x00u8, 0xFF, 0x80] { + let mut s = stream.clone(); + s[at] = x; + let _ = read_xls(&compound("Workbook", &s)); + } + } + for cut in 0..stream.len() { + let _ = read_xls(&compound("Workbook", &stream[..cut])); + } +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index ceaf3e26d..7f1df1197 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -3,24 +3,27 @@ //! . //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including -//! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas, print titles and the calculation settings -//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, -//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! shared and array formulas), cell formatting (number formats, fonts, fills, borders, +//! alignment, protection, the colour palette and user-defined cell styles), the 1904 date +//! system, merged cells, column widths, row heights, defined names, print areas, print titles +//! and the calculation settings (mode, iteration, precision as displayed). Everything else +//! (charts, pictures, comments, conditional formats, validation, macros) is skipped with a +//! warning. //! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; mod records; +mod styles; use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, Sheet, StyleId, Visibility, Workbook}; use crate::cfb::Compound; -use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; +use crate::tables::{col_width_to_px, pt_to_px}; use crate::{IoError, ReadReport}; use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; use records::{Cursor, Record, Records}; @@ -123,7 +126,7 @@ struct Globals { sheets: Vec, date1904: bool, formats: HashMap, - xf_formats: Vec, + styles: styles::StyleRecords, sst: Vec>, links: Links, names: Vec, @@ -153,19 +156,11 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo let mut wb = Workbook::new(); wb.sheets.clear(); wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 }; - // One model style per XF: only the number format is imported. - let styles: Vec = g - .xf_formats - .iter() - .map(|ifmt| { - let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General"); - if code.eq_ignore_ascii_case("General") { - StyleId::DEFAULT - } else { - wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() }) - } - }) - .collect(); + // One model style per XF. + let styles = styles::build(&g.styles, &g.formats, &mut wb); + if g.styles.short { + report.warn("Some cell formatting records were shorter than expected; the formatting present was kept."); + } // BoundSheet8 index → model sheet index. let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); @@ -202,7 +197,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo // The overview comes first. report.warnings.insert( 0, - "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), + "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights and defined names were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), ); Ok((wb, report)) } @@ -272,10 +267,10 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul budget.text(code.len())?; g.formats.insert(ifmt, code); } - 0x00E0 if g.xf_formats.len() < MAX_XFS => { - c.u16()?; - g.xf_formats.push(c.u16()?); - } + 0x00E0 => g.styles.xf(&mut c, MAX_XFS), + 0x0031 => g.styles.font(&mut c), + 0x0092 => g.styles.palette(&mut c), + 0x0293 => g.styles.style(&mut c), 0x00FC => g.sst = shared_strings(&mut c, budget)?, 0x01AE => { let _ctab = c.u16()?; diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs new file mode 100644 index 000000000..6e254c20c --- /dev/null +++ b/crates/xlsx/src/xls/styles.rs @@ -0,0 +1,390 @@ +//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with +//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is +//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether +//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`. + +use std::collections::HashMap; + +use gridcraft_model::style::Protection; +use gridcraft_model::{ + Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Underline, VAlign, + VertAlign, Workbook, +}; + +use super::records::Cursor; +use crate::tables::{INDEXED, builtin_format}; + +/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022). +const MAX_FONTS: usize = 1024; +/// Style records kept at most. +const MAX_STYLES: usize = 65_536; +/// Characters kept from a style name (Excel allows 255). +const MAX_STYLE_NAME: usize = 255; + +/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours. +const ICV_FONT_AUTO: u16 = 0x7FFF; +const ICV_DEFAULT_FG: u16 = 0x40; +const ICV_DEFAULT_BG: u16 = 0x41; + +/// One XF record, decoded as far as its bytes allow. +#[derive(Clone, Debug, Default)] +pub(super) struct Xf { + ifnt: u16, + ifmt: u16, + flags: u16, + /// The CellXF/StyleXF data: 14 bytes when complete. + data: [u8; 14], + /// Whether all 20 bytes were present. + complete: bool, +} + +/// A Style record: the XF it names and its name. +#[derive(Clone, Debug)] +pub(super) struct NamedStyle { + ixf: u16, + /// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style. + builtin: Option, + /// A user style's name (empty for built-in styles). + name: String, +} + +/// Formatting records collected from the globals substream. +#[derive(Debug, Default)] +pub(super) struct StyleRecords { + fonts: Vec, + font_colors: Vec, + xfs: Vec, + /// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present. + palette: Option>, + styles: Vec, + /// Records that were shorter than their structure; the parts present were used. + pub(super) short: bool, +} + +impl StyleRecords { + /// A Font record. The colour is resolved later, once the palette is known. + pub(super) fn font(&mut self, c: &mut Cursor<'_, '_>) { + if self.fonts.len() >= MAX_FONTS { + return; + } + match read_font(c) { + Some((font, icv)) => { + self.fonts.push(font); + self.font_colors.push(icv); + } + None => { + // Keep the numbering of the fonts after it. + self.short = true; + self.fonts.push(Font::default()); + self.font_colors.push(ICV_FONT_AUTO); + } + } + } + + /// An XF record. A short record keeps the fields it has. + pub(super) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) { + if self.xfs.len() >= max { + return; + } + let mut xf = Xf::default(); + let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))(); + match head { + Some((ifnt, ifmt, flags)) => { + xf.ifnt = ifnt; + xf.ifmt = ifmt; + xf.flags = flags; + xf.complete = true; + for b in xf.data.iter_mut() { + match c.u8() { + Ok(v) => *b = v, + Err(_) => { + xf.complete = false; + break; + } + } + } + } + None => { + // Not even the font and format: an unformatted, locked cell. + xf.flags = 1; + } + } + if !xf.complete { + self.short = true; + } + self.xfs.push(xf); + } + + /// A Palette record: colours for indexes 8 onwards. + pub(super) fn palette(&mut self, c: &mut Cursor<'_, '_>) { + let Ok(ccv) = c.u16() else { + self.short = true; + return; + }; + let n = usize::from(ccv).min(56); + if usize::from(ccv) > c.remaining() / 4 { + self.short = true; + } + let mut colors = Vec::with_capacity(n); + for _ in 0..n { + let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break }; + colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)); + } + self.palette = Some(colors); + } + + /// A Style record. + pub(super) fn style(&mut self, c: &mut Cursor<'_, '_>) { + if self.styles.len() >= MAX_STYLES { + return; + } + let Ok(word) = c.u16() else { + self.short = true; + return; + }; + let ixf = word & 0x0FFF; + if word & 0x8000 != 0 { + let Ok(id) = c.u8() else { + self.short = true; + return; + }; + // BuiltInStyle: the style's number (0 is Normal) and outline level. + self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() }); + } else { + match c.string() { + Ok(name) => { + let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect(); + if !name.is_empty() { + self.styles.push(NamedStyle { ixf, builtin: None, name }); + } + } + Err(_) => self.short = true, + } + } + } + + /// The 64-entry colour table: the built-in eight, then the palette (or the default one). + fn color_table(&self) -> [u32; 64] { + let mut table = INDEXED; + if let Some(p) = &self.palette { + for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) { + *slot = *c; + } + } + table + } +} + +/// Font record fields up to the name; `None` if the record is too short for them. +fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { + let height = c.u16().ok()?; + let flags = c.u16().ok()?; + let icv = c.u16().ok()?; + let weight = c.u16().ok()?; + let script = c.u16().ok()?; + let underline = c.u8().ok()?; + c.skip(3).ok()?; // family, character set, unused + let name = c.short_string().ok()?; + let mut f = Font::default(); + let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect(); + if !name.trim().is_empty() { + f.name = name; + } + if height > 0 { + f.size = (f32::from(height) / 20.0).clamp(1.0, 409.0); + } + f.italic = flags & 0x0002 != 0; + f.strike = flags & 0x0008 != 0; + // Weights run 100-1000; Excel writes 400 for normal and 700 for bold. + f.bold = weight >= 600; + f.vert = match script { + 1 => VertAlign::Superscript, + 2 => VertAlign::Subscript, + _ => VertAlign::Baseline, + }; + f.underline = match underline { + 0x01 => Underline::Single, + 0x02 => Underline::Double, + 0x21 => Underline::SingleAccounting, + 0x22 => Underline::DoubleAccounting, + _ => Underline::None, + }; + Some((f, icv)) +} + +/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system +/// and automatic colours as automatic. +fn color(icv: u16, table: &[u32; 64]) -> Color { + match icv { + ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto, + _ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)), + } +} + +fn border_style(dg: u32) -> BorderStyle { + match dg { + 1 => BorderStyle::Thin, + 2 => BorderStyle::Medium, + 3 => BorderStyle::Dashed, + 4 => BorderStyle::Dotted, + 5 => BorderStyle::Thick, + 6 => BorderStyle::Double, + 7 => BorderStyle::Hair, + 8 => BorderStyle::MediumDashed, + 9 => BorderStyle::DashDot, + 10 => BorderStyle::MediumDashDot, + 11 => BorderStyle::DashDotDot, + 12 => BorderStyle::MediumDashDotDot, + 13 => BorderStyle::SlantDashDot, + _ => BorderStyle::None, + } +} + +fn pattern(fls: u16) -> PatternType { + match fls { + 0x01 => PatternType::Solid, + 0x02 => PatternType::Gray50, + 0x03 => PatternType::Gray75, + 0x04 => PatternType::Gray25, + 0x05 => PatternType::DarkHorizontal, + 0x06 => PatternType::DarkVertical, + 0x07 => PatternType::DarkDown, + 0x08 => PatternType::DarkUp, + 0x09 => PatternType::DarkGrid, + 0x0A => PatternType::DarkTrellis, + 0x0B => PatternType::LightHorizontal, + 0x0C => PatternType::LightVertical, + 0x0D => PatternType::LightDown, + 0x0E => PatternType::LightUp, + 0x0F => PatternType::LightGrid, + 0x10 => PatternType::LightTrellis, + 0x11 => PatternType::Gray125, + 0x12 => PatternType::Gray0625, + _ => PatternType::None, + } +} + +fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine { + let style = border_style(dg); + if style == BorderStyle::None { + return BorderLine::default(); + } + // Colour 0 means "not specified": automatic. + let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) }; + BorderLine { style, color } +} + +/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one). +fn xf_font(ifnt: u16, fonts: &[Font]) -> Font { + let i = match ifnt { + 0..=3 => usize::from(ifnt), + 4 => return fonts.first().cloned().unwrap_or_default(), + _ => usize::from(ifnt) - 1, + }; + fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default() +} + +fn num_fmt(ifmt: u16, formats: &HashMap) -> NumFmt { + let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General"); + if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) } +} + +/// The model style of one XF. +fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap, table: &[u32; 64]) -> Style { + let mut s = Style { + font: xf_font(xf.ifnt, fonts), + num_fmt: num_fmt(xf.ifmt, formats), + protection: Protection { locked: xf.flags & 0x0001 != 0, hidden: xf.flags & 0x0002 != 0 }, + ..Style::default() + }; + if !xf.complete { + return s; + } + let d = &xf.data; + let byte = |i: usize| d.get(i).copied().unwrap_or(0); + let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]); + let (a0, rot, a2) = (byte(0), byte(1), byte(2)); + s.align = Alignment { + h: match a0 & 0x07 { + 1 => HAlign::Left, + 2 => HAlign::Center, + 3 => HAlign::Right, + 4 => HAlign::Fill, + 5 => HAlign::Justify, + 6 => HAlign::CenterAcross, + 7 => HAlign::Distributed, + _ => HAlign::General, + }, + wrap: a0 & 0x08 != 0, + v: match (a0 >> 4) & 0x07 { + 0 => VAlign::Top, + 1 => VAlign::Center, + 3 => VAlign::Justify, + 4 => VAlign::Distributed, + _ => VAlign::Bottom, + }, + rotation: match rot { + 0..=90 => i16::from(rot), + 91..=180 => -(i16::from(rot) - 90), + 255 => 255, + _ => 0, + }, + indent: a2 & 0x0F, + shrink: a2 & 0x10 != 0, + }; + let b1 = word32(4); + let b2 = word32(8); + let mut borders = Borders { + left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table), + right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table), + top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table), + bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table), + ..Borders::default() + }; + let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table); + let which = (b1 >> 30) & 0x03; + if which & 1 != 0 { + borders.diag_down = diag; + } + if which & 2 != 0 { + borders.diag_up = diag; + } + s.borders = borders; + let fill = u16::from_le_bytes([byte(12), byte(13)]); + let fls = ((b2 >> 26) & 0x3F) as u16; + let fg = color(fill & 0x7F, table); + let bg = color((fill >> 7) & 0x7F, table); + s.fill = match pattern(fls) { + PatternType::None => Fill::default(), + PatternType::Solid => Fill::solid(fg), + p => Fill { pattern: p, fg, bg }, + }; + s +} + +/// Imports the formatting: sets the workbook's default (Normal) font and named cell styles, and +/// returns the model style of every XF in file order. +pub(super) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut Workbook) -> Vec { + let table = r.color_table(); + let fonts: Vec = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect(); + // The Normal style's font is what every unformatted cell shows. + let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf)); + let normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned()); + if let Some(font) = normal_font { + wb.styles = StyleTable::with_default(Style { font, ..Style::default() }); + } + let out = r.xfs.iter().map(|xf| wb.styles.intern(xf_style(xf, &fonts, formats, &table))).collect(); + // User-defined named styles. Excel 2007 and later write every built-in style into each .xls + // (about fifty, their colours approximated to the palette); GridCraft defines those itself. + let mut named: Vec<(String, Style)> = Vec::new(); + for ns in &r.styles { + if ns.builtin.is_some() || ns.name.eq_ignore_ascii_case("Normal") || named.iter().any(|(n, _)| n.eq_ignore_ascii_case(&ns.name)) { + continue; + } + if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) { + named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table))); + } + } + wb.cell_styles = named; + out +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf567f8bd..b1109b678 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-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (`.xls` import reads cell formatting: fonts, fills, borders, alignment, protection, palette, user cell styles; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **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** @@ -54,7 +54,7 @@ values) is still dropped. | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -100,7 +100,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | -| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | +| Legacy `.xls` (BIFF8): exact theme colours (XFExt), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | | CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | @@ -132,6 +132,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) | | 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses ` ` (#1488) | | 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) | +| 2026-10-14 | minor | `.xls` import reads cell formatting from the Font, XF, Palette and Style records: fonts, fills, borders, alignment, protection, the custom palette, the Normal font and user cell styles | | 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-14 | minor | XLSX formulas read with dynamic-array metadata (`cm`) keep it on save, so Excel does not recalculate them with implicit intersection | | 2026-10-14 | minor | PDF export writes clickable annotations for safe cell URLs and destinations on printed pages; CLI `convert` accepts `.pdf` output (#954) | diff --git a/docs/gaps.md b/docs/gaps.md index b07727991..cfee087e3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,14 +72,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 7. Legacy `.xls` only partly read; encrypted workbooks can't be saved with a password (FF) - **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number - formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional + formats, merges, sizes, names, and cell formatting: fonts, fills, borders, alignment); exact theme colours (XFExt), charts, pictures, comments, conditional formats, validation and BIFF5 files are not read, and `.xls` is never written. Password-encrypted `.xlsx` workbooks (Agile and Standard encryption) open with their password since #11, but are saved without it: GridCraft can't write an encrypted file yet. Encrypted `.xls` (FILEPASS) and RC4-encrypted files are still refused with a message. - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 14–21 h (`.xls` styles and objects 10–15, encryption 4–6). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 12–18 h (`.xls` objects 8–12, encryption 4–6). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, From ad2e3ac9de1a1ce011229a2c77924c8a201d8d51 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:37:19 -0500 Subject: [PATCH 02/51] Check duplicate .xls style names in linear time --- crates/xlsx/src/xls/styles.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs index 6e254c20c..8bd4ca9f8 100644 --- a/crates/xlsx/src/xls/styles.rs +++ b/crates/xlsx/src/xls/styles.rs @@ -377,11 +377,18 @@ pub(super) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut W // User-defined named styles. Excel 2007 and later write every built-in style into each .xls // (about fifty, their colours approximated to the palette); GridCraft defines those itself. let mut named: Vec<(String, Style)> = Vec::new(); + // Names are compared ASCII-case-insensitively; a set keeps a hostile file's many names linear. + let mut seen: std::collections::HashSet = std::collections::HashSet::from(["normal".to_string()]); for ns in &r.styles { - if ns.builtin.is_some() || ns.name.eq_ignore_ascii_case("Normal") || named.iter().any(|(n, _)| n.eq_ignore_ascii_case(&ns.name)) { + if ns.builtin.is_some() { + continue; + } + let key = ns.name.to_ascii_lowercase(); + if seen.contains(&key) { continue; } if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) { + seen.insert(key); named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table))); } } From bf85ba482cfde3202595ffe8c85f0f71b9af2eab Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:10:40 -0500 Subject: [PATCH 03/51] Align .xls estimate in gaps with file-format parity --- docs/gaps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index cfee087e3..3e4cb4266 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,7 +79,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- RC4-encrypted files are still refused with a message. - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 12–18 h (`.xls` objects 8–12, encryption 4–6). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 14–21 h (`.xls` objects 10–15, encryption 4–6). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, From 9cc126409b85363f0622303f050207069c6d94bc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:15:00 -0500 Subject: [PATCH 04/51] Match Excel for YEARFRAC basis 0, MOD, NUMBERVALUE and PERCENTRANK edge cases Checked against Excel 16: YEARFRAC basis 0 tests the end-31 rule against the original start day; MOD gives #NUM! once the quotient magnitude reaches 1.1259E12; NUMBERVALUE no longer rejects unmatched separators; PERCENTRANK gives #DIV/0! from significance 309 and 15 significant digits from significance 15. --- crates/functions/src/date.rs | 13 ++++++++++--- crates/functions/src/math.rs | 28 +++++++++++++++++++--------- crates/functions/src/stat.rs | 25 +++++++++++++++++++++---- crates/functions/src/text.rs | 15 +++++++++++---- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 93328154b..21d2a49c1 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -449,13 +449,15 @@ pub(crate) fn yearfrac_core(sys: DateSystem, s: i64, e: i64, basis: i64) -> R { let (mut a, mut b) = (d1.day as i64, d2.day as i64); + // Excel tests the end-31 rule against the original start day, before a February end moves to 30. + let start_30 = a >= 30; if last_of_feb(&d1) && last_of_feb(&d2) { b = 30; } if last_of_feb(&d1) || a == 31 { a = 30; } - if a == 30 && b == 31 { + if start_30 && b == 31 { b = 30; } ((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0 @@ -817,8 +819,13 @@ mod tests { #[test] fn yearfrac_basis0_feb_end_to_31() { - close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0); - close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0); + // Excel 16: a February-end start keeps its original day for the end-31 test, so 31 days. + close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11); + close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11); + close(ev("YEARFRAC", vec![n(d(2021.0, 3.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.086_111_111_111_111_11); + close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.077_777_777_777_777_78); + close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 30.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 1.0 / 6.0); + close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 1.0); close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.0); } diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 9824da7bf..eeeb5303d 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -306,6 +306,10 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } + // Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16). + if (x / d).abs() >= 1.1259e12 { + return Err(CellError::Num); + } // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. @@ -1443,16 +1447,22 @@ mod tests { } #[test] - fn mod_tiny_divisor_stays_in_range() { - for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { - match ev2("MOD", x, d) { - Value::Number(r) => { - assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); - assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); - } - other => panic!("MOD({x}, {d}) = {other:?}"), - } + fn mod_quotient_limit() { + // Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12. + for (x, d) in [ + (3.0, 1e-300), + (-1.0, 1e-300), + (3.0, -1e-300), + (-1.0, -1e-300), + (1e15, 7.0), + (1e10, 0.001), + (1_125_900_000_000.0, 1.0), + (-1_125_900_000_000.0, 1.0), + ] { + is_err(ev2("MOD", x, d), Num); } + close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); + close(ev2("MOD", 1_125_899_999_999.9998, 1.0), 0.989_990_234_375); } #[test] diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 344ba9ff8..b67aeb885 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -345,6 +345,10 @@ fn percentrank_impl(a: &[Arg], exc: bool) -> R { if sig < 1.0 || s.is_empty() { return Err(CellError::Num); } + // Excel: 10^significance overflows from 309 up. + if sig >= 309.0 { + return Err(CellError::Div0); + } let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); if x < first || x > last { return Err(CellError::NA); @@ -368,6 +372,10 @@ fn percentrank_impl(a: &[Arg], exc: bool) -> R { let (rl, rh) = (rank_of(lo), rank_of(hi)); rl + (x - lo) / (hi - lo) * (rh - rl) }; + // From significance 15 up Excel returns 15 significant digits, rounded. + if sig >= 15.0 { + return num_val(format!("{r:.14e}").parse().unwrap_or(r)); + } num_val(trunc_digits(r, sig)) } @@ -2490,10 +2498,19 @@ mod tests { close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); let t = c(&[1.0, 2.0, 3.0]); - close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); - close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); + // Excel 16: significance 309 and up is #DIV/0!, 308 still works. + for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] { + is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0); + is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0); + close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5); + } + // From significance 15 up the result has 15 significant digits. + let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + for sig in [15.0, 16.0, 308.0] { + close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667); + } + close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); + close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); } #[test] diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..db4b6d70c 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -334,8 +334,6 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { if seen_dec { return Err(CellError::Value); } - } else if c == '.' || c == ',' { - return Err(CellError::Value); } else { out.push(c); } @@ -1225,8 +1223,17 @@ mod tests { close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); close(ev("NUMBERVALUE", vec![t("")]), 0.0); is_err(ev("NUMBERVALUE", vec![t("1.2.3")]), CellError::Value); - is_err(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), CellError::Value); - is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(" ")]), CellError::Value); + // Excel 16 (en-US) swaps the given separators, then parses what is left in the host locale. + close(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), 1.5); + close(ev("NUMBERVALUE", vec![t("1 234.5"), t(","), t(" ")]), 1234.5); + close(ev("NUMBERVALUE", vec![t("2.5%"), t(","), t(" ")]), 0.025); + close(ev("NUMBERVALUE", vec![t("1.5E3"), t(","), t(" ")]), 1500.0); + close(ev("NUMBERVALUE", vec![t("1.5"), t(";")]), 1.5); + close(ev("NUMBERVALUE", vec![t("1.5"), t("x")]), 1.5); + close(ev("NUMBERVALUE", vec![t("1,5"), t(".")]), 15.0); + is_err(ev("NUMBERVALUE", vec![t("12.345,6"), t(","), t(" ")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1.2.3"), t(","), t(" ")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(".")]), CellError::Value); is_text(ev("FIXED", vec![n(1234.567), n(1.0)]), "1,234.6"); is_text(ev("FIXED", vec![n(1234.567), n(-1.0)]), "1,230"); is_text(ev("FIXED", vec![n(-1234.567), n(1.0), b(true)]), "-1234.6"); From 551ec6590de5e0f79391ffb7b654fb4e92e22b38 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:25:20 -0500 Subject: [PATCH 05/51] Fix literal grouping in the MOD test --- crates/functions/src/math.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index eeeb5303d..4b84e572d 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1462,7 +1462,7 @@ mod tests { is_err(ev2("MOD", x, d), Num); } close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); - close(ev2("MOD", 1_125_899_999_999.9998, 1.0), 0.989_990_234_375); + close(ev2("MOD", 1_125_899_999_999.999_8, 1.0), 0.989_990_234_375); } #[test] From a75b19e678af2fd039b996fe99e74bddb8648499 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:32:08 -0500 Subject: [PATCH 06/51] MOD test: use the literal Excel reads (15 significant digits) --- crates/functions/src/math.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 4b84e572d..cc21dfd8f 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1462,7 +1462,7 @@ mod tests { is_err(ev2("MOD", x, d), Num); } close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); - close(ev2("MOD", 1_125_899_999_999.999_8, 1.0), 0.989_990_234_375); + close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375); } #[test] From 5ed5c09bc667ff706c46710a1783c5d9c1c37cdd Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:39:22 -0500 Subject: [PATCH 07/51] NUMBERVALUE: a leftover dot is a decimal point, not a date separator --- crates/functions/src/text.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index db4b6d70c..09fbc2be4 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -338,6 +338,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { out.push(c); } } + // A leftover `.` can be a decimal point but never a date separator: `1.2.3` is `#VALUE!` in Excel. + if out.matches('.').count() > 1 { + return Err(CellError::Value); + } let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?; for _ in 0..pct { n /= 100.0; From c72f5a49b045adba72842afd845456babbb10c87 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:48:54 -0500 Subject: [PATCH 08/51] PERCENTRANK test: check the fifteenth digit with a tight tolerance --- crates/functions/src/stat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index b67aeb885..899a8a5c9 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -2507,7 +2507,7 @@ mod tests { // From significance 15 up the result has 15 significant digits. let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); for sig in [15.0, 16.0, 308.0] { - close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667); + close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); } close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); From 25816f0009361211ed5be25b1dd8bd1907656bc6 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:57:17 -0500 Subject: [PATCH 09/51] xlsx: write _xlfn. for STOCKHISTORY, TRANSLATE, DETECTLANGUAGE; none for NETWORKDAYS.INTL, WORKDAY.INTL --- crates/xlsx/src/tables.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 23c936c4a..361a79483 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -151,6 +151,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "CSCH", "DAYS", "DECIMAL", + "DETECTLANGUAGE", "DROP", "ENCODEURL", "ERF.PRECISE", @@ -206,7 +207,6 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "MODE.SNGL", "MUNIT", "NEGBINOM.DIST", - "NETWORKDAYS.INTL", "NORM.DIST", "NORM.INV", "NORM.S.DIST", @@ -243,6 +243,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "SORTBY", "STDEV.P", "STDEV.S", + "STOCKHISTORY", "SWITCH", "T.DIST", "T.DIST.2T", @@ -257,6 +258,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "TEXTSPLIT", "TOCOL", "TOROW", + "TRANSLATE", "TRIMRANGE", "UNICHAR", "UNICODE", @@ -267,7 +269,6 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "VSTACK", "WEBSERVICE", "WEIBULL.DIST", - "WORKDAY.INTL", "WRAPCOLS", "WRAPROWS", "XLOOKUP", @@ -352,6 +353,12 @@ mod tests { assert_eq!(file_function_name("FILTER").as_deref(), Some("_xlfn._xlws.FILTER")); assert_eq!(file_function_name("PERCENTOF").as_deref(), Some("_xlfn.PERCENTOF")); assert_eq!(file_function_name("TRIMRANGE").as_deref(), Some("_xlfn.TRIMRANGE")); + assert_eq!(file_function_name("stockhistory").as_deref(), Some("_xlfn.STOCKHISTORY")); + assert_eq!(file_function_name("TRANSLATE").as_deref(), Some("_xlfn.TRANSLATE")); + assert_eq!(file_function_name("DETECTLANGUAGE").as_deref(), Some("_xlfn.DETECTLANGUAGE")); + // Excel writes these two without a prefix. + assert_eq!(file_function_name("NETWORKDAYS.INTL"), None); + assert_eq!(file_function_name("WORKDAY.INTL"), None); assert_eq!(file_function_name("SUM"), None); assert_eq!(builtin_format(14), Some("m/d/yyyy")); assert_eq!(builtin_id("0.00%"), Some(10)); From f6015a7e6e9e3482f4ebf615ec87daf1999eb04e Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:21:04 -0500 Subject: [PATCH 10/51] Pin COPILOT as written without an _xlfn. prefix --- crates/xlsx/src/tables.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 361a79483..dd9e31b5e 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -359,6 +359,8 @@ mod tests { // Excel writes these two without a prefix. assert_eq!(file_function_name("NETWORKDAYS.INTL"), None); assert_eq!(file_function_name("WORKDAY.INTL"), None); + // Excel 16.0 build 20527 saves COPILOT as plain `COPILOT(...)` (it does not know the name yet). + assert_eq!(file_function_name("COPILOT"), None); assert_eq!(file_function_name("SUM"), None); assert_eq!(builtin_format(14), Some("m/d/yyyy")); assert_eq!(builtin_id("0.00%"), Some(10)); From 591c6ed6f9522c4d0b9a5ac00c79f7f662344104 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:40:56 -0500 Subject: [PATCH 11/51] Fix XLS style test Clippy complexity --- crates/xlsx/src/tests/xls_style_tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs index d944e8915..cfd2b4698 100644 --- a/crates/xlsx/src/tests/xls_style_tests.rs +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -197,6 +197,12 @@ fn reads_fonts_fills_borders_alignment_and_protection() { let (wb, report) = read_xls(&compound("Workbook", &formatted())).unwrap(); assert!(report.warnings[0].contains("cell formatting"), "{:?}", report.warnings); assert!(!report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + reads_primary_style(&wb); + reads_fallback_styles(&wb); + reads_cell_style_and_xlsx_roundtrip(&wb); +} + +fn reads_primary_style(wb: &Workbook) { // The Normal style's font is the workbook default. let normal = wb.styles.get(StyleId::DEFAULT); assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false)); @@ -222,7 +228,10 @@ fn reads_fonts_fills_borders_alignment_and_protection() { // A formatted blank cell keeps its formatting. assert_eq!(wb.sheets[0].style_id(CellRef::parse("B1").unwrap()), wb.sheets[0].style_id(CellRef::parse("A1").unwrap())); assert_eq!(wb.sheets[0].value(CellRef::parse("B1").unwrap()), Value::Empty); +} +fn reads_fallback_styles(wb: &Workbook) { + let normal = wb.styles.get(StyleId::DEFAULT); let s = style_at(&wb, "A2"); assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); @@ -240,7 +249,9 @@ fn reads_fonts_fills_borders_alignment_and_protection() { assert_eq!(wb.sheets[0].style_id(CellRef::parse("A5").unwrap()), StyleId::DEFAULT); // A blank cell in the default style isn't stored. assert!(wb.sheets[0].cell(CellRef::parse("A7").unwrap()).is_none()); +} +fn reads_cell_style_and_xlsx_roundtrip(wb: &Workbook) { let s = style_at(&wb, "A6"); assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF))); assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript)); From c82a1384629765240339438ddab8281d1382e80c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:47:18 -0500 Subject: [PATCH 12/51] M2.1: fix XLS style test clippy lints --- crates/xlsx/src/tests/xls_style_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs index cfd2b4698..1148208d3 100644 --- a/crates/xlsx/src/tests/xls_style_tests.rs +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -208,7 +208,7 @@ fn reads_primary_style(wb: &Workbook) { assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false)); assert_eq!(normal.font.color, Color::Auto); - let s = style_at(&wb, "A1"); + let s = style_at(wb, "A1"); let f = &s.font; assert_eq!((f.name.as_str(), f.size), ("Courier New", 14.0)); assert!(f.bold && f.italic && f.strike); @@ -232,12 +232,12 @@ fn reads_primary_style(wb: &Workbook) { fn reads_fallback_styles(wb: &Workbook) { let normal = wb.styles.get(StyleId::DEFAULT); - let s = style_at(&wb, "A2"); + let s = style_at(wb, "A2"); assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Distributed, VAlign::Distributed, 255)); - let s = style_at(&wb, "A3"); + let s = style_at(wb, "A3"); assert_eq!(s.font, normal.font, "a font index past the end falls back to the default font"); assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Right, VAlign::Top, 90)); assert_eq!(s.borders.right, BorderLine { style: BorderStyle::SlantDashDot, color: Color::Rgb(0x0000FF) }); @@ -252,19 +252,19 @@ fn reads_fallback_styles(wb: &Workbook) { } fn reads_cell_style_and_xlsx_roundtrip(wb: &Workbook) { - let s = style_at(&wb, "A6"); + let s = style_at(wb, "A6"); assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF))); assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript)); // User-defined styles are kept (first of a name wins); built-in ones are GridCraft's own. assert_eq!(wb.cell_styles.len(), 1, "{:?}", wb.cell_styles.iter().map(|(n, _)| n).collect::>()); assert_eq!(wb.cell_styles[0].0, "Fancy"); - assert_eq!(&wb.cell_styles[0].1, style_at(&wb, "A1")); + assert_eq!(&wb.cell_styles[0].1, style_at(wb, "A1")); // The formatting survives a save as .xlsx. - let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let (back, _) = read_xlsx(&write_xlsx(wb).unwrap()).unwrap(); for a1 in ["A1", "A2", "A3", "A6"] { - assert_eq!(style_at(&back, a1), style_at(&wb, a1), "{a1}"); + assert_eq!(style_at(&back, a1), style_at(wb, a1), "{a1}"); } assert_eq!(back.styles.get(StyleId::DEFAULT).font.name, "Arial"); assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::>(), ["Fancy"]); From b612141726987d3b98b3e80615e96036c17362ca Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:56:52 -0500 Subject: [PATCH 13/51] xlsx: keep intl date functions in xlfn list --- crates/xlsx/src/tables.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index dd9e31b5e..3cfd84c2d 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -207,6 +207,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "MODE.SNGL", "MUNIT", "NEGBINOM.DIST", + "NETWORKDAYS.INTL", "NORM.DIST", "NORM.INV", "NORM.S.DIST", @@ -269,6 +270,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "VSTACK", "WEBSERVICE", "WEIBULL.DIST", + "WORKDAY.INTL", "WRAPCOLS", "WRAPROWS", "XLOOKUP", @@ -356,9 +358,8 @@ mod tests { assert_eq!(file_function_name("stockhistory").as_deref(), Some("_xlfn.STOCKHISTORY")); assert_eq!(file_function_name("TRANSLATE").as_deref(), Some("_xlfn.TRANSLATE")); assert_eq!(file_function_name("DETECTLANGUAGE").as_deref(), Some("_xlfn.DETECTLANGUAGE")); - // Excel writes these two without a prefix. - assert_eq!(file_function_name("NETWORKDAYS.INTL"), None); - assert_eq!(file_function_name("WORKDAY.INTL"), None); + assert_eq!(file_function_name("NETWORKDAYS.INTL").as_deref(), Some("_xlfn.NETWORKDAYS.INTL")); + assert_eq!(file_function_name("WORKDAY.INTL").as_deref(), Some("_xlfn.WORKDAY.INTL")); // Excel 16.0 build 20527 saves COPILOT as plain `COPILOT(...)` (it does not know the name yet). assert_eq!(file_function_name("COPILOT"), None); assert_eq!(file_function_name("SUM"), None); From 22b6dfe8d70fdde8b61ce0307efb6ec2b40f5e89 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:00:26 -0500 Subject: [PATCH 14/51] gc-fix-2657: split YEARFRAC parity change --- crates/functions/src/math.rs | 4 ---- crates/functions/src/math/tests.rs | 24 +++++++++--------------- crates/functions/src/stat/descriptive.rs | 8 -------- crates/functions/src/stat/tests.rs | 17 ++++------------- crates/functions/src/text.rs | 19 ++++--------------- docs/function-parity.md | 4 +++- 6 files changed, 20 insertions(+), 56 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index a8222d5f9..6d4cdcf58 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -314,10 +314,6 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } - // Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16). - if (x / d).abs() >= 1.1259e12 { - return Err(CellError::Num); - } // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs index f0b1ccd5a..14f72d1e1 100644 --- a/crates/functions/src/math/tests.rs +++ b/crates/functions/src/math/tests.rs @@ -135,22 +135,16 @@ fn rounding() { } #[test] -fn mod_quotient_limit() { - // Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12. - for (x, d) in [ - (3.0, 1e-300), - (-1.0, 1e-300), - (3.0, -1e-300), - (-1.0, -1e-300), - (1e15, 7.0), - (1e10, 0.001), - (1_125_900_000_000.0, 1.0), - (-1_125_900_000_000.0, 1.0), - ] { - is_err(ev2("MOD", x, d), Num); +fn mod_tiny_divisor_stays_in_range() { + for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { + match ev2("MOD", x, d) { + Value::Number(r) => { + assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); + assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); + } + other => panic!("MOD({x}, {d}) = {other:?}"), + } } - close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); - close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375); } #[test] diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index a920b3c9a..af4c53607 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -336,10 +336,6 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { if sig < 1.0 || s.is_empty() { return Err(CellError::Num); } - // Excel: 10^significance overflows from 309 up. - if sig >= 309.0 { - return Err(CellError::Div0); - } let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); if x < first || x > last { return Err(CellError::NA); @@ -363,10 +359,6 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { let (rl, rh) = (rank_of(lo), rank_of(hi)); rl + (x - lo) / (hi - lo) * (rh - rl) }; - // From significance 15 up Excel returns 15 significant digits, rounded. - if sig >= 15.0 { - return num_val(format!("{r:.14e}").parse().unwrap_or(r)); - } num_val(trunc_digits(r, sig)) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 454f3a8bf..87ce19fac 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -214,19 +214,10 @@ fn percentiles() { close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); let t = c(&[1.0, 2.0, 3.0]); - // Excel 16: significance 309 and up is #DIV/0!, 308 still works. - for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] { - is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0); - is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0); - close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5); - } - // From significance 15 up the result has 15 significant digits. - let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); - for sig in [15.0, 16.0, 308.0] { - close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); - } - close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); - close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); + close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); + close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); } #[test] diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 09fbc2be4..df125f022 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -334,14 +334,12 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { if seen_dec { return Err(CellError::Value); } + } else if c == '.' || c == ',' { + return Err(CellError::Value); } else { out.push(c); } } - // A leftover `.` can be a decimal point but never a date separator: `1.2.3` is `#VALUE!` in Excel. - if out.matches('.').count() > 1 { - return Err(CellError::Value); - } let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?; for _ in 0..pct { n /= 100.0; @@ -1227,17 +1225,8 @@ mod tests { close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); close(ev("NUMBERVALUE", vec![t("")]), 0.0); is_err(ev("NUMBERVALUE", vec![t("1.2.3")]), CellError::Value); - // Excel 16 (en-US) swaps the given separators, then parses what is left in the host locale. - close(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), 1.5); - close(ev("NUMBERVALUE", vec![t("1 234.5"), t(","), t(" ")]), 1234.5); - close(ev("NUMBERVALUE", vec![t("2.5%"), t(","), t(" ")]), 0.025); - close(ev("NUMBERVALUE", vec![t("1.5E3"), t(","), t(" ")]), 1500.0); - close(ev("NUMBERVALUE", vec![t("1.5"), t(";")]), 1.5); - close(ev("NUMBERVALUE", vec![t("1.5"), t("x")]), 1.5); - close(ev("NUMBERVALUE", vec![t("1,5"), t(".")]), 15.0); - is_err(ev("NUMBERVALUE", vec![t("12.345,6"), t(","), t(" ")]), CellError::Value); - is_err(ev("NUMBERVALUE", vec![t("1.2.3"), t(","), t(" ")]), CellError::Value); - is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(".")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(" ")]), CellError::Value); is_text(ev("FIXED", vec![n(1234.567), n(1.0)]), "1,234.6"); is_text(ev("FIXED", vec![n(1234.567), n(-1.0)]), "1,230"); is_text(ev("FIXED", vec![n(-1234.567), n(1.0), b(true)]), "-1234.6"); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..8bd04b9f4 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-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: 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 +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (function edge-case parity row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: 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). @@ -86,6 +86,7 @@ This improves series preparation; it does not change the model-fitting or season | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | | Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) | | Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` | +| YEARFRAC basis 0, February-end start to day 31 | done: Excel 16.0 returns 31/360 for `YEARFRAC(2021-02-28,2021-03-31,0)` (0.08611111111111111); regression also covers leap day and reversed dates | | 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. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` | | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Added a function edge-case parity row. | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 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 | From 5c31d7a17e6bcd0ad07a6f24b80ef594794d94f4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:01:08 -0500 Subject: [PATCH 15/51] gc-fix-2657: isolate MOD parity fix --- crates/functions/src/math.rs | 4 ++++ crates/functions/src/math/tests.rs | 24 +++++++++++++++--------- docs/function-parity.md | 4 +++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 6d4cdcf58..a8222d5f9 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -314,6 +314,10 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } + // Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16). + if (x / d).abs() >= 1.1259e12 { + return Err(CellError::Num); + } // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs index 14f72d1e1..f0b1ccd5a 100644 --- a/crates/functions/src/math/tests.rs +++ b/crates/functions/src/math/tests.rs @@ -135,16 +135,22 @@ fn rounding() { } #[test] -fn mod_tiny_divisor_stays_in_range() { - for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { - match ev2("MOD", x, d) { - Value::Number(r) => { - assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); - assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); - } - other => panic!("MOD({x}, {d}) = {other:?}"), - } +fn mod_quotient_limit() { + // Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12. + for (x, d) in [ + (3.0, 1e-300), + (-1.0, 1e-300), + (3.0, -1e-300), + (-1.0, -1e-300), + (1e15, 7.0), + (1e10, 0.001), + (1_125_900_000_000.0, 1.0), + (-1_125_900_000_000.0, 1.0), + ] { + is_err(ev2("MOD", x, d), Num); } + close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); + close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375); } #[test] diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..0acbd3ae2 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-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: 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 +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (function edge-case parity row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: 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). @@ -90,6 +90,7 @@ This improves series preparation; it does not change the model-fitting or season | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` | | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 | +| MOD quotient limit | done: Excel 16.0 returns `#NUM!` when `ABS(number/divisor) >= 1.1259E12`; lower quotients retain the remainder | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | | Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Added a function edge-case parity row. | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 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 | From fbff1e301c6ed4c0f499666447a7126d22117c31 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:01:37 -0500 Subject: [PATCH 16/51] gc-fix-2657: isolate PERCENTRANK parity fix --- crates/functions/src/stat/descriptive.rs | 8 ++++++++ crates/functions/src/stat/tests.rs | 17 +++++++++++++---- docs/function-parity.md | 4 +++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index af4c53607..a920b3c9a 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -336,6 +336,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { if sig < 1.0 || s.is_empty() { return Err(CellError::Num); } + // Excel: 10^significance overflows from 309 up. + if sig >= 309.0 { + return Err(CellError::Div0); + } let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); if x < first || x > last { return Err(CellError::NA); @@ -359,6 +363,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { let (rl, rh) = (rank_of(lo), rank_of(hi)); rl + (x - lo) / (hi - lo) * (rh - rl) }; + // From significance 15 up Excel returns 15 significant digits, rounded. + if sig >= 15.0 { + return num_val(format!("{r:.14e}").parse().unwrap_or(r)); + } num_val(trunc_digits(r, sig)) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..454f3a8bf 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -214,10 +214,19 @@ fn percentiles() { close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); let t = c(&[1.0, 2.0, 3.0]); - close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); - close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); + // Excel 16: significance 309 and up is #DIV/0!, 308 still works. + for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] { + is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0); + is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0); + close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5); + } + // From significance 15 up the result has 15 significant digits. + let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + for sig in [15.0, 16.0, 308.0] { + close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); + } + close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); + close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); } #[test] diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..b61d2757c 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-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: 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 +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (function edge-case parity row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: 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). @@ -90,6 +90,7 @@ This improves series preparation; it does not change the model-fitting or season | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` | | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 | +| PERCENTRANK significance and precision | done: Excel 16.0 returns `#DIV/0!` from significance 309, and rounds to 15 significant digits for significance 15–308 | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | | Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Added a function edge-case parity row. | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 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 | From 3de6cb1a3d77dc1d1621a76dc5c6cf3efd5d27d1 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:09:50 -0500 Subject: [PATCH 17/51] gc-fix-2657: tighten PERCENTRANK boundary tolerance --- crates/functions/src/stat/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 454f3a8bf..7d3a52e9b 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -225,7 +225,7 @@ fn percentiles() { for sig in [15.0, 16.0, 308.0] { close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); } - close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); + close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66, 1e-15); close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); } From 5f7137ca01f4147913f3ddff728e2853d9f9bf3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:17:38 +0000 Subject: [PATCH 18/51] docs: tidy agent, contributor and asset docs --- AGENTS.md | 15 ++++------- ATTRIBUTION.md | 18 ++++++------- CLAUDE.md | 10 +++----- ROADMAP.md | 2 +- assets/app-icon/README.md | 2 +- assets/app-icon/gridcraft.svg | 2 +- .../apps/ai.storyteller.gridcraft.svg | 2 +- contributors/people.toml | 7 ++---- crates/ui-egui/build.rs | 7 +++--- crates/ui-egui/src/credits.rs | 3 +-- docs/contributors.md | 25 +++---------------- docs/file-format-parity.md | 2 +- docs/release-playbook.md | 9 ------- docs/releasing.md | 8 +++--- 14 files changed, 36 insertions(+), 76 deletions(-) delete mode 100644 docs/release-playbook.md diff --git a/AGENTS.md b/AGENTS.md index 42acddadb..e4fcf5570 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,6 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). 2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. @@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule - The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. ## Never crash -People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). @@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Non-negotiables - **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. -- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** @@ -51,7 +49,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. ## Contributor credits (About window) @@ -62,14 +60,11 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a1e4d8fbc..c93a94891 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,15 +12,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. | | `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. | | `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. | -| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | | +| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/LICENSE-brand.txt` | (licence text) | project | — | | | `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later | | `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via ``, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/CLAUDE.md b/CLAUDE.md index 1d316750c..3297f122a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,6 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). 2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. @@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule - The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. ## Never crash -People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). @@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Non-negotiables - **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. -- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** @@ -50,7 +48,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ROADMAP.md b/ROADMAP.md index 3d1a1d0d8..887df3f0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -233,7 +233,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) | | 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) | | 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) | -| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | +| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | | 2026-10-07 | About window with contributor and model credits; macOS release runner | diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index fdf0b0e09..349dcfd5a 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,7 +1,7 @@ # GridCraft app icon (PLACEHOLDER) **This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the -product owner makes (craftrules `standards/icon-design.md`); GridCraft's hasn't been made yet. +product owner makes; GridCraft's hasn't been made yet. Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number column, a selected cell with its fill handle and a double rule under a total, on a full-bleed GridCraft-green tile. It is original geometry written by hand for this repository, deliberately diff --git a/assets/app-icon/gridcraft.svg b/assets/app-icon/gridcraft.svg index fd53f9e1b..38e3e802a 100644 --- a/assets/app-icon/gridcraft.svg +++ b/assets/app-icon/gridcraft.svg @@ -1,7 +1,7 @@ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg index fd53f9e1b..38e3e802a 100644 --- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg @@ -1,7 +1,7 @@ diff --git a/contributors/people.toml b/contributors/people.toml index 6f7327033..0e5b14ca4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 07ab18a23..0c5dbf3e8 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose +//! people.toml entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index a20039d03..95c39a1a3 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,7 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is -//! read at run time. +//! build.rs from contributors/contributors.json; nothing is read at run time. use std::cmp::Ordering; diff --git a/docs/contributors.md b/docs/contributors.md index 811fb8548..7cb4a6240 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to GridCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. Never hand-edit. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -37,20 +35,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ea1cc15db..1523e9d94 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -97,7 +97,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Work | Hours | |---|---| -| Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | +| Real-world XLSX corpus run, and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | | Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | diff --git a/docs/release-playbook.md b/docs/release-playbook.md deleted file mode 100644 index b563a2400..000000000 --- a/docs/release-playbook.md +++ /dev/null @@ -1,9 +0,0 @@ -# Release playbook - -The release playbook shared by every crafting app lives in craftrules: -[`../craftrules/release/playbook.md`](../../../craftrules/release/playbook.md) -(`storytold/craftrules`, `release/playbook.md`). It is the canonical copy; don't duplicate it -here. The signing runbook is next to it in `release/signing-setup.md`. - -GridCraft's copy was taken from DesignCraft (`.github/workflows/release.yml`, `packaging/`, -`cargo xtask version`). GridCraft-specific details are in [releasing.md](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index 8e1687175..00ee84a91 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,8 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o **draft** GitHub Release named `GridCraft v`. Nobody sees a draft until a maintainer publishes it. -This is GridCraft's implementation of the shared [release playbook](release-playbook.md) -(craftrules `release/playbook.md`), copied from DesignCraft and renamed. User-facing names say +This process was copied from DesignCraft and renamed. User-facing names say **GridCraft**. Files, binaries and ids stay lowercase (`gridcraft---.`, `ai.storyteller.gridcraft`). @@ -223,7 +222,7 @@ shows a `::warning::`. Never store these as org- or repo-level Actions secrets ( `GITHUB_TOKEN` creates the release; only the final job gets `contents: write`. -The exact commands (from craftrules `release/signing-setup.md`, steps 1–3), run by an org +The repository setup commands, run by an org admin. Done for storytold/gridcraft on 2026-10-07; the `release-managers` team also has write access to the repo, which its ruleset bypass needs. @@ -273,8 +272,7 @@ the publisher. ## Icons `assets/app-icon/gridcraft.svg` is a **placeholder** (an original ledger-page drawing on -GridCraft green) until the owner makes GridCraft's engraved creature portrait (craftrules -`standards/icon-design.md`). `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px +GridCraft green) until the owner makes GridCraft's engraved creature portrait. `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px PNG, the `.icns` (iconutil), the `.ico` (`cargo xtask ico`) and the hicolor PNGs from it; it needs `resvg`. The outputs are committed, so packaging never needs those tools. Every file has a row in `ATTRIBUTION.md` (`cargo xtask assets`). From 23299040243400e7b8420be08aee800226a14635 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:25:43 +0000 Subject: [PATCH 19/51] docs: tidy wording --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 887df3f0c..40652c341 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -296,5 +296,5 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | -| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` | +| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; progress docs restructured; detail moved to `docs/` | | 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity | From 79722c76666055bb662ab64a3335ff91568833e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:42:26 +0000 Subject: [PATCH 20/51] updates: show check results with craft-update's CheckResultDialog --- Cargo.lock | 29 +++++++++++++++-------------- Cargo.toml | 4 ++-- apps/gridcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/updates_ui.rs | 29 +++++++++++++++++++---------- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c72e9ef2b..5da7c781d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -889,12 +889,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -917,9 +917,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-winprint-gdi", + "log", "miniz_oxide 0.9.1", "roxmltree", "tempfile", @@ -930,7 +931,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -950,7 +951,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", @@ -976,7 +977,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "egui", "objc2 0.6.5", @@ -1456,7 +1457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3461,7 +3462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3875,7 +3876,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4185,7 +4186,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4244,7 +4245,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4978,7 +4979,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5937,7 +5938,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9594d48ff..a43312950 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ gridcraft-pdf = { path = "crates/pdf" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in # the app) comes from one storytold/craft-libs revision. # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", version = "=0.1.0", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" @@ -63,7 +63,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } epaint_default_fonts = "0.37" # One native window per workbook: the window list, Window menu titles and the immediate-viewport # driver. Same craft-libs revision as craft-update above. -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 2ec400cd6..e5ad73da4 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -30,7 +30,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Same craft-libs revision as the workspace's craft-update and craft-window (see the root # Cargo.toml). -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b9cddcb90..297f7bab9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] # The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every # craft-libs dependency uses (see the root Cargo.toml). -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } # `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index b74151063..2553fbbc7 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,6 +2,7 @@ //! offers the new release: Update and Restart (direct self-update), Download or Later. The flow //! itself is `craft_update::updater::Updater`. +use craft_update::CheckResultResponse; use craft_update::updater::{Action, Status, Updater}; use crate::SheetApp; @@ -14,11 +15,13 @@ pub(crate) struct Runtime { startup: bool, /// Help ▸ Check for Updates… was chosen; the next `tick` starts it. manual: bool, + /// The result dialog of a requested check is showing. + result_open: bool, } impl Default for Runtime { fn default() -> Self { - Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false } + Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false, result_open: false } } } @@ -35,19 +38,14 @@ pub(crate) fn tick(app: &mut SheetApp, ctx: &egui::Context) { let rt = &mut app.updates_rt; if std::mem::take(&mut rt.manual) { rt.updater.check_now(ctx); + rt.result_open = true; } else if rt.startup { rt.updater.check_at_start(ctx); } rt.updater.tick(ctx); - let lang = app.ui.language; - let outcome = match rt.updater.status() { - Status::UpToDate => Some(lang.tr("GridCraft {version} is up to date.").replace("{version}", VERSION)), - Status::Failed(e) => Some(lang.tr("Could not check for updates: {error}").replace("{error}", e)), - Status::Idle | Status::Checking => None, - }; - if let Some(message) = outcome { - rt.updater.dismiss_status(); - app.message = Some(("GridCraft".into(), message)); + // A requested check that found a release leaves the status idle and the prompt opens. + if matches!(rt.updater.status(), Status::Idle) { + rt.result_open = false; } } @@ -65,4 +63,15 @@ pub(crate) fn show(app: &mut SheetApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } + if app.updates_rt.result_open { + let rt = &mut app.updates_rt; + match craft_update::CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) { + CheckResultResponse::Close => { + rt.updater.dismiss_status(); + rt.result_open = false; + } + CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::None => {} + } + } } From 671e194bb9e69bc25b5b92c4b314a119dc1d83fb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:37:17 +0100 Subject: [PATCH 21/51] Make the Merge & Center ribbon label part of its split button Fixes #2920 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ribbon/home.rs | 7 ++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/merge_center_label.rs | 51 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/it/merge_center_label.rs diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 72fce56cb..c061dfac3 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -5,7 +5,7 @@ use super::controls::{ use super::galleries::{cell_style_gallery, table_gallery}; use super::{ HAlign, Icon, Key, Modifiers, Rect, SheetApp, Style, Tokens, Ui, VAlign, big_button, color_palette, icon_button, json, keytip_table, - small_button, split_button, theme, toggle_button, vec2, + small_button, small_split_button, split_button, theme, toggle_button, vec2, }; /// The Accounting Number Format dropdown: label and the code Excel applies for that symbol. @@ -292,12 +292,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "home.increaseIndent", json!({})); } - let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center"); + let (m, ma) = small_split_button(ui, Icon::Merge, "Merge & Center", "Merge & Center"); control_keytip(app, ui, &ma, "home.mergeCenter"); - if m { + if m.clicked() { act(app, "home.mergeCenter", json!({})); } - ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5))); egui::Popup::menu(&ma).show(|ui| { menu_items( app, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 302bc9c49..b683f125d 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -63,6 +63,7 @@ mod keytips_generated; mod macro_editor; mod macros_ui; mod menu_hover; +mod merge_center_label; mod merged_cells; mod more_colors; mod native_print; diff --git a/crates/ui-egui/tests/it/merge_center_label.rs b/crates/ui-egui/tests/it/merge_center_label.rs new file mode 100644 index 000000000..16cb0e798 --- /dev/null +++ b/crates/ui-egui/tests/it/merge_center_label.rs @@ -0,0 +1,51 @@ +//! Home › Merge & Center is a split button: clicking its text label runs `home.mergeCenter`, like +//! its icon does. + +use egui::{Event, Shape, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; +use serde_json::json; + +#[test] +fn clicking_the_label_merges_and_centers() { + for language in [Language::En, Language::De] { + click_label(language); + } +} + +fn click_label(language: Language) { + let label = language.tr("Merge & Center"); + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "A1:B1"})).unwrap(); + let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + { + let mut app = crate::common::app(s); + app.ui.language = language; + app + }, + ); + h.run_steps(4); + let at = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == label => Some(t.pos + t.galley.size() / 2.0), + _ => None, + }) + .unwrap_or_else(|| panic!("the Home tab shows {label}")); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeCenter"), "{label}"); +} From 67e39faeea1fa4b603cbab421a30737db2cef8d9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 10:17:29 -0400 Subject: [PATCH 22/51] Review fixes: describe the YEARFRAC, MOD and PERCENTRANK changes in function-parity --- docs/function-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index 369fffa55..40ad424bc 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-31 · **Last updated:** 2026-10-31 · **Change:** minor (edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: function edge-case parity row; earlier: function edge-case parity row; earlier: function edge-case parity row; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **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). @@ -150,7 +150,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | 2026-10-31 | minor | BINOM.DIST and BINOM.DIST.RANGE reject trial counts above 2,147,483,646 after truncation | | 2026-10-31 | minor | BETA.DIST density returns `#NUM!` at the lower endpoint with alpha less than or equal to 1 and upper endpoint with beta less than or equal to 1; cumulative behavior is unchanged | | 2026-10-31 | minor | HARMEAN returns `#N/A` when array or reference inputs contain no numbers; nonpositive numeric observations remain `#NUM!` | -| 2026-10-30 | minor | Added a function edge-case parity row. | +| 2026-10-31 | minor | YEARFRAC basis 0 tests the end-31 rule against the original start day; MOD returns `#NUM!` from a quotient of 1.1259E12; PERCENTRANK returns `#DIV/0!` from significance 309 and 15 significant digits from 15 (Excel 16) | | 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | From 81aee9e075836cddf14ca63aba0ea082fb1d7498 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:41:24 +0100 Subject: [PATCH 23/51] calc: form the MAP result size in u64 so wasm32 cannot wrap the cap (#2165) `broadcast_len`, the MAX_CELLS guard shared by MAP, the binary operators, TEXT and array IF, multiplied `rows` and `cols` as `usize`. On wasm32 a broadcast such as 65536 x 65536 (2^32 cells) wraps that product to 0 and slips past the cap, after which the allocation and loop run far past MAX_CELLS. The product is now formed in u64 and converted back only after the cap check, so the guard means the same on every target. `single`, the per-cell scalar check in MAP and MAKEARRAY, compared `rows * cols` with 1. A constructed array stores exactly `rows * cols` values, so it now tests `data.len()` directly: the same answer without an unchecked product. Tests cover MAX_CELLS exactly, one cell over, the 2^32 pair that wraps to zero on a 32-bit usize, a full column x row (2^34), and MAP itself just over the cap and at 16384 x 16384. Closes #2165 --- crates/calc/src/eval/array.rs | 31 +++++++++++++++++++++++++++++-- crates/calc/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index ab6ac1b06..67662c83c 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -89,8 +89,14 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value /// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. /// Each operand passes its own size cap, but their broadcast product can be far larger /// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. +/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above +/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap. pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option { - rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) + let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?; + if cells > MAX_CELLS { + return None; + } + usize::try_from(cells).ok() } fn broadcast_dim(a: usize, b: usize) -> usize { @@ -124,7 +130,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, & /// can't nest, so the whole helper gives `#CALC!`, as in Excel. pub(super) fn single(v: Value) -> Option { match v { - Value::Array(a) if a.rows * a.cols > 1 => None, + Value::Array(a) if a.data.len() > 1 => None, v => Some(v.scalar()), } } @@ -178,3 +184,24 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), } } + +#[cfg(test)] +mod tests { + use super::{MAX_CELLS, broadcast_len}; + + #[test] + fn broadcast_len_caps_at_max_cells() { + assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize)); + assert_eq!(broadcast_len(4_000, 4_001), None); + assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize)); + } + + /// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a + /// `> MAX_CELLS` test would let through; the guard must reject the true product. + #[test] + fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() { + assert_eq!(65_536u32.wrapping_mul(65_536), 0); + assert_eq!(broadcast_len(65_536, 65_536), None); + assert_eq!(broadcast_len(1_048_576, 16_384), None); + } +} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index e2d62db66..89e6e7327 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1251,6 +1251,25 @@ fn array_broadcast_result_is_capped() { assert_eq!(t.num("C3"), 5.0); } +/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the +/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in +/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at +/// 16384 columns, so the widest product it can build is 16384 squared). +#[test] +fn map_caps_its_result_size() { + let mut t = T::new(); + for (at, formula) in + [("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")] + { + t.set(at, formula); + assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}"); + } + // Within the cap the broadcast shape is unchanged. + t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))"); + t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))"); + assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0)); +} + #[test] fn text_function() { let mut t = T::new(); From 40b254c3067b5872f322f773845dd86ad2a79355 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:52:49 +0100 Subject: [PATCH 24/51] fix(ui): refuse a locked cell's edit before a draft blocks Save (#2945) --- crates/engine/src/lib.rs | 11 +++ crates/engine/src/tests.rs | 11 +++ crates/ui-egui/src/app/editing.rs | 88 +++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/protected_cell.rs | 70 ++++++++++++++++++ 5 files changed, 181 insertions(+) create mode 100644 crates/ui-egui/tests/it/protected_cell.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3ae4ac95a..a470f2158 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -558,6 +558,17 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit + /// is allowed. Front ends ask before opening an editor, so typing into a locked cell never + /// leaves a draft that can't be entered (and so blocks saving). + pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option { + let d = self.active()?; + let sh = d.wb.sheet(sheet)?; + if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked { + return None; + } + cmd::locked_cell_error(sh, at, &d.unlocked_ranges) + } /// Opens a workbook in a new window, which becomes the active one. pub fn add_document(&mut self, d: DocState) -> usize { let uid = d.uid; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f99f246df..04d080eea 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1400,6 +1400,17 @@ fn protection_refuses_edits_of_locked_cells() { assert_eq!(v(&s, "A1"), Value::Number(1.0)); } +#[test] +fn locked_cell_message_matches_what_an_edit_would_be_refused_with() { + let s = protected(json!({})); + let locked = CellRef::parse("A1").unwrap(); + let unlocked = CellRef::parse("D1").unwrap(); + + assert!(s.locked_cell_message(0, locked).is_some_and(|m| m.contains("protected sheet"))); + assert_eq!(s.locked_cell_message(0, unlocked), None, "an unlocked cell stays editable"); + assert_eq!(s.locked_cell_message(9, locked), None, "no such sheet"); +} + #[test] fn protection_allows_permitted_row_and_column_edits() { let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true})); diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index 6890fb944..bb99ae260 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -15,6 +15,13 @@ impl SheetApp { let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + // A locked cell on a protected sheet can't be entered (Excel's warning, and the engine + // refuses `cell.set` anyway): refuse before a draft exists, rather than leaving one that + // can never be committed and blocks Save (#2945). + if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) { + self.message = Some(("GridCraft".into(), m)); + return; + } // A picture has no editable scalar text. Selecting it, F2, or clicking // the formula bar must not turn its fallback value into a text edit. // Typing still starts an intentional replacement. @@ -102,6 +109,13 @@ impl SheetApp { } return true; } + // A draft on a cell sheet protection now locks (the sheet was protected while it was + // open) can't be entered: drop it with the warning instead of keeping it, which would + // block Save with a draft that can never be committed (#2945). + if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) { + self.message = Some(("GridCraft".into(), m)); + return true; + } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -209,3 +223,77 @@ impl SheetApp { self.grid.ensure_visible = true; } } + +#[cfg(test)] +mod protected_cell_tests { + use super::*; + use gridcraft_engine::Session; + use gridcraft_engine::core::{CellRef, Value}; + + /// A protected sheet whose C2 is unlocked; C2 holds "keep" as the last undo step. + fn protected_book() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.run("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap(); + app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap(); + app.run("review.protectSheet", json!({})).unwrap(); + app + } + + fn value(app: &SheetApp, a1: &str) -> Value { + let at = CellRef::parse(a1).unwrap(); + app.session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default() + } + + fn protected_message(app: &SheetApp) -> Option<&str> { + app.message.as_ref().map(|(_, m)| m.as_str()) + } + + #[test] + fn typing_in_a_locked_cell_does_not_leave_a_draft_that_blocks_undo_and_save() { + let mut app = protected_book(); + assert_eq!(app.session.active().unwrap().selection.active.a1(), "A1", "the locked A1 is the active cell"); + let undo = app.session.active().unwrap().undo.len(); + let dirty = app.session.active().unwrap().is_dirty(); + + app.begin_edit(Some("locked".into()), false); + + assert!(app.editor.is_none(), "a locked cell must not open an editor"); + assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet")), "the warning names the protected sheet"); + assert_eq!(app.session.active().unwrap().undo.len(), undo, "a refused edit adds no undo step"); + assert_eq!(app.session.active().unwrap().is_dirty(), dirty, "a refused edit leaves the workbook as it was"); + assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok(), "Save is not blocked by a draft that can never be entered"); + } + + #[test] + fn an_unlocked_cell_on_a_protected_sheet_still_edits_undoes_and_saves() { + let mut app = protected_book(); + app.run("selection.set", json!({"cell": "C2"})).unwrap(); + app.begin_edit(Some("typed".into()), false); + assert!(app.editor.is_some(), "an unlocked cell opens its editor"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(value(&app, "C2"), Value::text("typed")); + + assert!(app.run("edit.undo", json!({})).is_ok()); + assert_eq!(value(&app, "C2"), Value::text("keep"), "Undo of the typed edit still works"); + assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + } + + #[test] + fn a_draft_that_becomes_locked_is_cancelled_instead_of_staying_open() { + let mut app = protected_book(); + app.run("selection.set", json!({"cell": "C2"})).unwrap(); + app.begin_edit(Some("typed".into()), false); + // The sheet is unprotected, C2 locked, and the sheet protected again while the draft is open. + app.run("review.unprotectSheet", json!({})).unwrap(); + app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": true}}})).unwrap(); + app.run("review.protectSheet", json!({})).unwrap(); + + assert!(app.commit_edit(0, 0, false, false), "the refused draft is handled, not left open"); + assert!(app.editor.is_none()); + assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet"))); + assert_eq!(value(&app, "C2"), Value::text("keep")); + assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b683f125d..9cebf6198 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -80,6 +80,7 @@ mod pivot_show_details; mod pivot_show_details_menu; mod popup_dismissal; mod protect_password; +mod protected_cell; mod ribbon_layout; mod right_to_left; mod scroll_position; diff --git a/crates/ui-egui/tests/it/protected_cell.rs b/crates/ui-egui/tests/it/protected_cell.rs new file mode 100644 index 000000000..15a3f5fd7 --- /dev/null +++ b/crates/ui-egui/tests/it/protected_cell.rs @@ -0,0 +1,70 @@ +//! Typing into a locked cell of a protected sheet warns before an editor opens, and the warning +//! leaves Undo, Save and typing in unlocked cells working (#2945). + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; + +/// A protected sheet whose C2 is unlocked and holds "keep"; A1 is locked and active. +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap(); + s.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + crate::support::harness(s, 4) +} + +fn key(h: &mut Harness, k: Key) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// Types text the way a keyboard does (into whatever has focus). +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn value(h: &Harness, a1: &str) -> Value { + let at = CellRef::parse(a1).unwrap(); + h.state().session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default() +} + +#[test] +fn typing_in_a_locked_cell_warns_without_a_stuck_draft() { + let mut h = harness(); + let undo = h.state().session.active().unwrap().undo.len(); + + type_text(&mut h, "locked"); + + assert!(h.state().editor.is_none(), "no editor opened on the locked cell"); + assert_eq!(h.state().message.as_ref().map(|(_, m)| m.as_str()), Some(PROTECTED)); + assert_eq!(h.state().session.active().unwrap().undo.len(), undo, "the refused edit added no undo step"); + assert_eq!(value(&h, "A1"), Value::Empty); + // No draft exists, so Save is not blocked while the warning is up. + assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + + // Enter dismisses the warning and the keyboard goes back to the grid. + key(&mut h, Key::Enter); + assert!(h.state().message.is_none(), "Enter dismisses the warning"); + + // An unlocked cell on the same sheet still edits, undoes and saves. + h.state_mut().run("selection.set", json!({"cell": "C2"})).unwrap(); + h.run_steps(1); + type_text(&mut h, "typed"); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("typed")); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "C2"), Value::text("typed")); + assert!(h.state_mut().run("edit.undo", json!({})).is_ok()); + assert_eq!(value(&h, "C2"), Value::text("keep"), "Undo of the unlocked cell's edit still works"); + assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); +} From 8fff311210e8c152a5f2dc29e8f3b646d0241987 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:57:38 +0100 Subject: [PATCH 25/51] range.setValues: report the strings typed input read, and clear "" with parse: false (#2255) The default still reads a string with typed input, which is en-US: "05.04.2026" from a dd.mm.yyyy export became 4 May, "2.500" became 2.5, and the command said nothing (#2255). `parse: false` (PR #2267) lets a caller keep the text, but a script that does not pass it still cannot notice the change. The result now names them: `converted` lists the strings typed input read as a number (dates and times included), a boolean or an error, with the cell, the input and what the cell shows, and `convertedCount` is the total. It is capped at 20 entries and 32 characters per string, so a big write does not put a second copy of its data in the result. A string written as a formula (`=1+1`) and a leading apostrophe are not conversions. `cells` and the default reading of the values are unchanged. Two `parse: false` fixes from the review of #2267: - An empty string clears the cell, as `null` and the default path do. Storing an empty text cell left a cell that is not blank: `values: [["", ""]]` gave COUNTA 2 and ISBLANK FALSE. - MCP `write_range` takes `parse`, so an agent can opt out too; the tool's description and docs/mcp.md say so. Tests: the issue's examples under both settings, the report and its cap, the empty string, and an XLSX save/reopen that keeps the text as text and the default's date as a date. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/edit/cell.rs | 62 +++++++++++++++++++++++++--- crates/engine/src/cmd/edit/tests.rs | 64 +++++++++++++++++++++++++++++ crates/mcp/src/tests.rs | 16 ++++++++ crates/mcp/src/tools.rs | 6 ++- docs/mcp.md | 2 +- 6 files changed, 142 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27e217474..798ed8a48 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Set Values", [], None, - "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}", + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}", has_doc, range_set_values ), diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index c57afae97..65d5b73ce 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -183,6 +183,22 @@ pub(crate) fn json_to_input(v: &Json) -> String { } } +/// How many converted strings `range.setValues` reports back, and how much of each input and +/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data +/// in the result. +const MAX_REPORTED_CONVERSIONS: usize = 20; +const MAX_REPORTED_CHARS: usize = 32; + +/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was +/// cut. Counting characters keeps a multi-byte input whole. +fn clipped(text: &str) -> String { + let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect(); + if out.len() < text.len() { + out.push('…'); + } + out +} + pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let start = target_range(s, p)?.start; @@ -200,6 +216,10 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { return Err(bad("range.setValues", "too many rows")); } let mut count = 0; + // The strings typed input read as something other than the text given, for the result: a + // caller can notice a "05.04.2026" that became a date without reading the file back (#2255). + let mut converted: Vec<(CellRef, String)> = Vec::new(); + let mut converted_count = 0; edit(s, |cx| { for (ri, row) in rows.iter().enumerate() { let cols: Vec<&Json> = match row.as_array() { @@ -222,15 +242,35 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { // text rather than the marker that makes it text. A formula is still a // formula, which is what the ticket asks for and what `=` means in a cell. Json::String(text) if !parse && !text.starts_with('=') => { - let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - // Line breaks still turn on Wrap Text, as typed input does. - if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { - style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + if text.is_empty() { + // An empty string clears the cell, as `null` and the default path do: + // an empty text cell is not blank, and a script clearing cells with + // `""` would leave cells `COUNTA` counts. + None + } else { + let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + // Line breaks still turn on Wrap Text, as typed input does. + if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { + style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) } - Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) } _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; + // A string that became a formula was written as one (`=`), which is deliberate; + // only a number (dates and times included), boolean or error is a reading of the + // string and worth reporting. + if let Some(c) = &cell + && matches!(v, Json::String(_)) + && c.formula.is_none() + && matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_)) + { + converted_count += 1; + if converted.len() < MAX_REPORTED_CONVERSIONS { + converted.push((at, input.clone())); + } + } let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.set_cell(at, c), @@ -245,7 +285,17 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { } Ok(()) })?; - Ok(json!({"cells": count})) + let d = s.doc()?; + let reported: Vec = + d.wb.sheet(sheet) + .map(|sh| { + converted + .iter() + .map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))})) + .collect() + }) + .unwrap_or_default(); + Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported})) } pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index c9e1e7a93..3701dfd19 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -192,6 +192,70 @@ fn set_values_can_store_strings_as_the_text_they_are() { assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); } +/// The result names the strings typed input read as something else, so a script notices without +/// reading the file back (#2255). A formula and a leading apostrophe are not conversions. +#[test] +fn set_values_reports_the_strings_typed_input_read() { + let mut s = Session::new(); + s.new_workbook(); + let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]); + let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(r["cells"], 6, "{r}"); + assert_eq!(r["convertedCount"], 4, "{r}"); + assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}"); + assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}"); + assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}"); + assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}"); + // The report is a diagnostic, not a second copy of the data: a long input is cut. + let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap(); + assert_eq!(long["convertedCount"], 1, "{long}"); + let input = long["converted"][0]["input"].as_str().unwrap_or_default(); + assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}"); + assert!(input.ends_with('…'), "{input}"); + // Nothing is read with `parse: false`, so nothing is reported. + let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap(); + assert_eq!(verbatim["convertedCount"], 0, "{verbatim}"); + assert_eq!(verbatim["converted"], json!([]), "{verbatim}"); +} + +/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a +/// script clearing cells with "" must not leave cells `COUNTA` counts. +#[test] +fn set_values_parse_false_clears_an_empty_string() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["type"], "empty"); + assert_eq!(get(&mut s, "B1")["type"], "empty"); + assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0)); +} + +/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the +/// default's reading of a date survives as a date (#2255). +#[test] +fn set_values_text_survives_an_xlsx_roundtrip() { + let mut s = Session::new(); + s.new_workbook(); + let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]); + s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap(); + s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap(); + let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); + let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] { + assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given"); + } + assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula"); + assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0)); + assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true)); + // The default read the date, and the file keeps it as one. + let at = CellRef::new(2, 0); + assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026"); + assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy"); +} + #[test] fn legacy_array_entry() { let mut s = Session::new(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 61cfc8dfd..71631b5e8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -252,6 +252,22 @@ fn save_and_open_roundtrip() { let _ = std::fs::remove_file(path); } +/// #2255: `parse: false` on `write_range` stores strings as the text they are, so a dd.mm.yyyy +/// date or a thousands separator survives, and the result names the strings the default read. +#[test] +fn write_range_can_store_strings_as_text() { + let mut s = server(); + let row = json!([["05.04.2026", "2.500", "4.25 %", "=1+1"]]); + let stored = ok(&mut s, "write_range", json!({"range": "A1", "parse": false, "values": row})); + assert_eq!(stored["convertedCount"], 0, "nothing was read: {stored}"); + let v = ok(&mut s, "read_range", json!({"range": "A1:D1"})); + assert_eq!(v["values"], json!([["05.04.2026", "2.500", "4.25 %", 2.0]]), "{v}"); + // Without it the strings are read like typed input, and the result says which. + let read = ok(&mut s, "write_range", json!({"range": "A3", "values": row})); + assert_eq!(read["convertedCount"], 3, "{read}"); + assert_eq!(read["converted"][0], json!({"cell": "A3", "input": "05.04.2026", "stored": "5/4/2026"}), "{read}"); +} + /// An agent builds a small monthly budget through MCP tool calls only. #[test] fn agent_builds_a_budget() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b422b5f34..f1a36b004 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -190,11 +190,13 @@ fn base_tool_definitions() -> Vec { "Write range", "Write a 2-D array of values starting at the top-left cell of `range` (rows of cells). Numbers, booleans, null (clears) \ and strings are accepted; strings starting with = are formulas (\"=SUM(B2:B4)\"), other strings are parsed like typed \ - input (\"12%\", \"1/2/2024\", \"$5\"). One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", + input (\"12%\", \"1/2/2024\", \"$5\") unless `parse` is false, which stores them as the text they are. The result's \ + `converted` lists the strings that were read as something else. One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", obj( json!({ "range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""), "values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}}, + "parse": boolean("Store every string not starting with = as the text it is, instead of reading it like typed input (default true)"), }), &["range", "values"], ), @@ -637,7 +639,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result "list_windows" => exec(b, "window.list", json!({})), "inspect_workbook" => b.call("document.inspect", json!({})), "read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])), - "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])), + "write_range" => exec(b, "range.setValues", pick(a, &["range", "values", "parse"])), "set_cell" => exec(b, "cell.set", pick(a, &["cell", "input"])), "get_cell" => exec(b, "cell.get", pick(a, &["cell"])), "evaluate_formula" => { diff --git a/docs/mcp.md b/docs/mcp.md index 32ea6e0c1..c3ee026b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,7 +44,7 @@ with a file open. | `list_windows` | — | Open windows: window id, workbook (`doc`) id, title ("Book1", or "Book1:2" for a workbook's second window), unsaved changes, active, hidden. | | `inspect_workbook` | — | Sheets, used ranges, tables, charts, names, selection, history. | | `read_range` | `range?`, `sheet?`, `formulas?`, `formatted?` | Rows of values (default: the used range). | -| `write_range` | `range`, `values` (2-D) | `range.setValues`; strings starting with `=` are formulas. | +| `write_range` | `range`, `values` (2-D), `parse?` | `range.setValues`; strings starting with `=` are formulas, other strings are read like typed input unless `parse: false` stores them as text. `converted` in the result lists the strings read as a number, date, time, boolean or error. | | `set_cell` / `get_cell` | `cell`, `input` / `cell` | Enter one cell / read value, formula, display text, style. | | `evaluate_formula` | `formula`, `cell?` | Evaluates without changing the sheet. A formula that couldn't be entered in a cell (it doesn't parse, or a function gets the wrong number of arguments) is an error saying why. | | `select` | `range`, `active?` | Sets the selection. | From 72b1310a5a6afd27e7c2e93b2cd778ddb7ab2e0c Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:57:58 +0100 Subject: [PATCH 26/51] #2765: CSV and text open detect each column's date order --- ROADMAP.md | 3 +- crates/engine/src/io.rs | 8 +- crates/engine/src/io/tests.rs | 14 ++ crates/xlsx/src/csv.rs | 245 ++++++++++++++++++++++++++++- crates/xlsx/src/lib.rs | 2 +- crates/xlsx/src/tests/csv_tests.rs | 82 +++++++++- docs/file-format-parity.md | 11 +- 7 files changed, 348 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40652c341..e59862919 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (CSV and text open detects each column's date order, so day-first dates are not silently swapped; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; macro engine and `macro.*` commands; macro interface on the Automate tab) · **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-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 71060a4c9..96d100aea 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -182,17 +182,17 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } Some(FileKind::Tsv) => { let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; - let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; // Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default. wb.csv_delimiter = None; rename_first_sheet(&mut wb, name); - Ok((wb, vec![])) + Ok((wb, report.warnings)) } _ => { let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() }; - let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; rename_first_sheet(&mut wb, name); - Ok((wb, vec![])) + Ok((wb, report.warnings)) } } } diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 130b1a6d9..97087999e 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -291,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() { assert_eq!(wb.sheets[0].name, "Sales"); } +#[test] +fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() { + let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap(); + let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap(); + for file in ["dates.csv", "dates.txt"] { + let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}"); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}"); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + } + let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap(); + assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}"); +} + /// A save that can't be put in place (here a folder already has the workbook's name, as /// with a file another app holds locked) fails without leaving `.sctmp` behind. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index dfc694ea6..460cd3227 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -2,11 +2,12 @@ use std::sync::Arc; -use gridcraft_core::parse::parse_input; -use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value}; +use gridcraft_core::date::{days_in_month_in, serial_from_ymd}; +use gridcraft_core::parse::{Parsed, parse_input}; +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, Value, col_to_letters}; use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; -use crate::{CsvOptions, IoError}; +use crate::{CsvOptions, IoError, ReadReport}; /// The largest CSV or tab-separated file written (bytes). pub const MAX_CSV_BYTES: usize = 512 << 20; @@ -248,13 +249,246 @@ pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: } } +/// How many rows and bytes of a text file are read to decide each column's date order. +const DATE_SAMPLE_ROWS: usize = 200; +const DATE_SAMPLE_BYTES: usize = 1 << 20; + +/// What a column's numeric dates say about the order of day and month. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateRule { + /// No `d/m/y` or `d-m-y` value in the sample: read as typed input does. + NoDates, + /// A value reads only month first (`12/31/2026`), none reads only day first. + MonthFirst, + /// A value reads only day first (`31/05/2026`), none reads only month first. + DayFirst, + /// Every date is ambiguous (`03/04/2026`): keep the input parser's month-first reading. + Ambiguous, + /// Values read only day first and values read only month first: the dates stay text. + Conflict, +} + +impl DateRule { + fn of(day_first: usize, month_first: usize, ambiguous: usize) -> Self { + match (day_first > 0, month_first > 0, ambiguous > 0) { + (true, true, _) => Self::Conflict, + (true, false, _) => Self::DayFirst, + (false, true, _) => Self::MonthFirst, + (false, false, true) => Self::Ambiguous, + (false, false, false) => Self::NoDates, + } + } +} + +/// The evidence a column's sample gives for its date order. +#[derive(Clone, Default)] +struct DateEvidence { + day_first: usize, + month_first: usize, + ambiguous: usize, + day_first_example: Option, + month_first_example: Option, + ambiguous_example: Option, +} + +/// A numeric `d/m/y` or `d-m-y` field, with the serial it has read each way and its time of day. +struct NumericDate { + day_first: Option, + month_first: Option, + time: Option, +} + +impl NumericDate { + /// The field under the column's rule: a date when that reading is valid, else text. + fn read(&self, field: &str, rule: DateRule) -> Parsed { + let serial = match rule { + DateRule::DayFirst => self.day_first, + DateRule::MonthFirst | DateRule::Ambiguous => self.month_first, + DateRule::Conflict | DateRule::NoDates => None, + }; + match serial { + Some(serial) => Parsed { + value: Value::number(serial + self.time.unwrap_or(0.0)), + format: Some(if self.time.is_some() { "m/d/yyyy h:mm" } else { "m/d/yyyy" }), + }, + None => Parsed { value: Value::text(field), format: None }, + } + } +} + +/// A numeric date written with `/` or `-`, with the serial it has read day first and month first. +/// `None` when the field isn't such a date (including ISO `yyyy-mm-dd`, month names and dots, +/// which read one way and keep the input parser). +fn numeric_date(field: &str, sys: DateSystem) -> Option { + let text = field.trim(); + let (date, time) = split_time(text, sys); + // Typed input reads a trailing comma after a date (`3/4/2026,`); do the same here. + let date = date.trim_end_matches(','); + let mut parts = date.split(['/', '-']); + let (a, b, c) = (parts.next()?, parts.next()?, parts.next()?); + if parts.next().is_some() { + return None; + } + let (a, b, c) = (a.trim(), b.trim(), c.trim()); + // ISO dates are unambiguous; the input parser reads them. A three-digit year is not a + // date to typed input either. + if a.len() == 4 || c.len() == 3 { + return None; + } + let a = parse_digits(a)?; + let b = parse_digits(b)?; + let year = expand_year(c, parse_digits(c)?); + let day_first = valid_date(sys, year, b, a); + let month_first = valid_date(sys, year, a, b); + if day_first.is_none() && month_first.is_none() { + return None; + } + Some(NumericDate { day_first, month_first, time }) +} + +/// Splits a field into its date and a time of day, when the tail reads as a time. Both the first +/// and last space are tried, so `03/04/2026 10:30 AM` and `03 / 04 / 2026 10:30` both split. +fn split_time(text: &str, sys: DateSystem) -> (&str, Option) { + for at in [text.find(' '), text.rfind(' ')].into_iter().flatten() { + let (date, time) = text.split_at(at); + let time = time.trim(); + // Times are short; a long tail is not one, and parsing it would be wasted work. + if time.len() > 40 { + continue; + } + if let Some(fraction) = time_of_day(time, sys) { + return (date.trim(), Some(fraction)); + } + } + (text, None) +} + +fn time_of_day(text: &str, sys: DateSystem) -> Option { + match parse_input(text, sys) { + Parsed { value: Value::Number(fraction), format: Some(code) } if code.contains('h') => Some(fraction), + _ => None, + } +} + +fn parse_digits(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// Expands a one- or two-digit year the way typed input does (`26` is 2026, `30` is 1930). +fn expand_year(text: &str, year: i64) -> i64 { + if text.len() <= 2 { if year < 30 { 2000 + year } else { 1900 + year } } else { year } +} + +/// The serial for a date the input parser accepts, or `None`. +fn valid_date(sys: DateSystem, year: i64, month: i64, day: i64) -> Option { + if !(1..=12).contains(&month) || day < 1 || year < 1900 { + return None; + } + let year = i32::try_from(year).ok()?; + let month = u32::try_from(month).ok()?; + if day > i64::from(days_in_month_in(sys, year, month)) { + return None; + } + serial_from_ymd(sys, i64::from(year), i64::from(month), day) +} + +/// The first records of the file, bounded by rows and bytes, for date-order inference. +fn sample_records(text: &str, delim: char, quote: char) -> Vec> { + let mut cut = text.len().min(DATE_SAMPLE_BYTES); + while !text.is_char_boundary(cut) { + cut -= 1; + } + let truncated = cut < text.len(); + let sample = text.get(..cut).unwrap_or_default(); + let mut rows = Vec::new(); + parse_records(sample, delim, quote, |record| { + rows.push(record); + rows.len() < DATE_SAMPLE_ROWS + }); + // A cut through the last record leaves a partial field; it says nothing reliable. + if truncated { + rows.pop(); + } + rows +} + +/// The date rule for each column, from the sample. +fn infer_date_rules(rows: &[Vec], sys: DateSystem) -> Vec<(DateRule, DateEvidence)> { + let width = rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_COLS as usize); + let mut columns = vec![DateEvidence::default(); width]; + for row in rows { + for (col, field) in row.iter().enumerate() { + let Some(column) = columns.get_mut(col) else { continue }; + let Some(date) = numeric_date(field, sys) else { continue }; + let example = || field.trim().chars().take(40).collect::(); + match (date.day_first.is_some(), date.month_first.is_some()) { + (true, true) => { + column.ambiguous += 1; + column.ambiguous_example.get_or_insert_with(example); + } + (true, false) => { + column.day_first += 1; + column.day_first_example.get_or_insert_with(example); + } + (false, true) => { + column.month_first += 1; + column.month_first_example.get_or_insert_with(example); + } + (false, false) => {} + } + } + } + columns.into_iter().map(|c| (DateRule::of(c.day_first, c.month_first, c.ambiguous), c)).collect() +} + +/// Warns when the sample could not choose a date order: an all-ambiguous column keeps the +/// month-first reading, and a column with both kinds keeps its dates as text. +fn date_warnings(columns: &[(DateRule, DateEvidence)], report: &mut ReadReport) { + for (col, (rule, evidence)) in columns.iter().enumerate() { + match rule { + DateRule::Ambiguous => report.warn(format!( + "column {}: every date is ambiguous, so it was read month first ({})", + col_to_letters(col as u32), + evidence.ambiguous_example.as_deref().unwrap_or("a date") + )), + DateRule::Conflict => report.warn(format!( + "column {}: it holds both day-first and month-first dates ({} and {}), so its dates stay text", + col_to_letters(col as u32), + evidence.day_first_example.as_deref().unwrap_or("a day-first date"), + evidence.month_first_example.as_deref().unwrap_or("a month-first date") + )), + DateRule::NoDates | DateRule::MonthFirst | DateRule::DayFirst => {} + } + } +} + +/// Reads one field with the column's date rule; other values go through typed input. +fn read_field(field: &str, sys: DateSystem, rule: DateRule) -> Parsed { + match numeric_date(field, sys) { + Some(date) if rule != DateRule::NoDates => date.read(field, rule), + _ => parse_input(field, sys), + } +} + pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { + read_csv_with_report(bytes, opts).map(|(wb, _)| wb) +} + +/// [`read_csv`], with the warnings an open shows: a column whose date order had to be guessed, +/// or whose dates stayed text because it holds both orders. +pub fn read_csv_with_report(bytes: &[u8], opts: &CsvOptions) -> Result<(Workbook, ReadReport), IoError> { let text = decode_text(bytes); let quote = if opts.quote == 0 { '"' } else { opts.quote as char }; let delim = if opts.delimiter == 0 { sniff_delimiter(&text, quote) } else { opts.delimiter } as char; if delim == quote { return Err(IoError::Format("the delimiter and quote character must differ".into())); } + let date_columns = if opts.parse_values { infer_date_rules(&sample_records(&text, delim, quote), opts.date_system) } else { vec![] }; + let mut report = ReadReport::default(); + date_warnings(&date_columns, &mut report); let mut wb = Workbook::new(); wb.date_system = opts.date_system; wb.csv_delimiter = u8::try_from(delim).ok(); @@ -276,7 +510,8 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { } let pos = CellRef::new(row, col as u32); let cell = if opts.parse_values { - let p = parse_input(&f, opts.date_system); + let rule = date_columns.get(col).map_or(DateRule::NoDates, |(rule, _)| *rule); + let p = read_field(&f, opts.date_system, rule); let style = match p.format { Some(code) => match fmt_cache.iter().find(|(c, _)| *c == code) { Some((_, id)) => *id, @@ -299,7 +534,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { }); wb.styles = styles; wb.sheets = vec![Arc::new(sheet)]; - Ok(wb) + Ok((wb, report)) } /// Writes the sheet as CSV from A1 to the end of its used range, so leading empty columns and rows diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b22820..dab1bb5af 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -45,7 +45,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited}; +pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited}; pub use ods::read_ods; pub use package::MAX_PART; pub use passthrough::rebase_links; diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index 066f507e0..585fb680f 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value}; use gridcraft_model::{Sheet, Workbook}; use crate::csv::{decode_text, parse_records, sniff_delimiter}; -use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; +use crate::{CsvOptions, Format, IoError, read_csv, read_csv_with_report, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { wb.sheet(0).unwrap().value(CellRef::new(r, c)) @@ -23,6 +23,86 @@ fn quoting_and_newlines() { assert_eq!(v(&wb, 3, 0), Value::text("last")); } +fn date(y: i64, m: i64, d: i64) -> Value { + Value::Number(gridcraft_core::date::serial_from_ymd(DateSystem::D1900, y, m, d).unwrap()) +} + +#[test] +fn day_first_columns_read_ambiguous_dates_day_first() { + let src = "CustomerID,Name,City,SignupDate,RenewalDate,CardNumber,Balance\n\ + 00123,Zoë Ångström,Malmö,03/04/2026,25/12/2026,4111111111111111,1234.50\n\ + 00456,José Núñez,São Paulo,12/01/2026,01/02/2027,5500005555555559,99.90\n\ + 00789,Françoise Lefèvre,Besançon,31/05/2026,30/11/2026,4012888888881881,0.00\n\ + 01000,Jürgen Weiß,Köln,07/08/2026,13/09/2026,3530111333300000,-15.25\n"; + let (wb, report) = read_csv_with_report(src.as_bytes(), &CsvOptions::default()).unwrap(); + // A value that reads only day first (31/05, 25/12) makes the whole column day first. + assert_eq!(v(&wb, 1, 3), date(2026, 4, 3)); + assert_eq!(v(&wb, 2, 3), date(2026, 1, 12)); + assert_eq!(v(&wb, 3, 3), date(2026, 5, 31)); + assert_eq!(v(&wb, 4, 3), date(2026, 8, 7)); + assert_eq!(v(&wb, 1, 4), date(2026, 12, 25)); + assert_eq!(v(&wb, 2, 4), date(2027, 2, 1)); + assert_eq!(v(&wb, 3, 4), date(2026, 11, 30)); + assert_eq!(v(&wb, 4, 4), date(2026, 9, 13)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn month_first_columns_keep_month_first_without_a_warning() { + let (wb, report) = read_csv_with_report(b"When\n12/31/2026\n01/02/2027\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 12, 31)); + assert_eq!(v(&wb, 2, 0), date(2027, 1, 2)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn an_all_ambiguous_date_column_keeps_month_first_and_warns() { + let (wb, report) = read_csv_with_report(b"When\n03/04/2026\n12/01/2026\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 3, 4)); + assert_eq!(v(&wb, 2, 0), date(2026, 12, 1)); + assert!(report.warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{:?}", report.warnings); + // Keeping every field as text reads no dates, so there is no order to guess. + let (_, report) = read_csv_with_report(b"When\n03/04/2026\n12/01/2026\n", &CsvOptions { parse_values: false, ..Default::default() }).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn a_column_with_both_orders_keeps_its_dates_as_text_and_warns() { + let (wb, report) = read_csv_with_report(b"When\n31/05/2026\n12/31/2026\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), Value::text("31/05/2026")); + assert_eq!(v(&wb, 2, 0), Value::text("12/31/2026")); + assert!(report.warnings.iter().any(|w| w.contains("column A") && w.contains("both day-first and month-first")), "{:?}", report.warnings); +} + +#[test] +fn dashes_and_two_digit_years_follow_the_column_order() { + let (wb, report) = read_csv_with_report(b"When\n31-05-26\n03-04-26\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 5, 31)); + assert_eq!(v(&wb, 2, 0), date(2026, 4, 3)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + + let (wb, report) = read_csv_with_report(b"When\n12-31-26\n01-02-27\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 12, 31)); + assert_eq!(v(&wb, 2, 0), date(2027, 1, 2)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn a_day_first_date_and_time_is_read_day_first() { + let (wb, report) = read_csv_with_report(b"When\n31/05/2026 10:30\n03/04/2026 10:30\n", &CsvOptions::default()).unwrap(); + let at = |y, m, d| Value::Number(gridcraft_core::date::serial_from_ymd(DateSystem::D1900, y, m, d).unwrap() + 10.5 / 24.0); + assert_eq!(v(&wb, 1, 0), at(2026, 5, 31)); + assert_eq!(v(&wb, 2, 0), at(2026, 4, 3)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn iso_dates_are_not_reordered_by_their_column() { + let wb = read_csv(b"When\n31/05/2026\n2026-03-04\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 5, 31)); + assert_eq!(v(&wb, 2, 0), date(2026, 3, 4)); +} + #[test] fn values_and_formats() { let src = "1,2.5,\"1,234\",50%,TRUE,#N/A,2026-03-04,$5.00,'007,text"; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..5155cbc83 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-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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** @@ -66,10 +66,10 @@ values) is still dropped. | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | -| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | -| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes | Text Import Wizard options partly via Text to Columns | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | | `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | @@ -110,6 +110,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From 85bfe358dce1bdb9905befd208b019a6c1b0a9c7 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:57:28 +0100 Subject: [PATCH 27/51] Ribbon: each window keeps its own tab, so Table Design stays active (#2903) --- crates/ui-egui/src/window_ui.rs | 12 +++-- .../ui-egui/tests/it/contextual_ribbon_tab.rs | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index bc6f31a3d..dbabe9b28 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -26,15 +26,19 @@ pub struct WindowUi { selected_chart: Option, tab_run: Option<(u64, usize, u32, CellRef)>, open_switch_windows: bool, + /// The ribbon tab this window shows. A contextual tab depends on this window's selection, so + /// another window's frame must not reset it. + ribbon_tab: String, /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. mode: gridcraft_engine::Mode, } impl WindowUi { /// A new window's state. The last border, fill and font colour picked carry over, as they are - /// the user's recent choices rather than the window's. - fn fresh(like: &grid::GridState) -> WindowUi { + /// the user's recent choices rather than the window's; so does the ribbon tab. + fn fresh(like: &grid::GridState, ribbon_tab: &str) -> WindowUi { WindowUi { + ribbon_tab: ribbon_tab.to_string(), grid: grid::GridState { last_border: like.last_border.clone(), last_fill: like.last_fill.clone(), @@ -64,6 +68,7 @@ impl SheetApp { selected_chart, tab_run, open_switch_windows, + ribbon_tab, mode, } = w; std::mem::swap(&mut self.editor, editor); @@ -79,6 +84,7 @@ impl SheetApp { std::mem::swap(&mut self.selected_chart, selected_chart); std::mem::swap(&mut self.tab_run, tab_run); std::mem::swap(&mut self.open_switch_windows, open_switch_windows); + std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab); std::mem::swap(&mut self.session.mode, mode); } @@ -100,7 +106,7 @@ impl SheetApp { self.ui_window = active; return; }; - let mut outgoing = WindowUi::fresh(&self.grid); + let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab); self.swap_window_ui(&mut outgoing); if self.session.window(previous).is_some() { self.parked.insert(previous, outgoing); diff --git a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs index ae318a22a..7ca8e8a21 100644 --- a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs @@ -74,3 +74,49 @@ fn an_ordinary_tab_stays_put_in_a_session() { h.run_steps(3); assert_eq!(h.state().ui.ribbon_tab, "Data"); } + +#[test] +fn a_second_window_does_not_reset_the_first_windows_table_design_tab() { + use egui::{Event, PointerButton, Shape}; + + let mut h = harness(session_with_table()); + let first = h.state().session.active_window().unwrap(); + // A second window on the same workbook, with its active cell outside the table. + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().run("selection.set", json!({"cell": "D10"})).unwrap(); + h.state_mut().run("window.activate", json!({"window": first})).unwrap(); + h.run_steps(2); + let tab_center = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == "Table Design" => Some(t.pos + t.galley.size() / 2.0), + _ => None, + }) + .expect("the Table Design tab is painted while a cell is in the table"); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { + pos: tab_center, + button: PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Table Design"); + // DocWindows draws every window each frame. The second window's cell is outside the table, + // so its frame falls back to Home; the first window's Table Design tab must survive it. + h.state_mut().show_window(second); + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Home", "the second window shows Home for its own selection"); + h.state_mut().enter_window(first); + h.run_steps(2); + assert_eq!(h.state().session.active_window(), Some(first)); + assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the first window's tab survives the second window's frame"); + let d = h.state().session.active().unwrap(); + assert_eq!(d.selection.active.a1(), "A2", "the first window still selects a cell in the table"); + assert!(d.wb.active().unwrap().table_at(d.selection.active).is_some(), "the table is still there"); +} From f2bef898e87ce23279b318197fb8640565320d48 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sat, 31 Oct 2026 18:09:27 +0100 Subject: [PATCH 28/51] #3035: let Esc leave File-page fields before closing --- ROADMAP.md | 4 +- crates/ui-egui/src/backstage.rs | 6 ++- crates/ui-egui/tests/it/file_menu.rs | 66 ++++++++++++++++++++++++++++ docs/ui-parity.md | 5 ++- 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40652c341..064de8093 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (Esc leaves a File-page text field before closing the view; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **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-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 6310dd6a0..194533d36 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -114,6 +114,8 @@ pub struct Backstage { pub page: Page, /// Info: property drafts while a field is being edited (committed when it loses focus). props: [String; 6], + /// Focus at the end of the previous frame, before egui clears it on Esc. + text_edit_focused: bool, /// Print: the whole workbook rather than the active sheet. pub print_all: bool, /// Print: fit each sheet on one page. @@ -138,6 +140,7 @@ impl Backstage { Backstage { page, props: Default::default(), + text_edit_focused: false, print_all: false, fit_to_page: false, preview_page: 0, @@ -160,7 +163,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let Some(mut bs) = app.backstage.take() else { return }; let ctx = ui.ctx().clone(); // Esc inside a text field leaves the field; it closes the view only from outside one. - let typing = ctx.text_edit_focused(); + let typing = bs.text_edit_focused; let t = Tokens::get(&ctx); let lang = app.ui.language; let mut out = Outcome::default(); @@ -229,6 +232,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); }); }); + bs.text_edit_focused = ctx.text_edit_focused(); if !typing && app.dialog.is_none() && app.message.is_none() diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index fb0779631..406ef6742 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -62,6 +62,72 @@ fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() { assert!(h.state().ui.ribbon_collapsed); } +/// Esc first cancels the focused File-page field, then closes File on the next press. +#[test] +fn escape_leaves_file_fields_before_closing_the_view() { + use egui::accesskit::Role; + + for (page_id, label) in [ + ("info", "Title"), + ("info", "Subject"), + ("info", "Author"), + ("info", "Company"), + ("info", "Keywords"), + ("info", "Description"), + ("options", "Maximum Iterations:"), + ("options", "Maximum Change:"), + ("print", "Copies"), + ] { + let services = Services { + printers: Some(Box::new(|| { + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(Vec::new()).unwrap(); + rx + })), + ..Default::default() + }; + let mut session = Session::new(); + session.new_workbook(); + let mut h = crate::common::harness_with( + crate::common::builder().with_size(egui::vec2(1200.0, 1400.0)), + crate::common::prepare(SheetApp::new(session, services)), + 4, + ); + h.state_mut().run("formulas.calculationOptions", serde_json::json!({"iterative": true})).unwrap(); + h.state_mut().run("ui.backstage", serde_json::json!({"page": page_id})).unwrap(); + h.run_steps(3); + let role = if page_id == "print" { Role::SpinButton } else { Role::TextInput }; + h.get_by_role_and_label(role, label).click(); + h.run_steps(2); + assert!(h.ctx.text_edit_focused(), "{label} has the caret"); + let original = h.get_by_role_and_label(role, label).value(); + let revision = h.state().session.active().unwrap().revision; + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.input_mut().events.push(egui::Event::Text("7".into())); + h.run_steps(2); + if page_id != "print" { + assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value().as_deref(), Some("7")); + } + let copies = h.state().printing.settings.copies; + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(page(&h), Some(page_id), "Esc in {label} keeps File open"); + assert!(!h.ctx.text_edit_focused(), "Esc leaves {label}"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "Esc does not commit {label}"); + if page_id == "print" { + assert_eq!(h.state().printing.settings.copies, copies, "leaving Copies preserves its existing live value"); + } else { + assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value(), original, "Esc discards the {label} draft"); + } + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(page(&h), None, "the next Esc returns to the sheet"); + } +} + #[test] fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { let downloads = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0e1a98f47..94ce53217 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -31,7 +31,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes; PivotTable source-field list scrollbar sits at the right edge of its pane | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | template gallery missing | | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239); icons, reference colours, primary buttons and the comments pane follow the theme, and a unit test holds every token pair to WCAG 2 contrast in both themes | -| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | +| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return; Esc in a text field first cancels editing and leaves the field, #3035) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344), and whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures missing (1–3 h). Unverified: with Align with Page Margins off, header and footer text sits 0.7 in from the paper edge, a value not yet measured in the target app | | Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. View › Arrange All (dialog: Tiled, Horizontal, Vertical, Cascade, Windows of active workbook; `view.arrangeAll` tiles by default) lays the visible windows out over the main window's monitor, the active one first. Web: one window | Arrange All uses one monitor; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | @@ -194,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | From 9654c2030a80a70fa3050928a18021fd21a716c5 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sat, 31 Oct 2026 18:21:13 +0100 Subject: [PATCH 29/51] #3035: assert Copies updates before Escape --- crates/ui-egui/tests/it/file_menu.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 406ef6742..7cc455fa7 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -106,7 +106,9 @@ fn escape_leaves_file_fields_before_closing_the_view() { h.run_steps(1); h.input_mut().events.push(egui::Event::Text("7".into())); h.run_steps(2); - if page_id != "print" { + if page_id == "print" { + assert_eq!(h.state().printing.settings.copies, 7, "Copies applies the entered value while editing"); + } else { assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value().as_deref(), Some("7")); } let copies = h.state().printing.settings.copies; From f2a75eca1b5ae8bef66e9fbbf8ec27bb287aef3f Mon Sep 17 00:00:00 2001 From: Daniel Lubryka Date: Sat, 31 Oct 2026 18:47:15 +0100 Subject: [PATCH 30/51] M2.1: keep BIFF8 font family and charset when importing .xls fonts Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/xls/styles.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs index 8bd4ca9f8..007849753 100644 --- a/crates/xlsx/src/xls/styles.rs +++ b/crates/xlsx/src/xls/styles.rs @@ -183,7 +183,9 @@ fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { let weight = c.u16().ok()?; let script = c.u16().ok()?; let underline = c.u8().ok()?; - c.skip(3).ok()?; // family, character set, unused + let family = c.u8().ok()?; + let charset = c.u8().ok()?; + c.skip(1).ok()?; // reserved let name = c.short_string().ok()?; let mut f = Font::default(); let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect(); @@ -209,6 +211,8 @@ fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { 0x22 => Underline::DoubleAccounting, _ => Underline::None, }; + f.family = Some(family); + f.charset = Some(charset); Some((f, icv)) } From 879ea7385ff0d35ebd476be19f6a15c285f49062 Mon Sep 17 00:00:00 2001 From: Daniel Lubryka Date: Sat, 31 Oct 2026 19:01:26 +0100 Subject: [PATCH 31/51] M2.1: test that .xls fonts keep their family and charset Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/tests/xls_style_tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs index 1148208d3..344cf37ed 100644 --- a/crates/xlsx/src/tests/xls_style_tests.rs +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -40,6 +40,14 @@ fn font(twips: u16, flags: u16, icv: u16, weight: u16, script: u16, underline: u rec(0x0031, &p) } +/// Sets the family and character-set bytes of a Font record built by `font` (after the 4-byte +/// record header, 10 bytes of fields and the underline byte). +fn with_family_charset(mut font_record: Vec, family: u8, charset: u8) -> Vec { + font_record[15] = family; + font_record[16] = charset; + font_record +} + /// The CellXF fields an XF record carries after ifnt, ifmt and the protection flags. #[derive(Clone, Copy, Default)] struct XfData { @@ -270,6 +278,18 @@ fn reads_cell_style_and_xlsx_roundtrip(wb: &Workbook) { assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::>(), ["Fancy"]); } +#[test] +fn font_family_and_charset_are_kept() { + let mut f = default_fonts(); + // ifnt 5: family 3 (modern) and character set 0xEE (Eastern European). + f.push(with_family_charset(font(200, 0, 0x7FFF, 400, 0, 0, "Courier New"), 3, 0xEE)); + f.push(xf(0, 0, STYLE, plain())); + f.push(xf(5, 0, CELL, plain())); + let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0)]))).unwrap(); + let s = style_at(&wb, "A1"); + assert_eq!((s.font.name.as_str(), s.font.family, s.font.charset), ("Courier New", Some(3), Some(0xEE))); +} + #[test] fn without_a_palette_the_default_colours_apply() { let mut f = default_fonts(); From d25445f4e309a83c9dd9164e7dfbab3243e8d199 Mon Sep 17 00:00:00 2001 From: Daniel Lubryka Date: Sat, 31 Oct 2026 19:14:33 +0100 Subject: [PATCH 32/51] M2.1: document the .xls style helpers Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/xls/styles.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs index 007849753..cc7c496f2 100644 --- a/crates/xlsx/src/xls/styles.rs +++ b/crates/xlsx/src/xls/styles.rs @@ -225,6 +225,7 @@ fn color(icv: u16, table: &[u32; 64]) -> Color { } } +/// A border line style from its BIFF8 code (dgLeft and the like); unknown codes are no border. fn border_style(dg: u32) -> BorderStyle { match dg { 1 => BorderStyle::Thin, @@ -244,6 +245,7 @@ fn border_style(dg: u32) -> BorderStyle { } } +/// A fill pattern from its BIFF8 code (fls); unknown codes are no pattern. fn pattern(fls: u16) -> PatternType { match fls { 0x01 => PatternType::Solid, @@ -268,6 +270,7 @@ fn pattern(fls: u16) -> PatternType { } } +/// One border side: its style and colour; a side without a style has no colour. fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine { let style = border_style(dg); if style == BorderStyle::None { @@ -288,6 +291,7 @@ fn xf_font(ifnt: u16, fonts: &[Font]) -> Font { fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default() } +/// The number format an XF names: a custom format, else a built-in one, else General. fn num_fmt(ifmt: u16, formats: &HashMap) -> NumFmt { let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General"); if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) } From aacdec4138c37a818d58ccdb364b4bdd28987664 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:00:24 +0100 Subject: [PATCH 33/51] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From 1a0693341083a946dd246d152505b69a6f5c67e8 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:45 +0100 Subject: [PATCH 34/51] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From f2fab9e1619a0b7877465b15bd04f49ee6ebb552 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:47 +0100 Subject: [PATCH 35/51] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From edcb626c3c124c600eb97fb4feaaa3909525ad5f Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:48 +0100 Subject: [PATCH 36/51] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From ea3460ea19a575b4512fb54130d91c37f85e9262 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:50 +0100 Subject: [PATCH 37/51] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From 9276827b1a12d8915950e172464814b4bf1a56cf Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan <126534658+scks1@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:31:07 -0400 Subject: [PATCH 38/51] xlsx: keep the workbook view settings across an open + save (#3057) The reader took only activeTab from and the writer wrote that alone back, so a workbook Excel saved with the sheet tabs and both scroll bars hidden (and a non-default tab split) opened with all three shown and lost the settings on save. Workbook gains a view field (WorkbookView: show_sheet_tabs, show_horizontal_scroll, show_vertical_scroll, tab_ratio; the format defaults are shown/shown/shown/600). The reader fills it from the attributes; the writer emits each attribute only when it differs from the default, so an ordinary workbook file is unchanged. Regression test: workbook_view_settings_survive_a_round_trip reads a package carrying the hidden settings, checks the model, and checks the written workbook.xml keeps all four attributes (and that a default workbook still writes the bare element). Scope: this is the data-loss half of #3057. The app does not act on the settings yet and File > Options has no controls for them; that is the other half the ticket was split into. --- crates/model/src/lib.rs | 2 +- crates/model/src/workbook.rs | 45 ++++++++++++++++++++++++++++++ crates/xlsx/src/read.rs | 18 ++++++++++-- crates/xlsx/src/tests/roundtrip.rs | 34 ++++++++++++++++++++++ crates/xlsx/src/write.rs | 25 ++++++++++++++++- 5 files changed, 120 insertions(+), 4 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index d3cbfc0db..46ca86b68 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -27,7 +27,7 @@ pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, VAlign, VertAlign, }; -pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook}; +pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView}; /// Font used for new workbooks. Only a name: renderers substitute an available open font /// (e.g. the metric-compatible Carlito) when it isn't installed. diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 0d82ca6ff..0942f6773 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -30,6 +30,47 @@ pub enum ShowObjects { None, } +/// The workbook window's display settings from `` (Excel: File › Options › +/// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs +/// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`, +/// thousandths of the bar; the format's default is 600). +/// +/// The values are modelled so an open + save keeps them — a workbook Excel saved with the +/// tabs hidden must not come back with them shown and the settings gone. The app does not +/// act on them yet and there is no Options control for them; that is the other half of +/// storytold/gridcraft#3057. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkbookView { + #[serde(default = "yes")] + pub show_sheet_tabs: bool, + #[serde(default = "yes")] + pub show_horizontal_scroll: bool, + #[serde(default = "yes")] + pub show_vertical_scroll: bool, + #[serde(default = "default_tab_ratio")] + pub tab_ratio: u32, +} + +fn yes() -> bool { + true +} + +fn default_tab_ratio() -> u32 { + 600 +} + +impl Default for WorkbookView { + fn default() -> Self { + WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: true, show_vertical_scroll: true, tab_ratio: default_tab_ratio() } + } +} + +impl WorkbookView { + fn is_default(&self) -> bool { + *self == WorkbookView::default() + } +} + /// File › Options › Formulas › Calculation options, stored per workbook (``). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CalcSettings { @@ -150,6 +191,9 @@ pub struct Workbook { pub show_objects: ShowObjects, /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. pub active_sheet: ActiveSheet, + /// The workbook window's display settings (``), kept across an open + save. + #[serde(default, skip_serializing_if = "WorkbookView::is_default")] + pub view: WorkbookView, pub props: DocProps, /// Workbook structure protection. pub protected_structure: bool, @@ -284,6 +328,7 @@ impl Workbook { calc: CalcSettings::default(), show_objects: ShowObjects::All, active_sheet: ActiveSheet::default(), + view: WorkbookView::default(), props: DocProps::default(), protected_structure: false, structure_password: None, diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 8b118ecdb..1d5066ae9 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use gridcraft_core::{DateSystem, RangeRef}; -use gridcraft_model::{CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook}; +use gridcraft_model::{ + CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook, WorkbookView, +}; use crate::package::{Package, Rel}; use crate::styles::{StylesIn, read_styles}; @@ -268,7 +270,19 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } read_workbook_properties(&mut cx, &mut wb, &wb_xml); - let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; + let view_el = wb_xml.path(&["bookViews", "workbookView"]); + let active_tab = view_el.and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; + if let Some(v) = view_el { + // The window's display settings (Excel's "Display options for this workbook"). + // Absent attributes take the format's defaults, which is also what a workbook + // without a gets. + wb.view = WorkbookView { + show_sheet_tabs: v.flag("showSheetTabs", true), + show_horizontal_scroll: v.flag("showHorizontalScroll", true), + show_vertical_scroll: v.flag("showVerticalScroll", true), + tab_ratio: v.attr_u32("tabRatio").unwrap_or(600), + }; + } // Sheets. `file_to_model[i]` maps the i-th to a model sheet index. let mut file_to_model: Vec> = vec![]; diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2298e513f..0cfc17ad3 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1499,3 +1499,37 @@ fn a_partial_sheet_filter_is_not_folded_into_a_table() { assert!(t.filter.is_empty(), "the criteria landed on the wrong table column: {:?}", t.filter); assert!(b.autofilter.is_none(), "a sheet filter inside a table would be invalid"); } + +#[test] +fn workbook_view_settings_survive_a_round_trip() { + // #3057: a workbook Excel saved with the sheet tabs and both scroll bars hidden + // (and a non-default tab split) must open with those settings in the model and + // save with them still in the file. + let bytes = super::minimal( + "", + &[], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert_eq!( + wb.view, + WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: false, show_vertical_scroll: false, tab_ratio: 250 }, + "the reader takes the view settings from " + ); + + let out = write_xlsx(&wb).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(out)).unwrap(); + let mut xml = String::new(); + z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); + for attr in ["showSheetTabs=\"0\"", "showHorizontalScroll=\"0\"", "showVerticalScroll=\"0\"", "tabRatio=\"250\""] { + assert!(xml.contains(attr), "the writer keeps {attr} in {xml}"); + } + + // A workbook with the format's defaults writes the bare element it always has. + let plain = write_xlsx(&Workbook::new()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(plain)).unwrap(); + let mut xml = String::new(); + z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains(""), "defaults stay unwritten: {xml}"); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index f03dd9b04..511dd7093 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -208,6 +208,27 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { write_xlsx_named(wb, kind, None) } +/// The `` attributes beyond `activeTab`: the window's display settings +/// that differ from the format's defaults. An ordinary workbook gets none, so its file +/// is unchanged; a workbook Excel saved with the sheet tabs or scroll bars hidden +/// keeps them that way (#3057). +fn workbook_view_attrs(wb: &Workbook) -> String { + let mut attrs = String::new(); + if !wb.view.show_sheet_tabs { + attrs.push_str(" showSheetTabs=\"0\""); + } + if !wb.view.show_horizontal_scroll { + attrs.push_str(" showHorizontalScroll=\"0\""); + } + if !wb.view.show_vertical_scroll { + attrs.push_str(" showVerticalScroll=\"0\""); + } + if wb.view.tab_ratio != 600 { + let _ = write!(attrs, " tabRatio=\"{}\"", wb.view.tab_ratio); + } + attrs +} + /// Writes the workbook for the file `book_name` (a bare file name). A PivotChart is bound to its /// PivotTable in Excel only when its link starts with the workbook in brackets. pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) -> Result, IoError> { @@ -273,7 +294,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); let _ = write!(w, ""); } - let _ = write!(w, "{sheets_xml}", kept.file_index(active)); + let _ = write!(w, "{sheets_xml}"); w.push_str(&kept.elements); if !names.is_empty() { let _ = write!(w, "{names}"); From a85b1aea3a8a3369581a749d8da041e4db014504 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 18:53:12 -0400 Subject: [PATCH 39/51] packaging(flatpak): add OCI image build and GHCR publishing helper (#3063) --- packaging/linux/flatpak-bundle.sh | 8 ++++ packaging/linux/flatpak-oci.sh | 64 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100755 packaging/linux/flatpak-oci.sh diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 0f13a9107..5ee85b3c5 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.gridcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 000000000..fa811240e --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build gridcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/gridcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="gridcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/gridcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi From 2c0a00fbae58d90e194d9370b29f820ad3cb5c15 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 19:19:20 -0600 Subject: [PATCH 40/51] #2871: open Google Sheets' DUMMYFUNCTION export wrapper as the original formula Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks as IFERROR(__xludf.DUMMYFUNCTION(""), ). GridCraft showed the wrapper as the formula. fmla::from_file, which every cell formula read goes through (plain, shared master and t="array"), now recognises exactly that shape: IFERROR (with or without _xlfn.) of a single-string __xludf.DUMMYFUNCTION call, any case. If the string parses, the formula inside replaces the wrapper, as the whole formula or nested in a larger one (parenthesised unless it is a single operand). The cell keeps the file's cached value. Bounded on hostile input: inner text over MAX_FORMULA_LEN is not parsed, only one level is unwrapped, and a result that no longer parses (nesting past the parser's limit) keeps the file's formula. Text that doesn't parse keeps the wrapper. Saving writes the real formula with Excel's _xlfn. prefixes, as Excel does with the _xlfn functions it knows. Keeping cached results for functions GridCraft can't calculate is #2770 and not part of this change. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/fmla.rs | 134 ++++++++++++++++++- crates/xlsx/src/tests.rs | 1 + crates/xlsx/src/tests/google_sheets_dummy.rs | 92 +++++++++++++ docs/file-format-parity.md | 16 ++- 4 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 crates/xlsx/src/tests/google_sheets_dummy.rs diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 966088d5b..77c13511b 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -140,15 +140,86 @@ fn strip_param_prefix(n: String) -> String { pub fn from_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => gridcraft_formula::print(&e.map(&mut |x| match x { - Expr::Name(n) => Expr::Name(strip_param_prefix(n)), - Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), - other => other, - })), + Ok(e) => { + let e = unwrap_dummy_functions(e); + gridcraft_formula::print(&e.map(&mut |x| match x { + Expr::Name(n) => Expr::Name(strip_param_prefix(n)), + Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), + other => other, + })) + } Err(_) => body.to_string(), } } +/// The function Google Sheets' XLSX export wraps formulas in when it thinks Excel lacks one of +/// their functions. +const DUMMY_FUNCTION: &str = "__xludf.DUMMYFUNCTION"; + +/// The original formula inside Google Sheets' export wrapper +/// `IFERROR(__xludf.DUMMYFUNCTION(""),)`, if `e` is that +/// wrapper and the text parses. The parser has already undone the doubled quotes. The cached value +/// is also the cell's ``, which the reader keeps as the cell's value. +fn dummy_function_inner(e: &Expr) -> Option { + let Expr::Call(name, args) = e else { return None }; + // The parser has already dropped an `_xlfn.` prefix from IFERROR. + if !name.eq_ignore_ascii_case("IFERROR") { + return None; + } + let [Expr::Call(dummy, inner), _fallback] = args.as_slice() else { return None }; + if !dummy.eq_ignore_ascii_case(DUMMY_FUNCTION) { + return None; + } + let [Expr::Text(src)] = inner.as_slice() else { return None }; + // The text came out of a formula within the length limit, so it is within it too; check + // anyway rather than rely on that. + if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN { + return None; + } + gridcraft_formula::parse(src).ok() +} + +/// Replaces each Google Sheets export wrapper (see [`dummy_function_inner`]) with the formula it +/// holds, for the whole formula or anywhere inside it. One level only: wrappers in the unwrapped +/// text stay as they are, so the work is bounded by the formula's length. If the result no longer +/// parses (nesting beyond the parser's limit), the formula is kept as the file had it. +fn unwrap_dummy_functions(e: Expr) -> Expr { + let mut found = false; + e.walk(&mut |x| { + found |= matches!(x, Expr::Call(n, _) if n.eq_ignore_ascii_case(DUMMY_FUNCTION)); + }); + if !found { + return e; + } + // The whole formula: no parentheses needed. + if let Some(inner) = dummy_function_inner(&e) { + return inner; + } + let original = e.clone(); + let rewritten = e.map(&mut |x| match dummy_function_inner(&x) { + // The printer writes no parentheses of its own, so keep the inner formula's precedence + // with explicit ones unless it is a single operand. + Some( + inner @ (Expr::Call(..) + | Expr::Ref(_) + | Expr::Name(_) + | Expr::Number(_) + | Expr::Text(_) + | Expr::Bool(_) + | Expr::Error(_) + | Expr::Paren(_) + | Expr::Array(_) + | Expr::Struct(_)), + ) => inner, + Some(inner) => Expr::Paren(Box::new(inner)), + None => x, + }); + match gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)) { + Ok(_) => rewritten, + Err(_) => original, + } +} + #[cfg(test)] mod tests { use super::*; @@ -284,6 +355,59 @@ mod tests { assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } + /// #2871: Google Sheets' export wraps formulas as + /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`. + #[test] + fn google_sheets_dummy_function_wrapper() { + // The issue's formula, with its embedded newlines and doubled quotes. + let file = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)"; + let want = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; + assert_eq!(from_file(file), want); + assert_eq!(from_file(&format!("={file}")), want); + // Any case, and IFERROR with its `_xlfn.` prefix. + assert_eq!(from_file("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\""); + assert_eq!(from_file("iferror(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)"), "ARRAYFORMULA(A1:A3*2)"); + // Nested: an operator expression keeps its precedence; a call or operand needs no parentheses. + assert_eq!(from_file("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3"); + assert_eq!(from_file("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)"); + assert_eq!(from_file("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)"); + // One level only: a wrapper inside the unwrapped text stays. + assert_eq!( + from_file("IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)"), + "IFERROR(__XLUDF.DUMMYFUNCTION(\"A1\"),0)" + ); + } + + /// Text that doesn't parse, and shapes that aren't exactly the wrapper, are kept as they were. + #[test] + fn google_sheets_dummy_function_kept_when_not_the_wrapper() { + for file in [ + "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(\"),5)", + "IFERROR(__xludf.DUMMYFUNCTION(\"1+\"),5)+1", + "IFERROR(__xludf.DUMMYFUNCTION(A1),5)", + "IFERROR(__xludf.DUMMYFUNCTION(\"A1\",\"B1\"),5)", + "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"))", + "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),5,6)", + "IFNA(__xludf.DUMMYFUNCTION(\"A1\"),5)", + "IFERROR(_xludf.DUMMYFUNCTION(\"A1\"),5)", + "__xludf.DUMMYFUNCTION(\"A1\")", + ] { + let kept = gridcraft_formula::print(&gridcraft_formula::parse(file).unwrap()); + assert_eq!(from_file(file), kept, "{file}"); + } + // Inner text over the formula length limit. + let long = format!("IFERROR(__xludf.DUMMYFUNCTION(\"{}\"),5)", "1".repeat(gridcraft_formula::MAX_FORMULA_LEN + 1)); + assert!(from_file(&long).contains("DUMMYFUNCTION")); + // Nesting that would go past the parser's depth limit once unwrapped is kept as well. + let deep = |n: usize, mid: &str| format!("{}{mid}{}", "(".repeat(n), ")".repeat(n)); + let inner = deep(200, "1").replace('"', "\"\""); + let file = deep(200, &format!("1+IFERROR(__xludf.DUMMYFUNCTION(\"{inner}\"),5)")); + assert!(gridcraft_formula::parse(&file).is_ok()); + let read = from_file(&file); + assert!(read.contains("DUMMYFUNCTION")); + assert!(gridcraft_formula::parse(&read).is_ok()); + } + #[test] fn let_lambda_params() { assert_eq!(to_file(&Formula::new("LET(x,1,x+1)")), "_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index 73be1a064..cf138fcf8 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -9,6 +9,7 @@ mod chart_ref_offsets; mod csv_tests; mod encrypted_tests; mod fixtures; +mod google_sheets_dummy; mod ink_roundtrip; mod literal_category_roundtrip; mod locale_numfmt; diff --git a/crates/xlsx/src/tests/google_sheets_dummy.rs b/crates/xlsx/src/tests/google_sheets_dummy.rs new file mode 100644 index 000000000..37e27ecc4 --- /dev/null +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -0,0 +1,92 @@ +//! #2871: Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks +//! as `IFERROR(__xludf.DUMMYFUNCTION(""),)`. GridCraft +//! reads the formula inside, keeps the file's cached value, and saves the real formula. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Formula, Workbook}; + +use crate::package::Package; +use crate::{read_xlsx, write_xlsx}; + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +/// The issue's formula as Google writes it, with real newlines in the string literal. +const HEBREW: &str = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)"; +const HEBREW_MODEL: &str = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; + +/// A sheet as Google's export writes it: plain, `t="array"` and shared formulas, a wrapper inside +/// a larger formula, and one whose inner text doesn't parse. +fn google_book() -> Vec { + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); + let body = format!( + concat!( + "", + r#"{hebrew}401"#, + r#"_xlfn.IFERROR(__xludf.DUMMYFUNCTION("FILTER(B1:B3,B1:B3>1)"),2)2"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("B1*2"),2)2"#, + r#"1+IFERROR(__xludf.DUMMYFUNCTION("B1+B2"),5)6"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("SUM("),5)5"#, + r#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"438"#, + r#"36"#, + "" + ), + hebrew = esc(HEBREW), + ); + super::minimal(&body, &[], "", "") +} + +fn formula(wb: &Workbook, cell: &str) -> Formula { + let c = wb.sheets[0].cell(at(cell)).unwrap(); + Formula::clone(c.formula.as_ref().unwrap_or_else(|| panic!("{cell} has no formula"))) +} + +fn value(wb: &Workbook, cell: &str) -> Value { + wb.sheets[0].cell(at(cell)).unwrap().value.clone() +} + +fn assert_google_book(wb: &Workbook) { + assert_eq!(formula(wb, "A1").text, HEBREW_MODEL); + let e1 = formula(wb, "E1"); + assert_eq!(e1.text, "FILTER(B1:B3,B1:B3>1)"); + assert_eq!(e1.array, Some(gridcraft_core::RangeRef::parse("E1:E2").unwrap())); + assert_eq!(formula(wb, "F1").text, "B1*2"); + assert_eq!(formula(wb, "F2").text, "B2*2"); + assert_eq!(formula(wb, "F3").text, "B3*2"); + assert_eq!(formula(wb, "G1").text, "1+(B1+B2)"); + assert_eq!(formula(wb, "H1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"SUM(\"),5)"); + assert_eq!(formula(wb, "I1").text, "\"a\"&B1&CHAR(10)&B2"); + // The cached values are the file's (inside an array range the reader keeps only the anchor's). + for (cell, v) in [("A1", 40.0), ("E1", 2.0), ("F1", 2.0), ("F2", 8.0), ("F3", 6.0), ("G1", 6.0), ("H1", 5.0)] { + assert_eq!(value(wb, cell), Value::Number(v), "{cell}"); + } + assert_eq!(value(wb, "I1"), Value::text("a1")); + // Every unwrapped formula parses. + for cell in ["A1", "E1", "F1", "F2", "F3", "G1", "I1"] { + assert!(formula(wb, cell).expr().is_some(), "{cell}"); + } +} + +#[test] +fn google_sheets_wrapper_reads_as_the_original_formula() { + let (wb, _) = read_xlsx(&google_book()).unwrap(); + assert_google_book(&wb); +} + +/// Saving writes the real formula, with the file prefixes Excel expects, as for any formula +/// GridCraft reads; reopening gives the same workbook. +#[test] +fn google_sheets_wrapper_saves_the_real_formula() { + let (wb, _) = read_xlsx(&google_book()).unwrap(); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let xml = String::from_utf8(pkg.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + assert!(xml.contains("MAX(IFERROR(MAX(_xlfn._xlws.FILTER(ROW(C:C),C:C=\"שולם\")),0)"), "{xml}"); + assert!(xml.contains("_xlfn._xlws.FILTER(B1:B3,B1:B3>1)"), "{xml}"); + // Only the formula whose inner text doesn't parse keeps its wrapper. + assert_eq!(xml.matches("DUMMYFUNCTION").count(), 1, "{xml}"); + let (reopened, _) = read_xlsx(&saved).unwrap(); + assert_google_book(&reopened); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..9ad905a96 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-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)`, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **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** @@ -39,6 +39,19 @@ names keep the mark; retyping the formula drops it, and the writer then decides formula's shape. Rich-value metadata other than cell pictures (data types, `#SPILL!` error values) is still dropped. +Formulas from Google Sheets' XLSX export open as the formulas written in Google Sheets (#2871). +Google writes a formula that uses a function it thinks Excel lacks as +`IFERROR(__xludf.DUMMYFUNCTION(""),)`, which Excel shows +as the cached fallback. GridCraft recognises exactly that shape (any case, with or without +`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` formulas, as the whole formula or inside a +larger one (`=1+IFERROR(__xludf.DUMMYFUNCTION("A1+2"),5)` opens as `=1+(A1+2)`), and reads the +formula inside when it parses. The cell keeps the file's cached value. Inner text that doesn't +parse, is longer than 8,192 characters or would nest past the parser's limit keeps the wrapper, +and only one level is unwrapped. **Saving writes the real formula** (with `_xlfn.` prefixes, e.g. +`_xlfn._xlws.FILTER`), not the wrapper: this is what Excel does with the `_xlfn.` functions it +knows. A function GridCraft can't calculate then shows its error rather than the cached value; +keeping saved results for those is #2770 (`google_sheets_dummy.rs`, `fmla.rs`). + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -110,6 +123,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside (plain, shared and array formulas, whole or nested; unparseable, over-long or over-deep inner text keeps the wrapper) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From c55a7e35aadefeb482d759af7d3fec64c3d59578 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 19:48:37 -0600 Subject: [PATCH 41/51] #2964: Alt, H, B opens the Borders menu with its item letters At the "HB" level every Borders sequence (HBA..HBW) shares the prefix, so control_badge drew no badge and nothing opened: after B the screen showed no key tips at all, although the next letter still applied its preset. The Borders menu now opens at its group level, as Excel's does, with each preset's letter (told apart by its params) and More Borders' M. A letter or Esc changes the prefix, which closes the menu as before; clicking the arrow still toggles it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 29 ++++++++++++++++++++++ crates/ui-egui/src/ribbon/home.rs | 18 +++++++++++--- crates/ui-egui/tests/keytips.rs | 26 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index d14af141b..5303298bc 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -92,6 +92,25 @@ pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> { sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix)) } +/// True when `prefix` is the group level of a menu control: some of `target`'s sequences finish +/// one key below it (Borders' "HB" before HBA..HBW). The menu opens there, as Excel's does, so its +/// item letters show. +pub fn opens_menu(prefix: &str, target: &str) -> bool { + !prefix.is_empty() && is_group_level(prefix) && sequences_for(target).iter().any(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix)) +} + +/// The badge for a menu item that runs `target` with `params` (compact JSON, as `keytips.json` +/// writes it): its finish sequence one key below the prefix. Items sharing one command (the +/// Borders presets) are told apart by their params. +pub fn item_letter_with(prefix: &str, target: &str, params: &str) -> Option<&'static str> { + SEQUENCES.iter().find_map(|(seq, entry)| match entry { + Entry::Finish(Action::CommandWith(id, p)) if *id == target && *p == params && seq.len() == prefix.len() + 1 && seq.starts_with(prefix) => { + Some(*seq) + } + _ => None, + }) +} + /// Badge sequences for a command control: every sequence for `target` that continues from the /// current prefix (the painter strips the prefix, so the remaining letters show). pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> { @@ -197,6 +216,16 @@ mod tests { assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item"); } + #[test] + fn menu_levels_and_item_letters_with_params() { + assert!(opens_menu("HB", "home.borders")); + assert!(!opens_menu("H", "home.borders"), "the tab level shows the HB badge, not the menu"); + assert!(!opens_menu("", "home.borders")); + assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"all\"}"), Some("HBA")); + assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"bottom\"}"), Some("HBO")); + assert_eq!(item_letter_with("H", "home.borders", "{\"preset\":\"all\"}"), None); + } + #[test] fn item_letter_is_one_level_deep() { assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS")); diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index c061dfac3..34369be51 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -158,7 +158,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); } ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets")); - egui::Popup::menu(&ba).show(|ui| { + // Alt, H, B opens the menu with each item's letter, as Excel's does (#2964); a letter + // or Esc changes the prefix, which closes it again (`ribbon_keys`). + let prefix = app.keytips.prefix().unwrap_or_default().to_owned(); + let keyed = keytip_table::opens_menu(&prefix, "home.borders"); + egui::Popup::menu(&ba).open_memory(popup_command(keyed, ba.clicked())).show(|ui| { for (label, preset) in [ ("Bottom Border", "bottom"), ("Top Border", "top"), @@ -175,13 +179,21 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { ("Top and Double Bottom Border", "topDoubleBottom"), ("Inside Borders", "inside"), ] { - if crate::border_preview::preset_button(ui, label, preset).clicked() { + let item = crate::border_preview::preset_button(ui, label, preset); + if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset}).to_string()) { + keytip(app, ui, sequence, egui::pos2(item.rect.right() - 10.0, item.rect.center().y)); + } + if item.clicked() { app.grid.last_border = preset.to_string(); act(app, "home.borders", json!({"preset": preset})); } } ui.separator(); - if ui.button("More Borders…").clicked() { + let more = ui.button("More Borders…"); + if let Some(sequence) = keytip_table::item_letter(&prefix, "dialog:formatCells") { + keytip(app, ui, sequence, egui::pos2(more.rect.right() - 10.0, more.rect.center().y)); + } + if more.clicked() { app.open_dialog("formatCells", json!({"tab": "Border"})); } }); diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index b4418018a..68056f9ea 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -768,3 +768,29 @@ fn border_keytips_without_a_command_stay_silent() { assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border"); } } + +/// #2964: Alt, H, B opens the Borders menu (Excel shows it with each item's letter) instead of +/// leaving nothing on screen; a letter then picks its item and the menu closes, and Esc backs out. +#[test] +fn borders_keytip_opens_the_borders_menu() { + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B], false); + h.run_steps(2); + assert_eq!(h.state().keytips.prefix(), Some("HB")); + assert!(egui::Popup::is_any_open(&h.ctx), "Alt, H, B opens the Borders menu"); + assert!(h.query_by_label("All Borders").is_some(), "the menu's items show"); + letter(&mut h, Key::A, false); + h.run_steps(2); + assert!(!egui::Popup::is_any_open(&h.ctx), "picking an item closes the menu"); + assert_eq!(h.state().keytips.prefix(), None); + assert!(sides(&h, "B2").iter().all(|s| *s != gridcraft_engine::model::BorderStyle::None), "A applied All Borders"); + + let mut h = harness(blank()); + sequence(&mut h, &[Key::H, Key::B], false); + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx)); + key(&mut h, Key::Escape); + h.run_steps(2); + assert_eq!(h.state().keytips.prefix(), Some("H"), "Esc backs out to the Home letters"); + assert!(!egui::Popup::is_any_open(&h.ctx), "and closes the menu"); +} From 2b4a9fc0f3dd266158684c201b1a1b42a7817788 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 20:40:56 -0600 Subject: [PATCH 42/51] #2964: give Inside Borders a key tip (HBJ) Review follow-up. Inside Borders is GridCraft's own Borders menu item (Excel's menu has none), so it had no letter and was the one preset the keyboard couldn't reach from the open menu. It takes J, unused under HB. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 19 +++++++++++++++++++ .../src/keytips/keytip_table/generated.rs | 11 ++++++----- crates/ui-egui/src/keytips/keytips.json | 10 ++++++++++ crates/ui-egui/tests/it/keytips_generated.rs | 9 ++++++++- crates/ui-egui/tests/keytips.rs | 9 +++++++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 5303298bc..751abbca6 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -224,6 +224,25 @@ mod tests { assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"all\"}"), Some("HBA")); assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"bottom\"}"), Some("HBO")); assert_eq!(item_letter_with("H", "home.borders", "{\"preset\":\"all\"}"), None); + // Every preset in the Borders menu has its letter (Inside Borders is GridCraft's own, J). + for preset in [ + "bottom", + "top", + "left", + "right", + "none", + "all", + "outside", + "thickOutside", + "doubleBottom", + "thickBottom", + "topBottom", + "topThickBottom", + "topDoubleBottom", + "inside", + ] { + assert!(item_letter_with("HB", "home.borders", &format!("{{\"preset\":\"{preset}\"}}")).is_some(), "{preset} has no letter"); + } } #[test] diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 2a2509f08..7c014a1c6 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -46,12 +46,13 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border) ("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item ("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu - ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item - ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab - ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item + ("HBJ", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"inside\"}"))), // Excel: (none); Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB + ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item + ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab + ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item ("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item - ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item - ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item + ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item + ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item ("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item ("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item ("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5dfe7b2f5..d14f457d0 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -273,6 +273,16 @@ "excel": "Line Color", "note": "pending: Line Color submenu" }, + { + "seq": "HBJ", + "kind": "command", + "target": "home.borders", + "params": { + "preset": "inside" + }, + "excel": "(none)", + "note": "Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB" + }, { "seq": "HBL", "kind": "command", diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 810338a48..02f4e99fb 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -338,6 +338,13 @@ fn keytip_hbi_() { assert!(h.state().session.journal.is_empty(), "HBI must not journal"); } +#[test] +fn keytip_hbj_() { + let mut h = harness(blank()); + sequence(&mut h, "HBJ", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBJ must journal home.borders"); +} + #[test] fn keytip_hbl_() { let mut h = harness(blank()); @@ -2754,7 +2761,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 365; + const EXPECTED: usize = 366; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 68056f9ea..e3fc7a9dc 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -785,6 +785,15 @@ fn borders_keytip_opens_the_borders_menu() { assert_eq!(h.state().keytips.prefix(), None); assert!(sides(&h, "B2").iter().all(|s| *s != gridcraft_engine::model::BorderStyle::None), "A applied All Borders"); + // Inside Borders, GridCraft's own item, has a letter too (J): only the inner edges. + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::J], false); + h.run_steps(2); + let (a1, b2) = (sides(&h, "A1"), sides(&h, "B2")); + use gridcraft_engine::model::BorderStyle::{None as N, Thin}; + assert_eq!(a1, [N, Thin, N, Thin], "A1: right and bottom only"); + assert_eq!(b2, [Thin, N, Thin, N], "B2: left and top only"); + let mut h = harness(blank()); sequence(&mut h, &[Key::H, Key::B], false); h.run_steps(2); From 15c9d8c10b84e2a8e06cccfa012ab97906809869 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 23:43:09 -0400 Subject: [PATCH 43/51] ci: grant packages write permission to release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 168408d87..e0c09905d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} From 1c7e9a2f90e583e03c11614b8d9d81697db8f52c Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 21:45:37 -0600 Subject: [PATCH 44/51] #2964: compare menu item params as JSON values; test non-menu prefixes Review follow-up. item_letter_with compared the compact params string, so a key order or spacing difference between keytips.json and the caller would miss the item. It now takes a serde_json::Value and compares parsed values. Tests add prefixes that must not open the Borders menu (a finished sequence, another group's level, another control) and an unknown preset. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 38 +++++++++++++++++----- crates/ui-egui/src/ribbon/home.rs | 2 +- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 751abbca6..87c85befd 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -99,12 +99,17 @@ pub fn opens_menu(prefix: &str, target: &str) -> bool { !prefix.is_empty() && is_group_level(prefix) && sequences_for(target).iter().any(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix)) } -/// The badge for a menu item that runs `target` with `params` (compact JSON, as `keytips.json` -/// writes it): its finish sequence one key below the prefix. Items sharing one command (the -/// Borders presets) are told apart by their params. -pub fn item_letter_with(prefix: &str, target: &str, params: &str) -> Option<&'static str> { +/// The badge for a menu item that runs `target` with `params`: its finish sequence one key below +/// the prefix. Items sharing one command (the Borders presets) are told apart by their params, +/// compared as JSON values, so key order and spacing don't matter. +pub fn item_letter_with(prefix: &str, target: &str, params: &serde_json::Value) -> Option<&'static str> { SEQUENCES.iter().find_map(|(seq, entry)| match entry { - Entry::Finish(Action::CommandWith(id, p)) if *id == target && *p == params && seq.len() == prefix.len() + 1 && seq.starts_with(prefix) => { + Entry::Finish(Action::CommandWith(id, p)) + if *id == target + && seq.len() == prefix.len() + 1 + && seq.starts_with(prefix) + && serde_json::from_str::(p).is_ok_and(|p| p == *params) => + { Some(*seq) } _ => None, @@ -218,12 +223,27 @@ mod tests { #[test] fn menu_levels_and_item_letters_with_params() { + use serde_json::json; assert!(opens_menu("HB", "home.borders")); + // Prefixes that are not the Borders menu level don't open it. assert!(!opens_menu("H", "home.borders"), "the tab level shows the HB badge, not the menu"); assert!(!opens_menu("", "home.borders")); - assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"all\"}"), Some("HBA")); - assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"bottom\"}"), Some("HBO")); - assert_eq!(item_letter_with("H", "home.borders", "{\"preset\":\"all\"}"), None); + assert!(!opens_menu("HBA", "home.borders"), "a finished sequence is not a menu level"); + assert!(!opens_menu("HI", "home.borders"), "another group's level"); + assert!(!opens_menu("HB", "home.fillColor"), "the Borders level is not another control's menu"); + // Presets sharing home.borders are told apart by their params. + assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "all"})), Some("HBA")); + assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "bottom"})), Some("HBO")); + assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "nope"})), None); + assert_eq!(item_letter_with("H", "home.borders", &json!({"preset": "all"})), None); + // Params compare as JSON values: key order and spacing don't matter. + let parsed: serde_json::Value = serde_json::from_str("{ \"preset\" : \"all\" }").unwrap(); + assert_eq!(item_letter_with("HB", "home.borders", &parsed), Some("HBA")); + assert_eq!( + json!({"b": 1, "a": 2}), + serde_json::from_str::("{\"a\":2,\"b\":1}").unwrap(), + "maps compare regardless of order" + ); // Every preset in the Borders menu has its letter (Inside Borders is GridCraft's own, J). for preset in [ "bottom", @@ -241,7 +261,7 @@ mod tests { "topDoubleBottom", "inside", ] { - assert!(item_letter_with("HB", "home.borders", &format!("{{\"preset\":\"{preset}\"}}")).is_some(), "{preset} has no letter"); + assert!(item_letter_with("HB", "home.borders", &json!({"preset": preset})).is_some(), "{preset} has no letter"); } } diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 34369be51..d9096beaa 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -180,7 +180,7 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { ("Inside Borders", "inside"), ] { let item = crate::border_preview::preset_button(ui, label, preset); - if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset}).to_string()) { + if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset})) { keytip(app, ui, sequence, egui::pos2(item.rect.right() - 10.0, item.rect.center().y)); } if item.clicked() { From a30644c872d1f27a1ad670f79da25c22330851a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 1 Nov 2026 04:26:47 +0000 Subject: [PATCH 45/51] fix(quit): bring the asking window to the front on a Dock quit Quit from the Dock reaches GridCraft while it is behind other apps, hidden or minimized. The Save Changes question opened in the workbook's window, but nothing brought that window forward when it was already the active one, so the app looked stuck. The window is restored, shown and focused in that order, since macOS ignores Focus for a minimized window or a hidden app. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/quit.rs | 1 + crates/ui-egui/src/doc_windows.rs | 9 +++++++++ crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/doc_windows.rs | 17 +++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6fa5def4f..068be59a4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -75,6 +75,7 @@ impl SheetApp { macros: macro_ui::MacroUi::default(), started: now_ms(), allow_close: false, + bring_forward: false, open_switch_windows: false, ui_window: None, parked: HashMap::new(), diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index d4479d191..ffa230c34 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -42,6 +42,7 @@ impl SheetApp { self.enter_window(window); let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); self.open_dialog("saveChanges", json!({"title": title, "quit": true})); + self.bring_forward = true; } None => { self.allow_close = true; diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 7ee1efaa1..7bf3a799d 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -160,6 +160,15 @@ impl DocWindows { self.list.focus(id); self.viewports.focus(ctx, id); } + // macOS ignores Focus for a minimized window or a hidden app, so restore and show first. + if std::mem::take(&mut app.bring_forward) + && let Some(id) = self.list.active() + { + let vp = self.viewports.viewport_id(id); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Focus); + } } /// Draws every window. Call from the root viewport's UI each frame, in place of diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db9ab316f..c5a9c0030 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -284,6 +284,9 @@ pub struct SheetApp { /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. pub allow_close: bool, + /// A quit question is waiting in the active window: [`doc_windows`] brings its native window + /// to the front, also when the app is hidden or the window minimized (a Dock quit). + pub bring_forward: bool, /// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a /// key tip, the command search or a menu, possibly while another tab was showing). pub(crate) open_switch_windows: bool, diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index 46427f3bf..1d72739e3 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -232,6 +232,23 @@ fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() { assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}"); } +#[test] +fn a_quit_question_brings_its_window_to_the_front_from_the_background() { + // A Dock quit reaches the app while it is behind other apps, hidden or minimized. + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + desk.frames(3); + let w = desk.window_of(0); + + let sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + for cmd in [ViewportCommand::Visible(true), ViewportCommand::Minimized(false), ViewportCommand::Focus] { + assert!(sent_to(&sent, desk.viewport(w), &cmd), "{cmd:?} missing: {sent:?}"); + } + let sent = desk.frames(2); + assert!(!sent_to(&sent, desk.viewport(w), &ViewportCommand::Focus), "only once: {sent:?}"); +} + #[test] fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() { let mut desk = Desk::new(true); From 7ed8a9f3128626b152c290ba24353d5e04d770ab Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 1 Nov 2026 04:30:17 +0000 Subject: [PATCH 46/51] fix(quit): bring GridCraft forward through AppKit for a Dock quit egui's Visible/Minimized/Focus go through winit, whose macOS focus_window skips a minimized window or a hidden app, and macOS can refuse the activation. When a Dock quit opens a question, unhide the app, deminiaturize its windows and activate it through AppKit, and bounce the Dock icon if it still isn't active. --- apps/gridcraft/Cargo.toml | 4 ++++ apps/gridcraft/src/apple_events.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index e5ad73da4..3082080e7 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -52,6 +52,10 @@ muda = { version = "0.21", default-features = false } # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda, +# winit); pinned exactly to the same versions. +objc2 = "=0.6.5" +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] } # tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit); # pinned exactly to the same versions. diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs index 4d596f957..b0898eced 100644 --- a/apps/gridcraft/src/apple_events.rs +++ b/apps/gridcraft/src/apple_events.rs @@ -11,6 +11,8 @@ use fmv_macos_events::{Event, Inbox, Registration}; use gridcraft_ui_egui::SheetApp; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSRequestUserAttentionType}; /// Keeps the Apple-event handlers registered; hold it until the event loop returns. pub struct AppleEvents { @@ -44,7 +46,32 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) { } } // Quit from the Dock or the system: each unsaved workbook is asked about in its window. - Event::Quit => app.request_quit(ctx), + Event::Quit => { + app.request_quit(ctx); + if app.dialog.is_some() { + bring_app_forward(); + } + } } } } + +/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to +/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and +/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock +/// icon until the user switches over. +fn bring_app_forward() { + let Some(mtm) = MainThreadMarker::new() else { return }; + let ns_app = NSApplication::sharedApplication(mtm); + ns_app.unhide(None); + for window in ns_app.windows().to_vec() { + if window.isMiniaturized() { + window.deminiaturize(None); + } + } + #[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version. + ns_app.activateIgnoringOtherApps(true); + if !ns_app.isActive() { + ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest); + } +} From a1e0383056741404d49d253e407b6d444499253f Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sun, 1 Nov 2026 00:08:40 -0600 Subject: [PATCH 47/51] #2871: unwrap Google Sheets' wrapper only when GridCraft calculates the formula A formula calling a Google-only function (GOOGLETRANSLATE, QUERY, ARRAYFORMULA...) opened as #NAME? where the wrapper had shown the cached value, as Excel does. The XLSX reader now unwraps a cell formula only when every function inside is known: read_xlsx_with takes the predicate (the xlsx crate can't depend on the function library) and the engine passes gridcraft_calc::is_known_function. Unwrapping stays in the reader so shared formulas are shifted from the unwrapped master. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 3 +- crates/engine/src/io/tests.rs | 29 ++++ crates/xlsx/src/fmla.rs | 146 +++++++++++++------ crates/xlsx/src/lib.rs | 17 ++- crates/xlsx/src/read.rs | 6 +- crates/xlsx/src/sheet_read.rs | 9 +- crates/xlsx/src/tests/google_sheets_dummy.rs | 31 ++-- docs/file-format-parity.md | 21 +-- 8 files changed, 189 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 71060a4c9..1c4c30095 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -153,7 +153,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } // Text named `.xlsx` is read as text. if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) { - let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| { + // Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871). + let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| { EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) { (gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size), _ => format!("We can't open '{name}': {e}"), diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 130b1a6d9..77d61a9fe 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -561,3 +561,32 @@ fn content_wins_over_a_mismatched_extension() { assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); } } + +/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` +/// wrapper as the formula inside when GridCraft calculates every function it calls (including +/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a +/// Google-only function. +#[test] +fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { + use gridcraft_core::{CellRef, Value}; + let mut wb = Workbook::new(); + let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]); + let wrapped = [ + ("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)), + ("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")), + ]; + for (at, text, value) in &wrapped { + let cell = gridcraft_model::Cell { + value: value.clone(), + formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))), + ..Default::default() + }; + sh.cells.set(CellRef::parse(at).unwrap(), cell); + } + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap(); + let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()); + assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))")); + assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); + assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); +} diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 77c13511b..29dd20870 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -140,27 +140,45 @@ fn strip_param_prefix(n: String) -> String { pub fn from_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => { - let e = unwrap_dummy_functions(e); - gridcraft_formula::print(&e.map(&mut |x| match x { - Expr::Name(n) => Expr::Name(strip_param_prefix(n)), - Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), - other => other, - })) - } + Ok(e) => gridcraft_formula::print(&strip_param_prefixes(e)), Err(_) => body.to_string(), } } +fn strip_param_prefixes(e: Expr) -> Expr { + e.map(&mut |x| match x { + Expr::Name(n) => Expr::Name(strip_param_prefix(n)), + Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), + other => other, + }) +} + /// The function Google Sheets' XLSX export wraps formulas in when it thinks Excel lacks one of /// their functions. const DUMMY_FUNCTION: &str = "__xludf.DUMMYFUNCTION"; +/// Whether `text` may hold Google Sheets' export wrapper: a cheap test before parsing. +fn mentions_dummy_function(text: &str) -> bool { + const NEEDLE: &[u8] = b"DUMMYFUNCTION"; + text.as_bytes().windows(NEEDLE.len()).any(|w| w.eq_ignore_ascii_case(NEEDLE)) +} + +/// The model text of `text` (a formula as read, see [`from_file`]) with its Google Sheets export +/// wrappers replaced (see [`crate::read_xlsx_with`]), or `None` when nothing changes. +pub(crate) fn unwrap_google_sheets(text: &str, known: &dyn Fn(&str) -> bool) -> Option { + if !mentions_dummy_function(text) { + return None; + } + let e = gridcraft_formula::parse(text.strip_prefix('=').unwrap_or(text)).ok()?; + let unwrapped = unwrap_dummy_functions(e, known)?; + Some(gridcraft_formula::print(&strip_param_prefixes(unwrapped))) +} + /// The original formula inside Google Sheets' export wrapper /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`, if `e` is that -/// wrapper and the text parses. The parser has already undone the doubled quotes. The cached value -/// is also the cell's ``, which the reader keeps as the cell's value. -fn dummy_function_inner(e: &Expr) -> Option { +/// wrapper, the text parses and every function it calls is `known`. The parser has already undone +/// the doubled quotes. The cached value is also the cell's ``, which the reader keeps. +fn dummy_function_inner(e: &Expr, known: &dyn Fn(&str) -> bool) -> Option { let Expr::Call(name, args) = e else { return None }; // The parser has already dropped an `_xlfn.` prefix from IFERROR. if !name.eq_ignore_ascii_case("IFERROR") { @@ -176,27 +194,28 @@ fn dummy_function_inner(e: &Expr) -> Option { if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN { return None; } - gridcraft_formula::parse(src).ok() + let parsed = gridcraft_formula::parse(src).ok()?; + // A function GridCraft can't calculate would show `#NAME?` where Excel shows the cached + // value; a wrapper inside the text is one too, so only one level is ever unwrapped. + let mut all_known = true; + parsed.walk(&mut |x| { + if let Expr::Call(n, _) = x { + all_known &= known(n); + } + }); + all_known.then_some(parsed) } -/// Replaces each Google Sheets export wrapper (see [`dummy_function_inner`]) with the formula it -/// holds, for the whole formula or anywhere inside it. One level only: wrappers in the unwrapped -/// text stay as they are, so the work is bounded by the formula's length. If the result no longer -/// parses (nesting beyond the parser's limit), the formula is kept as the file had it. -fn unwrap_dummy_functions(e: Expr) -> Expr { - let mut found = false; - e.walk(&mut |x| { - found |= matches!(x, Expr::Call(n, _) if n.eq_ignore_ascii_case(DUMMY_FUNCTION)); - }); - if !found { - return e; - } +/// `e` with each Google Sheets export wrapper (see [`dummy_function_inner`]) replaced by the +/// formula it holds, for the whole formula or anywhere inside it, or `None` when none is. If the +/// result no longer parses (nesting beyond the parser's limit), nothing is replaced. +fn unwrap_dummy_functions(e: Expr, known: &dyn Fn(&str) -> bool) -> Option { // The whole formula: no parentheses needed. - if let Some(inner) = dummy_function_inner(&e) { - return inner; + if let Some(inner) = dummy_function_inner(&e, known) { + return Some(inner); } - let original = e.clone(); - let rewritten = e.map(&mut |x| match dummy_function_inner(&x) { + let mut found = false; + let rewritten = e.map(&mut |x| match dummy_function_inner(&x, known) { // The printer writes no parentheses of its own, so keep the inner formula's precedence // with explicit ones unless it is a single operand. Some( @@ -210,14 +229,28 @@ fn unwrap_dummy_functions(e: Expr) -> Expr { | Expr::Paren(_) | Expr::Array(_) | Expr::Struct(_)), - ) => inner, - Some(inner) => Expr::Paren(Box::new(inner)), + ) => { + found = true; + inner + } + Some(inner) => { + found = true; + Expr::Paren(Box::new(inner)) + } None => x, }); - match gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)) { - Ok(_) => rewritten, - Err(_) => original, + if !found { + return None; } + gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)).ok()?; + Some(rewritten) +} + +/// What the engine passes as `known` (`gridcraft_calc::is_known_function`), for tests here: the +/// function library plus the evaluator's own functions the tests use. +#[cfg(test)] +pub(crate) fn test_known_function(n: &str) -> bool { + gridcraft_functions::lookup(n).is_some() || ["IF", "IFERROR", "ROW", "INDEX", "LET", "LAMBDA"].iter().any(|s| s.eq_ignore_ascii_case(n)) } #[cfg(test)] @@ -355,6 +388,13 @@ mod tests { assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } + /// A formula as the reader leaves it, then with Google Sheets' wrappers unwrapped against + /// the real function library. + fn read(file: &str) -> String { + let text = from_file(file); + unwrap_google_sheets(&text, &test_known_function).unwrap_or(text) + } + /// #2871: Google Sheets' export wraps formulas as /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`. #[test] @@ -362,28 +402,37 @@ mod tests { // The issue's formula, with its embedded newlines and doubled quotes. let file = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)"; let want = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; - assert_eq!(from_file(file), want); - assert_eq!(from_file(&format!("={file}")), want); - // Any case, and IFERROR with its `_xlfn.` prefix. - assert_eq!(from_file("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\""); - assert_eq!(from_file("iferror(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)"), "ARRAYFORMULA(A1:A3*2)"); + assert_eq!(read(file), want); + assert_eq!(read(&format!("={file}")), want); + // The reader itself leaves the wrapper: unwrapping is a separate pass. + assert!(from_file(file).contains("DUMMYFUNCTION")); + // Any case, and IFERROR with its `_xlfn.` prefix; doubled quotes inside. + assert_eq!(read("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\""); + assert_eq!(read("iferror(__xludf.DUMMYFUNCTION(\"SORT(A1:A3*2)\"),2)"), "SORT(A1:A3*2)"); // Nested: an operator expression keeps its precedence; a call or operand needs no parentheses. - assert_eq!(from_file("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3"); - assert_eq!(from_file("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)"); - assert_eq!(from_file("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)"); - // One level only: a wrapper inside the unwrapped text stays. + assert_eq!(read("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3"); + assert_eq!(read("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)"); + assert_eq!(read("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)"); + // Each wrapper on its own: a Google-only one stays next to one that is unwrapped. assert_eq!( - from_file("IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)"), - "IFERROR(__XLUDF.DUMMYFUNCTION(\"A1\"),0)" + read("IFERROR(__xludf.DUMMYFUNCTION(\"A1*2\"),2)+IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)"), + "(A1*2)+IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)" ); } - /// Text that doesn't parse, and shapes that aren't exactly the wrapper, are kept as they were. + /// Text that doesn't parse, calls a function GridCraft lacks, or shapes that aren't exactly + /// the wrapper, are kept as they were, so the cell keeps the cached value as in Excel. #[test] fn google_sheets_dummy_function_kept_when_not_the_wrapper() { for file in [ "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(\"),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"1+\"),5)+1", + // Google-only functions: GridCraft would show #NAME? instead of the cached value. + "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1,\"\"en\"\",\"\"fr\"\")\"),\"x\")", + "IFERROR(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)", + "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(QUERY(A1:B3,\"\"select B\"\"))\"),2)", + // A wrapper inside the text: only one level is ever unwrapped. + "IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)", "IFERROR(__xludf.DUMMYFUNCTION(A1),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"A1\",\"B1\"),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"))", @@ -393,17 +442,18 @@ mod tests { "__xludf.DUMMYFUNCTION(\"A1\")", ] { let kept = gridcraft_formula::print(&gridcraft_formula::parse(file).unwrap()); - assert_eq!(from_file(file), kept, "{file}"); + assert_eq!(read(file), kept, "{file}"); + assert_eq!(unwrap_google_sheets(&from_file(file), &test_known_function), None, "{file}"); } // Inner text over the formula length limit. let long = format!("IFERROR(__xludf.DUMMYFUNCTION(\"{}\"),5)", "1".repeat(gridcraft_formula::MAX_FORMULA_LEN + 1)); - assert!(from_file(&long).contains("DUMMYFUNCTION")); + assert!(read(&long).contains("DUMMYFUNCTION")); // Nesting that would go past the parser's depth limit once unwrapped is kept as well. let deep = |n: usize, mid: &str| format!("{}{mid}{}", "(".repeat(n), ")".repeat(n)); let inner = deep(200, "1").replace('"', "\"\""); let file = deep(200, &format!("1+IFERROR(__xludf.DUMMYFUNCTION(\"{inner}\"),5)")); assert!(gridcraft_formula::parse(&file).is_ok()); - let read = from_file(&file); + let read = read(&file); assert!(read.contains("DUMMYFUNCTION")); assert!(gridcraft_formula::parse(&read).is_ok()); } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b22820..19ad5074e 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -83,7 +83,22 @@ impl ReadReport { /// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the /// package relationships, so every main-part content type is accepted). pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { - read::read_xlsx(bytes) + read::read_xlsx(bytes, None) +} + +/// [`read_xlsx`], and opens formulas from Google Sheets' XLSX export as the formulas written in +/// Google Sheets (#2871). +/// +/// Google writes a formula that uses a function it thinks Excel lacks as +/// `IFERROR(__xludf.DUMMYFUNCTION(""),)`; Excel shows the +/// cached value. A cell formula holding that wrapper, whole or inside a larger formula, is read +/// as the formula inside when it parses and `known` says GridCraft calculates every function it +/// calls. Otherwise (a Google-only function such as `GOOGLETRANSLATE`, inner text that doesn't +/// parse, or nesting past the parser's limit) the wrapper stays, so the cell keeps showing the +/// cached value as in Excel. `known` is passed in because this crate doesn't depend on the +/// function library. +pub fn read_xlsx_with(bytes: &[u8], known: &dyn Fn(&str) -> bool) -> Result<(Workbook, ReadReport), IoError> { + read::read_xlsx(bytes, Some(known)) } /// The part of a zip package that declares more than [`MAX_PART`] unpacked bytes, with that size: diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 8b118ecdb..2b563e26a 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -32,6 +32,9 @@ pub struct Ctx<'a> { pub cell_pictures: Vec>>, /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. pub cf_ext_ids: Vec<(String, usize)>, + /// Whether GridCraft calculates a function, when the caller says: cell formulas wrapped by + /// Google Sheets' export are then read as the formula inside (see [`crate::read_xlsx_with`]). + pub known_function: Option<&'a dyn Fn(&str) -> bool>, } impl Ctx<'_> { @@ -221,7 +224,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { } } -pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { +pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> bool>) -> Result<(Workbook, ReadReport), IoError> { let pkg = Package::open(bytes)?; let mut cx = Ctx { pkg, @@ -238,6 +241,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { num_fmts: Default::default(), cell_pictures: vec![], cf_ext_ids: vec![], + known_function, }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 7a37e41d8..8dcb99d28 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -13,7 +13,7 @@ use gridcraft_model::{ }; use crate::IoError; -use crate::fmla::from_file; +use crate::fmla::{from_file, unwrap_google_sheets}; use crate::package::Rel; use crate::read::Ctx; use crate::styles::read_color; @@ -534,7 +534,12 @@ fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynam } fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { - let model = from_file(f.text.trim()); + let mut model = from_file(f.text.trim()); + if let Some(known) = cx.known_function + && let Some(unwrapped) = unwrap_google_sheets(&model, known) + { + model = unwrapped; + } let text = model.as_str(); match f.attr("t").unwrap_or("normal") { "shared" => { diff --git a/crates/xlsx/src/tests/google_sheets_dummy.rs b/crates/xlsx/src/tests/google_sheets_dummy.rs index 37e27ecc4..921f46242 100644 --- a/crates/xlsx/src/tests/google_sheets_dummy.rs +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -1,12 +1,13 @@ //! #2871: Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks //! as `IFERROR(__xludf.DUMMYFUNCTION(""),)`. GridCraft -//! reads the formula inside, keeps the file's cached value, and saves the real formula. +//! reads the formula inside when GridCraft calculates every function it calls, keeps the file's +//! cached value, and saves the real formula. use gridcraft_core::{CellRef, Value}; use gridcraft_model::{Formula, Workbook}; use crate::package::Package; -use crate::{read_xlsx, write_xlsx}; +use crate::{read_xlsx, read_xlsx_with, write_xlsx}; fn at(s: &str) -> CellRef { CellRef::parse(s).unwrap() @@ -17,7 +18,7 @@ const HEBREW: &str = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER( const HEBREW_MODEL: &str = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; /// A sheet as Google's export writes it: plain, `t="array"` and shared formulas, a wrapper inside -/// a larger formula, and one whose inner text doesn't parse. +/// a larger formula, one whose inner text doesn't parse and one with a Google-only function. fn google_book() -> Vec { let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); let body = format!( @@ -28,7 +29,8 @@ fn google_book() -> Vec { r#"IFERROR(__xludf.DUMMYFUNCTION("B1*2"),2)2"#, r#"1+IFERROR(__xludf.DUMMYFUNCTION("B1+B2"),5)6"#, r#"IFERROR(__xludf.DUMMYFUNCTION("SUM("),5)5"#, - r#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("GOOGLETRANSLATE(""hello"",""en"",""fr"")"),"bonjour")bonjour"#, r#"438"#, r#"36"#, "" @@ -58,35 +60,44 @@ fn assert_google_book(wb: &Workbook) { assert_eq!(formula(wb, "G1").text, "1+(B1+B2)"); assert_eq!(formula(wb, "H1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"SUM(\"),5)"); assert_eq!(formula(wb, "I1").text, "\"a\"&B1&CHAR(10)&B2"); + // GridCraft has no GOOGLETRANSLATE: the wrapper stays, so the cell shows the cached text. + assert_eq!(formula(wb, "J1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hello\"\",\"\"en\"\",\"\"fr\"\")\"),\"bonjour\")"); // The cached values are the file's (inside an array range the reader keeps only the anchor's). for (cell, v) in [("A1", 40.0), ("E1", 2.0), ("F1", 2.0), ("F2", 8.0), ("F3", 6.0), ("G1", 6.0), ("H1", 5.0)] { assert_eq!(value(wb, cell), Value::Number(v), "{cell}"); } assert_eq!(value(wb, "I1"), Value::text("a1")); + assert_eq!(value(wb, "J1"), Value::text("bonjour")); // Every unwrapped formula parses. for cell in ["A1", "E1", "F1", "F2", "F3", "G1", "I1"] { assert!(formula(wb, cell).expr().is_some(), "{cell}"); } } +/// Reads a file as the engine opens one. +fn open(bytes: &[u8]) -> Workbook { + read_xlsx_with(bytes, &crate::fmla::test_known_function).unwrap().0 +} + #[test] fn google_sheets_wrapper_reads_as_the_original_formula() { + assert_google_book(&open(&google_book())); + // Without a function list the reader keeps the file's formula. let (wb, _) = read_xlsx(&google_book()).unwrap(); - assert_google_book(&wb); + assert!(formula(&wb, "A1").text.contains("DUMMYFUNCTION")); } /// Saving writes the real formula, with the file prefixes Excel expects, as for any formula /// GridCraft reads; reopening gives the same workbook. #[test] fn google_sheets_wrapper_saves_the_real_formula() { - let (wb, _) = read_xlsx(&google_book()).unwrap(); + let wb = open(&google_book()); let saved = write_xlsx(&wb).unwrap(); let mut pkg = Package::open(&saved).unwrap(); let xml = String::from_utf8(pkg.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); assert!(xml.contains("MAX(IFERROR(MAX(_xlfn._xlws.FILTER(ROW(C:C),C:C=\"שולם\")),0)"), "{xml}"); assert!(xml.contains("_xlfn._xlws.FILTER(B1:B3,B1:B3>1)"), "{xml}"); - // Only the formula whose inner text doesn't parse keeps its wrapper. - assert_eq!(xml.matches("DUMMYFUNCTION").count(), 1, "{xml}"); - let (reopened, _) = read_xlsx(&saved).unwrap(); - assert_google_book(&reopened); + // Only the formula whose inner text doesn't parse and the Google-only one keep their wrapper. + assert_eq!(xml.matches("DUMMYFUNCTION").count(), 2, "{xml}"); + assert_google_book(&open(&saved)); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9ad905a96..806d12405 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-31 · **Change:** minor (formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)`, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **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** @@ -43,14 +43,15 @@ Formulas from Google Sheets' XLSX export open as the formulas written in Google Google writes a formula that uses a function it thinks Excel lacks as `IFERROR(__xludf.DUMMYFUNCTION(""),)`, which Excel shows as the cached fallback. GridCraft recognises exactly that shape (any case, with or without -`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` formulas, as the whole formula or inside a -larger one (`=1+IFERROR(__xludf.DUMMYFUNCTION("A1+2"),5)` opens as `=1+(A1+2)`), and reads the -formula inside when it parses. The cell keeps the file's cached value. Inner text that doesn't -parse, is longer than 8,192 characters or would nest past the parser's limit keeps the wrapper, -and only one level is unwrapped. **Saving writes the real formula** (with `_xlfn.` prefixes, e.g. -`_xlfn._xlws.FILTER`), not the wrapper: this is what Excel does with the `_xlfn.` functions it -knows. A function GridCraft can't calculate then shows its error rather than the cached value; -keeping saved results for those is #2770 (`google_sheets_dummy.rs`, `fmla.rs`). +`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` cell formulas, as the whole formula or +inside a larger one (`=1+IFERROR(__xludf.DUMMYFUNCTION("A1+2"),5)` opens as `=1+(A1+2)`), and +reads the formula inside when it parses and GridCraft calculates every function it calls. The +cell keeps the file's cached value. A Google-only function (`GOOGLETRANSLATE`, `QUERY`, +`ARRAYFORMULA`…) keeps the wrapper, so the cell shows the cached value as in Excel rather than +`#NAME?`; so does inner text that doesn't parse, is longer than 8,192 characters or would nest +past the parser's limit, and a wrapper inside the text. **Saving writes the real formula** (with +`_xlfn.` prefixes, e.g. `_xlfn._xlws.FILTER`), not the wrapper: this is what Excel does with the +`_xlfn.` functions it knows (`google_sheets_dummy.rs`, `fmla.rs`, engine `io/tests.rs`). | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| @@ -123,7 +124,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside (plain, shared and array formulas, whole or nested; unparseable, over-long or over-deep inner text keeps the wrapper) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | +| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From 5e415cdd34d287b90a4a9df67eb4f532f6f72d96 Mon Sep 17 00:00:00 2001 From: vioreltus <142961713+vioreltus@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:54:28 +0200 Subject: [PATCH 48/51] macOS: Intel Macs skip wgpu's indirect-call validation shaders (fixes #2812) (#3039) * Fix #2812: Intel Macs skip wgpu's indirect-call validation shaders Metal's Intel compiler aborts on wgpu's indirect-validation compute shaders ("Compiler encountered an internal error"; MTLCompilerService crashes in reportUnsupportedMaxWorkGroupSizeMetadataError), the device is lost inside request_device and GridCraft exits before its window opens. GridCraft issues no indirect draws or dispatches, so on Intel Macs the instance leaves VALIDATION_INDIRECT_CALL out; WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. Every other platform keeps wgpu's default. Same as WordCraft (#2652 there). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk * Review: test that WGPU_VALIDATION_INDIRECT_CALL=1 still turns it on; qualify the doc line The override is checked in a child test process with the variable set, since tests can't set environment variables without unsafe code. The revision line no longer says Intel Macs start: OpenCore Legacy Patcher Macs on macOS 15 can still fail on the MSL version (#1116). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk --------- Co-authored-by: Claude --- apps/gridcraft/src/graphics.rs | 80 +++++++++++++++++++++++++++++++++- apps/gridcraft/src/main.rs | 7 ++- docs/hardware-parity.md | 5 ++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/apps/gridcraft/src/graphics.rs b/apps/gridcraft/src/graphics.rs index 83478edde..93ec2e4c9 100644 --- a/apps/gridcraft/src/graphics.rs +++ b/apps/gridcraft/src/graphics.rs @@ -1,4 +1,5 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Graphics instance configuration, applied before wgpu can initialize any driver: Windows +//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip. //! //! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters //! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access @@ -8,12 +9,38 @@ //! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see //! [`dx12_compiler`]. +// On macOS only the indirect-validation items are used outside tests; on Windows the other way +// round, so those two carry their own `allow`. +#![cfg_attr(target_os = "macos", allow(dead_code))] + use eframe::{ NativeOptions, egui_wgpu::WgpuSetup, - wgpu::{Backends, DeviceType, Dx12Compiler}, + wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags}, }; +/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On Intel +/// Macs (Iris 5100 in a Mac mini 2014) Metal's compiler aborts on them ("Compiler encountered an +/// internal error"), the device is lost and eframe exits before the window opens. GridCraft issues +/// no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's default. +/// Same as WordCraft (storytold/wordcraft#2652) and PhotoCraft (storytold/PhotoCraft#3171). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool { + macos && intel +} + +/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`. +/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) { + if !skip { + return; + } + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); + } +} + /// What the start-up probe learned about one DirectX 12 adapter. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Dx12Adapter { @@ -141,6 +168,55 @@ fn probe_dx12(options: &NativeOptions) -> Vec { mod tests { use super::*; + fn instance_flags(options: &NativeOptions) -> InstanceFlags { + let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + create.instance_descriptor.flags + } + + #[test] + fn intel_macs_skip_the_indirect_validation_shaders() { + // Metal's Intel compiler aborted on them, losing the device before the window opened. + assert!(skips_indirect_validation(true, true)); + // Apple silicon, Windows and Linux keep wgpu's validation. + for (macos, intel) in [(true, false), (false, true), (false, false)] { + assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}"); + } + let mut options = NativeOptions::default(); + let before = instance_flags(&options); + configure_indirect_validation(&mut options, false); + assert_eq!(instance_flags(&options), before, "flags untouched when not skipping"); + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + configure_indirect_validation(&mut options, true); + assert!(!instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + } + + /// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the validation back on even when it is skipped. + /// Tests can't set environment variables (`set_var` is unsafe, and unsafe code is forbidden), + /// so this runs itself again as a child process with the variable set. + #[test] + fn the_environment_turns_indirect_validation_back_on() { + const NAME: &str = "graphics::tests::the_environment_turns_indirect_validation_back_on"; + if std::env::var_os("GRIDCRAFT_INDIRECT_VALIDATION_CHILD").is_some() { + let mut options = NativeOptions::default(); + configure_indirect_validation(&mut options, true); + assert!(instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL)); + return; + } + let exe = std::env::current_exe().unwrap(); + let out = std::process::Command::new(exe) + .args(["--exact", NAME, "--test-threads=1"]) + .env("GRIDCRAFT_INDIRECT_VALIDATION_CHILD", "1") + .env("WGPU_VALIDATION_INDIRECT_CALL", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + // `--exact` matching nothing would pass too, so the child must report this one test. + assert!(out.status.success() && stdout.contains("1 passed"), "child test failed:\n{stdout}{}", String::from_utf8_lossy(&out.stderr)); + } + fn configured_backends(backends: Backends) -> Backends { let mut options = NativeOptions::default(); configure_backends(&mut options, backends); diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 9357d0796..3aab1ccfc 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -12,7 +12,7 @@ #[cfg(target_os = "macos")] mod apple_events; mod control_server; -#[cfg(any(target_os = "windows", test))] +#[cfg(any(target_os = "windows", target_os = "macos", test))] mod graphics; mod logging; #[cfg(target_os = "macos")] @@ -341,8 +341,11 @@ fn main() -> eframe::Result<()> { } let window_builder = viewport.clone(); let options = eframe::NativeOptions { viewport, ..Default::default() }; - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(unix)] let mut options = options; + // Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (see graphics.rs). + #[cfg(target_os = "macos")] + graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64"))); // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file // manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1 // keeps the native Wayland backend. diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 71cf001d5..65ae312cd 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Arrange All) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (Intel Mac start-up) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -8,7 +8,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Hardware feature | Excel | GridCraft (macOS · Windows · Linux · BSD · web) | Gap and estimate | |---|---|---|---| | Multi-core recalculation | Multi-threaded recalculation on all cores (Windows and Mac), configurable thread count | Single-threaded everywhere (no threading in `crates/calc`) | Parallel recalc of independent dependency chains on native builds; WASM stays single-threaded or uses workers later: 12–18 h | -| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | +| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal (Intel Macs skip wgpu's indirect-call validation shaders, which Metal's Intel compiler aborts on, #2812) · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor; View › Arrange All tiles, stacks or cascades them over the main window's monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | @@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | macOS: Intel Macs skip wgpu's indirect-call validation shaders, so they no longer exit on Metal's compiler error there (#2812). Macs run with OpenCore Legacy Patcher on macOS 15 can still fail earlier, on the MSL version (#1116). | | 2026-10-22 | minor | View › Arrange All over the main window's monitor. | | 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | From 60a267b4ef9448290bf3662001aba0e23b6e2357 Mon Sep 17 00:00:00 2001 From: scks1 <126534658+scks1@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:06:44 -0500 Subject: [PATCH 49/51] #3057: select visible workbook views and normalize tab ratios --- ROADMAP.md | 4 +- crates/engine/src/tests.rs | 30 +++++++ crates/model/src/workbook.rs | 6 +- crates/xlsx/src/read.rs | 11 ++- crates/xlsx/src/tests/roundtrip.rs | 140 +++++++++++++++++++++++++++++ crates/xlsx/src/write.rs | 7 +- docs/file-format-parity.md | 24 ++++- 7 files changed, 214 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40652c341..9a1145f78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged) · **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-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f99f246df..50316ee0e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2403,6 +2403,36 @@ fn deleting_a_3d_span_end_sheet_keeps_the_rest() { } } +#[test] +fn workbook_view_roundtrips_after_sheet_deletion_and_reordering() { + let view = gridcraft_model::WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: true, show_vertical_scroll: false, tab_ratio: 350 }; + for (initial_active, command, params, expected_order, expected_active) in [ + ("Third", "home.deleteSheet", json!({"sheet": "First"}), vec!["Second", "Third"], 1), + ("Third", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 1), + ("First", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 0), + ("Third", "sheet.move", json!({"sheet": "Third", "to": 0}), vec!["Third", "First", "Second"], 0), + ("Third", "sheet.move", json!({"sheet": "First", "to": 2}), vec!["Second", "Third", "First"], 2), + ] { + let mut session = s(); + session.execute("sheet.rename", json!({"name": "First"})).unwrap(); + for name in ["Second", "Third"] { + session.execute("home.insertSheet", json!({"name": name})).unwrap(); + } + std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).view = view; + session.execute("sheet.activate", json!({"sheet": initial_active})).unwrap(); + session.execute(command, params).unwrap(); + + let wb = &session.doc().unwrap().wb; + assert_eq!(wb.active_sheet(), expected_active, "{command}"); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), expected_order, "{command}"); + let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap(); + assert_eq!(back.sheets.iter().map(|s| s.name.as_str()).collect::>(), expected_order, "{command} after reopening"); + assert_eq!(back.active_sheet(), expected_active, "activeTab follows {command}"); + assert_eq!(back.active().unwrap().name, expected_order[expected_active], "the active sheet is not a stale index"); + assert_eq!(back.view, view, "workbook display settings survive {command}"); + } +} + #[test] fn xlsx_roundtrip_through_engine() { let mut s = s(); diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 0942f6773..586ed36fa 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -33,7 +33,8 @@ pub enum ShowObjects { /// The workbook window's display settings from `` (Excel: File › Options › /// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs /// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`, -/// thousandths of the bar; the format's default is 600). +/// thousandths of the bar; the format's default is 600). XLSX I/O normalizes this +/// share to 0–1000, including values supplied directly through the model. /// /// The values are modelled so an open + save keeps them — a workbook Excel saved with the /// tabs hidden must not come back with them shown and the settings gone. The app does not @@ -66,6 +67,9 @@ impl Default for WorkbookView { } impl WorkbookView { + /// The entire bar, in thousandths: the upper bound used by XLSX I/O. + pub const MAX_TAB_RATIO: u32 = 1000; + fn is_default(&self) -> bool { *self == WorkbookView::default() } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 1d5066ae9..c3d16a19e 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -223,6 +223,13 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { } } +/// The single window modelled by GridCraft: prefer the first visible window, +/// including the format's default visibility, and otherwise retain the first one. +fn selected_workbook_view(workbook: &El) -> Option<&El> { + let views = workbook.child("bookViews")?; + views.kids("workbookView").find(|view| view.attr("visibility").unwrap_or("visible") == "visible").or_else(|| views.child("workbookView")) +} + pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { let pkg = Package::open(bytes)?; let mut cx = Ctx { @@ -270,7 +277,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } read_workbook_properties(&mut cx, &mut wb, &wb_xml); - let view_el = wb_xml.path(&["bookViews", "workbookView"]); + let view_el = selected_workbook_view(&wb_xml); let active_tab = view_el.and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; if let Some(v) = view_el { // The window's display settings (Excel's "Display options for this workbook"). @@ -280,7 +287,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { show_sheet_tabs: v.flag("showSheetTabs", true), show_horizontal_scroll: v.flag("showHorizontalScroll", true), show_vertical_scroll: v.flag("showVerticalScroll", true), - tab_ratio: v.attr_u32("tabRatio").unwrap_or(600), + tab_ratio: v.attr_u32("tabRatio").unwrap_or(600).min(WorkbookView::MAX_TAB_RATIO), }; } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 0cfc17ad3..132fa4d75 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1533,3 +1533,143 @@ fn workbook_view_settings_survive_a_round_trip() { z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); assert!(xml.contains(""), "defaults stay unwritten: {xml}"); } + +/// Three original empty worksheets let view selection be checked through `activeTab`, +/// independently of the flags and ratio carried by the same window. +fn workbook_with_window_views(views: &str) -> Vec { + let workbook = format!( + r#"{views}"# + ); + let rels = r#""#; + let sheet = br#""#; + super::make_zip(&[ + ("[Content_Types].xml", super::CT.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet), + ("xl/worksheets/sheet2.xml", sheet), + ("xl/worksheets/sheet3.xml", sheet), + ]) +} + +fn saved_workbook_view_xml(wb: &Workbook) -> String { + let bytes = write_xlsx(wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + zip.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +#[test] +fn workbook_view_attributes_round_trip_independently() { + for (attribute, value, expected) in [ + ("showSheetTabs", "0", WorkbookView { show_sheet_tabs: false, ..Default::default() }), + ("showSheetTabs", "1", WorkbookView::default()), + ("showHorizontalScroll", "0", WorkbookView { show_horizontal_scroll: false, ..Default::default() }), + ("showHorizontalScroll", "1", WorkbookView::default()), + ("showVerticalScroll", "0", WorkbookView { show_vertical_scroll: false, ..Default::default() }), + ("showVerticalScroll", "1", WorkbookView::default()), + ("tabRatio", "250", WorkbookView { tab_ratio: 250, ..Default::default() }), + ("tabRatio", "600", WorkbookView::default()), + ] { + let view = format!(""); + let (wb, _) = read_xlsx(&workbook_with_window_views(&view)).unwrap(); + assert_eq!(wb.view, expected, "only {attribute} is supplied"); + let xml = saved_workbook_view_xml(&wb); + assert_eq!(xml.contains(&format!("{attribute}=\"{value}\"")), expected != WorkbookView::default(), "{xml}"); + for other in ["showSheetTabs", "showHorizontalScroll", "showVerticalScroll", "tabRatio"] { + if other != attribute { + assert!(!xml.contains(&format!("{other}=")), "unrelated defaults stay omitted: {xml}"); + } + } + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.view, expected, "{attribute}={value} survives a round trip"); + } +} + +#[test] +fn workbook_view_selects_first_visible_window_consistently() { + let cases = [ + ( + r#""#, + 2, + WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: false, show_vertical_scroll: true, tab_ratio: 750 }, + ), + // Visibility defaults to visible, so this view takes precedence over a later explicit one. + ( + r#""#, + 1, + WorkbookView { show_vertical_scroll: false, tab_ratio: 350, ..Default::default() }, + ), + // No visible window: retain the first window's settings and active sheet as one unit. + ( + r#""#, + 1, + WorkbookView { show_sheet_tabs: false, tab_ratio: 100, ..Default::default() }, + ), + ]; + for (views, active, view) in cases { + let (wb, _) = read_xlsx(&workbook_with_window_views(views)).unwrap(); + assert_eq!(wb.active_sheet(), active, "activeTab must come from the selected view: {views}"); + assert_eq!(wb.view, view, "all settings must come from the selected view: {views}"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.active_sheet(), active); + assert_eq!(back.view, view); + } +} + +#[test] +fn workbook_view_without_a_window_uses_defaults() { + for bytes in [super::minimal("", &[], "", ""), workbook_with_window_views("")] { + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(wb.active_sheet(), 0); + assert_eq!(wb.view, WorkbookView::default()); + let xml = saved_workbook_view_xml(&wb); + assert!(xml.contains(""), "missing views keep defaults omitted: {xml}"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.active_sheet(), 0); + assert_eq!(back.view, WorkbookView::default()); + } +} + +#[test] +fn workbook_view_tab_ratio_normalizes_imported_values() { + for (raw, expected) in [ + (None, 600), + (Some("0"), 0), + (Some("600"), 600), + (Some("1000"), 1000), + (Some("1001"), 1000), + (Some("4294967295"), 1000), + (Some("4294967296"), 600), + (Some("-1"), 600), + (Some("invalid"), 600), + (Some(""), 600), + ] { + let attr = raw.map(|value| format!(" tabRatio=\"{value}\"")).unwrap_or_default(); + let views = format!(""); + let (wb, _) = read_xlsx(&workbook_with_window_views(&views)).unwrap(); + assert_eq!(wb.view.tab_ratio, expected, "tabRatio={raw:?}"); + let xml = saved_workbook_view_xml(&wb); + if expected == 600 { + assert!(!xml.contains("tabRatio="), "the default is omitted: {xml}"); + } else { + assert!(xml.contains(&format!("tabRatio=\"{expected}\"")), "{xml}"); + } + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.view.tab_ratio, expected); + } +} + +#[test] +fn workbook_view_writer_clamps_out_of_range_model_values() { + for raw in [1001, u32::MAX] { + let mut wb = Workbook::new(); + wb.view.tab_ratio = raw; + let xml = saved_workbook_view_xml(&wb); + assert!(xml.contains("tabRatio=\"1000\""), "an out-of-range model value is bounded on write: {xml}"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.view.tab_ratio, 1000); + } +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 511dd7093..eb755c002 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use gridcraft_core::RangeRef; use gridcraft_formula::quote_sheet; -use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook, WorkbookView}; use crate::package::ZipOut; use crate::xml::{esc, esc_attr}; @@ -223,8 +223,9 @@ fn workbook_view_attrs(wb: &Workbook) -> String { if !wb.view.show_vertical_scroll { attrs.push_str(" showVerticalScroll=\"0\""); } - if wb.view.tab_ratio != 600 { - let _ = write!(attrs, " tabRatio=\"{}\"", wb.view.tab_ratio); + let tab_ratio = wb.view.tab_ratio.min(WorkbookView::MAX_TAB_RATIO); + if tab_ratio != 600 { + let _ = write!(attrs, " tabRatio=\"{tab_ratio}\""); } attrs } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..8557f19f3 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-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged) · **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** @@ -14,6 +14,27 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when shared formulas are expanded during import (`array_export.rs`, #1505). +## Workbook window settings + +GridCraft keeps one modelled workbook window. On XLSX import it selects the first visible +`workbookView` (omitted `visibility` means visible), or the first view when none is visible. +The active tab and display options come from that same view and survive the next save. +GridCraft normalizes `tabRatio` to 0–1000 on import and export; malformed or overflowing +unsigned-integer text uses the default 600. The valid endpoints 0 and 1000 survive, and +600 plus the default shown flags stay omitted from output. Synthetic regressions in +`crates/xlsx/src/tests/roundtrip.rs` cover each attribute independently, selection, fallback +and round trips (#3057). Engine regressions exercise real sheet deletion and reordering +commands and check that the saved active tab identifies the current sheet after reopening, +with the workbook display options preserved. +The format's [workbook-view defaults](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.workbookview?view=openxml-3.0.1) +include visible windows and a tab ratio of 600. UI controls and rendering of these settings +remain separate #3057 work. + +`firstSheet` records the leftmost visible tab, independently of `activeTab`. It is not modelled +by this branch. Supporting it needs its own view state and sheet-edit handling, tracked in +#2264; these regressions cover the active tab and existing display fields without introducing +that separate feature. + ## Workbook formats XLSX column declarations are limited to 1,000,000 expanded column assignments per worksheet, @@ -110,6 +131,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From ea1d52fe8f6adc60ace125e3e86498db414ff611 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 12:20:54 -0500 Subject: [PATCH 50/51] Review fixes for #3042: "" with parse: false keeps the cell's formatting; correct the write_range parse description Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/cell.rs | 4 ++-- crates/engine/src/cmd/edit/tests.rs | 4 ++++ crates/mcp/src/tools.rs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index 65d5b73ce..9cd1dd80f 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -245,8 +245,8 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { if text.is_empty() { // An empty string clears the cell, as `null` and the default path do: // an empty text cell is not blank, and a script clearing cells with - // `""` would leave cells `COUNTA` counts. - None + // `""` would leave cells `COUNTA` counts. The cell keeps its formatting. + input_to_cell("", sheet, at, &mut cx.wb)? } else { let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); // Line breaks still turn on Wrap Text, as typed input does. diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3701dfd19..3f3a2fb6d 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -225,10 +225,14 @@ fn set_values_parse_false_clears_an_empty_string() { let mut s = Session::new(); s.new_workbook(); s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap(); + s.execute("home.bold", json!({"range": "A1"})).unwrap(); s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap(); let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); assert_eq!(get(&mut s, "A1")["type"], "empty"); assert_eq!(get(&mut s, "B1")["type"], "empty"); + let d = s.doc().unwrap(); + let sh = d.wb.sheet(0).unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting"); assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0)); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f1a36b004..87f7fea5d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -196,7 +196,7 @@ fn base_tool_definitions() -> Vec { json!({ "range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""), "values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}}, - "parse": boolean("Store every string not starting with = as the text it is, instead of reading it like typed input (default true)"), + "parse": boolean("Read strings not starting with = like typed input, so \"05.04.2026\" becomes a date (default true); false stores them as the text they are, and \"\" clears the cell"), }), &["range", "values"], ), From 69177881248addc4e6b2131e519eded6b1f2dc8c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 12:30:04 -0500 Subject: [PATCH 51/51] docs: sync Craft App Suite list with ArtCraft Launcher --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d9dc5eb36..f2d9a930e 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,15 @@ stand on its own. | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | -| | **GridCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.