Browse / github.com/storytold/gridcraft / patch
Ignore Error: read and write ignoredErrors, ignore and reset commands, euro sign in numbers (#1994)
Ignore Error: read and write ignoredErrors, ignore and reset commands, euro sign in numbers (#1994) Follow-up to #1994 (Number Stored as Text). The warning button had no Ignore Error, and the file's `ignoredErrors` were dropped on open. This adds both, plus the one conversion case that still differed. ## What Excel does (observed) Observed in Excel 16 (Windows, US settings) through COM on new temporary workbooks: `Range.Errors(n).Ignore`, `Application.ErrorCheckingOptions`, and the saved sheet XML. - Ignore is per cell and per rule. `Errors(3)` is Number Stored as Text, and the other eight rules behave the same way (evalError, twoDigitTextYear, formula, formulaRange, unlockedFormula, empt
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #2705, open)
- Base
89ac453efa7ea03a3ab0b0d24fd85ca51172f5cc- Size
- +575 −11 in 18 files
- Review
- pending
- Shared
- 2026-10-28
- Id
5449b1b743a544e3af1662aa
How it went
No other operator has reported using it yet.
Diff
raw · crates/core/src/parse.rs crates/engine/src/cmd.rs crates/engine/src/cmd/edit/paste_special.rs crates/engine/src/cmd/formulas.rs crates/engine/src/cmd/sheet.rs crates/engine/src/tests.rs crates/model/src/features.rs crates/model/src/sheet.rs crates/ui-egui/src/grid.rs crates/ui-egui/src/grid/menus.rs crates/ui-egui/src/grid/quadrant.rs crates/ui-egui/src/i18n/de.rs crates/ui-egui/src/i18n/pt.rs crates/xlsx/src/sheet_read.rs crates/xlsx/src/sheet_write.rs crates/xlsx/src/tests.rs crates/xlsx/src/tests/ignored_errors.rs docs/ui-parity.md
@@ -56,7 +56,7 @@ pub fn number_stored_as_text(s: &str) -> Option<f64> { let t = s.trim(); // The same parentheses typed entry reads, accounting forms included: `$(5)`, `(5)%`. match parenthesized(t) {- Some(inner) if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) => None,+ Some(inner) if inner.strip_prefix(['$', '€']).map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) => None, Some(inner) => parse_plain(&inner).map(|(n, _)| -n), None => parse_plain(t).map(|(n, _)| n), }@@ -66,7 +66,7 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>) // Parenthesised negatives: (100) = -100, and the accounting forms `$(100)` and `(5)%`. if let Some(inner) = parenthesized(t) { // Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`.- if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) {+ if inner.strip_prefix(['$', '€']).map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) { return None; } let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?"))));@@ -112,6 +112,7 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { let mut s = t; let mut neg = false; let mut currency = false;+ let mut euro = false; let mut percent = false; if let Some(r) = s.strip_prefix('-') { neg = true;@@ -127,12 +128,30 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { s = r2; } }+ // The euro sign stands before or after the digits, with an optional space, and leaves the+ // cell in General (observed in Excel with US settings: `65.00€` and `€ 65` are 65; `£5` is+ // text).+ if !currency && let Some(r) = s.strip_prefix('€') {+ euro = true;+ s = r.trim_start();+ if !neg && let Some(r2) = s.strip_prefix('-') {+ neg = true;+ s = r2;+ }+ } if let Some(r) = s.strip_suffix('%') { percent = true; s = r.trim_end(); }- // Excel reads `$5` and `5%` but not `$5%`.- if s.is_empty() || currency && percent {+ if !currency+ && !euro+ && let Some(r) = s.strip_suffix('€')+ {+ euro = true;+ s = r.trim_end();+ }+ // Excel reads `$5` and `5%` but not `$5%`, and no euro sign with a percent sign.+ if s.is_empty() || (currency || euro) && percent { return None; } let has_sep = s.contains(',');@@ -177,6 +196,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { Some(if digits.contains('.') { "0.00%" } else { "0%" }) } else if sci { Some("0.00E+00")+ } else if euro {+ None } else if has_sep { Some(if digits.contains('.') { "#,##0.00" } else { "#,##0" }) } else {@@ -613,6 +634,24 @@ mod tests { assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0)); } + /// Typed into a General cell, Excel (US settings) reads a euro sign before or after the digits+ /// and keeps the cell General; other currency signs and a decimal comma make text.+ #[test]+ fn euro_sign_is_read_as_a_number() {+ for (text, n) in [("65.00€", 65.0), ("€65", 65.0), ("65 €", 65.0), ("€ 65", 65.0), ("-€5", -5.0), ("(€5)", -5.0), ("1,000€", 1000.0)]+ {+ let r = p(text);+ assert_eq!(r.value, Value::Number(n), "{text}");+ assert_eq!(r.format, None, "{text}");+ }+ // Excel keeps the scientific format a typed exponent gets, euro sign or not.+ assert_eq!(p("1e3€").value, Value::Number(1000.0));+ assert_eq!(p("1e3€").format, Some("0.00E+00"));+ for text in ["65,00€", "£5", "¥5", "65.00€%", "5 $", "12€3"] {+ assert_eq!(p(text).value, Value::text(text), "{text}");+ }+ }+ #[test] fn numbers_stored_as_text() { for (text, n) in [@@ -625,11 +664,46 @@ mod tests { ("(100)", -100.0), ("$(5)", -5.0), ("(5)%", -0.05),+ ("65.00€", 65.0),+ ("€65", 65.0),+ ("65 €", 65.0),+ ("€ 65", 65.0),+ ("-€5", -5.0),+ ("€-5", -5.0),+ ("(€5)", -5.0),+ ("1,000€", 1000.0),+ ("1e3€", 1000.0), ] { assert_eq!(number_stored_as_text(text), Some(n), "{text}"); } // Dates, times, fractions, words and blanks are not flagged.- for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))", "(-5)", "$(5)%", "(1/2)"] {+ for text in [+ "3/4/2026",+ "10:30",+ "1 1/2",+ "abc",+ "12abc",+ "",+ " ",+ "TRUE",+ "#N/A",+ "((1))",+ "(-5)",+ "$(5)%",+ "(1/2)",+ "65,00€",+ "£5",+ "5£",+ "¥5",+ "65.00€%",+ "€5%",+ "5 $",+ "12€3",+ "$5€",+ "€$5",+ "(€-5)",+ "€",+ ] { assert_eq!(number_stored_as_text(text), None, "{text}"); } }@@ -363,6 +363,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) } if !structural { check_arrays(&d.wb, &wb)?;+ forget_ignored_errors(&d.wb, &mut wb, &changed); } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));@@ -391,6 +392,22 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) Ok(r) } +/// Editing a cell drops the error checks it had ignored (Excel does the same): a cell whose+/// content differs from before stops ignoring them.+fn forget_ignored_errors(old: &Workbook, new: &mut Workbook, changed: &[Key]) {+ if new.sheets.iter().all(|s| s.ignored_errors.is_empty()) {+ return;+ }+ for (si, c) in changed {+ let before = old.sheet(*si).and_then(|s| s.cell(*c));+ let after = new.sheet(*si).and_then(|s| s.cell(*c));+ let same = same_content(before, after);+ if !same && let Some(sh) = new.sheet_mut(*si) {+ gridcraft_model::unignore_errors(&mut sh.ignored_errors, RangeRef::cell(*c), gridcraft_model::error_rule::ALL);+ }+ }+}+ /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; @@ -69,6 +69,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> { source.comments.retain(|c, _| !src.contains(*c)); source.hyperlinks.retain(|c, _| !src.contains(*c)); source.merges.retain(|m| !src.contains_range(m));+ gridcraft_model::unignore_errors(&mut source.ignored_errors, src, gridcraft_model::error_rule::ALL); for (c, _) in &taken { cx.touch(clip.sheet, *c); }@@ -259,6 +260,14 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> { cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); } cx.touch(dest_sheet, dest);+ if cut_move {+ // A structural commit skips the ignored-error cleanup, so each overwritten cell drops its rules here.+ gridcraft_model::unignore_errors(+ &mut cx.sheet_mut(dest_sheet)?.ignored_errors,+ RangeRef::cell(dest),+ gridcraft_model::error_rule::ALL,+ );+ } } } }@@ -79,6 +79,25 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, convert_to_number ),+ cmd!(+ "formulas.ignoreError",+ "Ignore Error",+ [],+ None,+ "{range?, kinds?: [\"numberStoredAsText\"] (default; or evalError, twoDigitTextYear, formula, formulaRange, unlockedFormula, emptyCellReference, listDataValidation, calculatedColumn, all)} → {ranges}: the cells stop being flagged for those error-checking rules (saved as the sheet's ignoredErrors). Editing a cell's content forgets what it ignored.",+ has_doc,+ ignore_error+ ),+ cmd!(+ "formulas.clearIgnoredErrors",+ "Reset Ignored Errors",+ [],+ None,+ "{range?, kinds? (default: all rules), all?: true = every sheet (File ▸ Options ▸ Formulas ▸ Reset Ignored Errors)} → {ranges}: the cells are checked again",+ has_doc,+ clear_ignored_errors+ ),+ cmd!(query "formulas.ignoredErrors", "Ignored Errors", [], None, "{} → [{sheet, ranges: [\"A1:B2\"], kinds: [\"numberStoredAsText\"]}]", has_doc, ignored_errors), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula), cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate),@@ -440,6 +459,8 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc if let Some(cell) = sh.cells.get_mut(c) { cell.formula = Some(std::sync::Arc::new(nf)); }+ // A structural commit skips the ignored-error cleanup, so the rewritten cell drops its rules here.+ gridcraft_model::unignore_errors(&mut sh.ignored_errors, RangeRef::cell(c), gridcraft_model::error_rule::ALL); } } for n in wb.names.iter_mut() {@@ -770,6 +791,78 @@ fn convert_to_number(s: &mut Session, p: &Json) -> Result<Json> { }) } +/// `kinds`: one rule name, or a list of them (`all` stands for every rule).+fn kinds_param(p: &Json, default: u16) -> Result<u16> {+ let names: Vec<&str> = match p.get("kinds") {+ None | Some(Json::Null) => return Ok(default),+ Some(Json::String(n)) => vec![n.as_str()],+ Some(Json::Array(a)) => a.iter().map(|n| n.as_str().ok_or_else(|| bad("kinds", "expected rule names (text)"))).collect::<Result<_>>()?,+ Some(_) => return Err(bad("kinds", "expected a rule name or a list of rule names")),+ };+ let mut mask = 0;+ for n in names {+ mask |= if n.eq_ignore_ascii_case("all") {+ gridcraft_model::error_rule::ALL+ } else {+ gridcraft_model::error_rule::parse(n).ok_or_else(|| bad("kinds", format!("unknown error-checking rule `{n}`")))?+ };+ }+ Ok(mask)+}++/// Ignore Error: the range's cells stop being flagged for the chosen rules.+fn ignore_error(s: &mut Session, p: &Json) -> Result<Json> {+ let sheet = target_sheet(s, p)?;+ let ranges = target_ranges(s, p)?;+ let kinds = kinds_param(p, gridcraft_model::error_rule::NUMBER_STORED_AS_TEXT)?;+ edit(s, |cx| {+ let sh = cx.sheet_mut(sheet)?;+ for r in &ranges {+ gridcraft_model::ignore_errors(&mut sh.ignored_errors, *r, kinds);+ }+ Ok(json!({"ranges": ranges.len()}))+ })+}++/// Reset Ignored Errors: the range's cells (or every sheet's, with `all`) are checked again.+fn clear_ignored_errors(s: &mut Session, p: &Json) -> Result<Json> {+ let all = bool_param(p, "all").unwrap_or(false);+ let kinds = kinds_param(p, gridcraft_model::error_rule::ALL)?;+ let (sheet, ranges) = if all {+ (0, vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(u32::MAX, u32::MAX))])+ } else {+ (target_sheet(s, p)?, target_ranges(s, p)?)+ };+ edit(s, |cx| {+ if all {+ for sh in cx.wb.sheets.iter_mut() {+ let sh = std::sync::Arc::make_mut(sh);+ for r in &ranges {+ gridcraft_model::unignore_errors(&mut sh.ignored_errors, *r, kinds);+ }+ }+ } else {+ let sh = cx.sheet_mut(sheet)?;+ for r in &ranges {+ gridcraft_model::unignore_errors(&mut sh.ignored_errors, *r, kinds);+ }+ }+ Ok(json!({"ranges": ranges.len()}))+ })+}++fn ignored_errors(s: &mut Session, _: &Json) -> Result<Json> {+ let d = s.doc()?;+ let mut out = Vec::new();+ for sh in &d.wb.sheets {+ for ie in &sh.ignored_errors {+ let kinds: Vec<&str> = gridcraft_model::error_rule::NAMES.iter().filter(|(_, bit)| ie.kinds & bit != 0).map(|(n, _)| *n).collect();+ out.push(json!({"sheet": sh.name, "ranges": ie.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), "kinds": kinds}));+ }+ }+ Ok(Json::Array(out))+}+ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> { let d = s.doc()?; let mut out = Vec::new();@@ -171,6 +171,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: for dv in sh.validations.iter_mut() { dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r)).collect(); }+ for ie in sh.ignored_errors.iter_mut() {+ ie.ranges = ie.ranges.iter().filter_map(|r| map_range(*r)).collect();+ }+ sh.ignored_errors.retain(|ie| !ie.ranges.is_empty()); sh.validations.retain(|dv| !dv.ranges.is_empty()); for t in sh.tables.iter_mut() { if let Some(r) = map_range(t.range) {@@ -762,6 +766,10 @@ pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) { for dv in sh.validations.iter_mut() { dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); }+ for ie in sh.ignored_errors.iter_mut() {+ ie.ranges = ie.ranges.iter().filter_map(|r| map_range(*r, e)).collect();+ }+ sh.ignored_errors.retain(|ie| !ie.ranges.is_empty()); sh.validations.retain(|dv| !dv.ranges.is_empty()); for t in sh.tables.iter_mut() { // Cells inserted or deleted across a table's full height add or remove table columns.@@ -4708,6 +4708,77 @@ fn convert_to_number_turns_numbers_stored_as_text_into_numbers() { assert_eq!(value(&mut s, "A1"), json!("123")); } +/// Ignore Error, as observed in Excel: per cell and rule, kept as Excel's multi-area ranges,+/// forgotten when the cell's content is edited, moved by inserted rows, never carried by paste.+#[test]+fn ignore_error_is_per_cell_and_rule_and_forgotten_on_edit() {+ let mut s = s();+ let ignored = |s: &mut Session| s.execute("formulas.ignoredErrors", json!({})).unwrap();+ for (cell, input) in [("A1", "'1"), ("A2", "'2"), ("A3", "'3"), ("A4", "'4")] {+ s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();+ }+ // The default rule is number stored as text; the selection is the default range.+ s.execute("selection.set", json!({"range": "A1:A2"})).unwrap();+ assert_eq!(s.execute("formulas.ignoreError", json!({})).unwrap()["ranges"], 1);+ s.execute("formulas.ignoreError", json!({"range": "A4", "kinds": ["evalError", "formula"]})).unwrap();+ assert_eq!(+ ignored(&mut s),+ json!([+ {"sheet": "Sheet1", "ranges": ["A1:A2"], "kinds": ["numberStoredAsText"]},+ {"sheet": "Sheet1", "ranges": ["A4"], "kinds": ["evalError", "formula"]},+ ])+ );+ assert!(s.execute("formulas.ignoreError", json!({"range": "A1", "kinds": ["nonsense"]})).is_err());+ assert!(s.execute("formulas.ignoreError", json!({"range": "A1", "kinds": ["evalError", 4]})).is_err());+ assert!(s.execute("formulas.ignoreError", json!({"range": "A1", "kinds": 4})).is_err());+ // Another rule on the same cells is its own record; the same rule on more cells joins one.+ s.execute("formulas.ignoreError", json!({"range": "A3"})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["A1:A2", "A3"]));+ // Undo takes the last ignore back.+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["A1:A2"]));+ // Retyping a cell forgets what it ignored; its neighbours keep theirs.+ s.execute("cell.set", json!({"cell": "A1", "input": "'7"})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["A2"]));+ // Formatting is not an edit.+ s.execute("home.bold", json!({"range": "A2", "on": true})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["A2"]));+ // Inserted rows move the record with its cells.+ s.execute("home.insertRows", json!({"rows": "2:3"})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["A4"]));+ assert_eq!(ignored(&mut s)[1]["ranges"], json!(["A6"]));+ // Reset one rule in one cell, then everything on every sheet.+ s.execute("formulas.clearIgnoredErrors", json!({"range": "A6", "kinds": "evalError"})).unwrap();+ assert_eq!(ignored(&mut s)[1]["kinds"], json!(["formula"]));+ s.execute("formulas.clearIgnoredErrors", json!({"all": true})).unwrap();+ assert_eq!(ignored(&mut s), json!([]));+}++/// Structural commits skip the generic cleanup, so a renamed name and a cut-paste forget ignored+/// rules themselves; a format-only edit of an empty cell keeps them.+#[test]+fn ignore_error_survives_format_only_edits_and_clears_on_structural_rewrites() {+ let mut s = s();+ let ignored = |s: &mut Session| s.execute("formulas.ignoredErrors", json!({})).unwrap();+ s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.2"})).unwrap();+ s.execute("cell.set", json!({"cell": "A1", "input": "=Rate"})).unwrap();+ s.execute("cell.set", json!({"cell": "B1", "input": "'5"})).unwrap();+ s.execute("cell.set", json!({"cell": "C1", "input": "'6"})).unwrap();+ s.execute("formulas.ignoreError", json!({"range": "A1:C1"})).unwrap();+ s.execute("home.bold", json!({"range": "D1", "on": true})).unwrap();+ s.execute("formulas.ignoreError", json!({"range": "D1"})).unwrap();+ s.execute("home.bold", json!({"range": "D1", "on": false})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["A1:C1", "D1"]));+ // Renaming the name rewrites A1's formula: A1 stops ignoring.+ s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2", "oldName": "Rate"})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["B1:C1", "D1"]));+ // Cutting B1 onto C1 overwrites C1 and empties B1: both records go.+ s.execute("edit.cut", json!({"range": "B1"})).unwrap();+ s.execute("selection.set", json!({"range": "C1"})).unwrap();+ s.execute("edit.paste", json!({})).unwrap();+ assert_eq!(ignored(&mut s)[0]["ranges"], json!(["D1"]));+}+ #[test] fn saving_csv_keeps_leading_empty_columns_and_rows() { let wb = gridcraft_xlsx::read_csv(b",x\r\n\r\n,,y\r\n", &gridcraft_xlsx::CsvOptions::default()).unwrap();@@ -273,6 +273,115 @@ pub enum ErrorStyle { Information, } +// ---------------------------------------------------------------- ignored errors++/// Error-checking rules (File ▸ Options ▸ Formulas) a user can ignore per cell, saved as the+/// attributes of `<ignoredError>` (ISO/IEC 29500-1 §18.3.1.51). A set of rules is a bit mask.+pub mod error_rule {+ pub const EVAL_ERROR: u16 = 1;+ pub const TWO_DIGIT_TEXT_YEAR: u16 = 1 << 1;+ pub const NUMBER_STORED_AS_TEXT: u16 = 1 << 2;+ pub const FORMULA: u16 = 1 << 3;+ pub const FORMULA_RANGE: u16 = 1 << 4;+ pub const UNLOCKED_FORMULA: u16 = 1 << 5;+ pub const EMPTY_CELL_REFERENCE: u16 = 1 << 6;+ pub const LIST_DATA_VALIDATION: u16 = 1 << 7;+ pub const CALCULATED_COLUMN: u16 = 1 << 8;+ pub const ALL: u16 = (1 << 9) - 1;+ /// The file's attribute name for each rule, in the schema's order.+ pub const NAMES: [(&str, u16); 9] = [+ ("evalError", EVAL_ERROR),+ ("twoDigitTextYear", TWO_DIGIT_TEXT_YEAR),+ ("numberStoredAsText", NUMBER_STORED_AS_TEXT),+ ("formula", FORMULA),+ ("formulaRange", FORMULA_RANGE),+ ("unlockedFormula", UNLOCKED_FORMULA),+ ("emptyCellReference", EMPTY_CELL_REFERENCE),+ ("listDataValidation", LIST_DATA_VALIDATION),+ ("calculatedColumn", CALCULATED_COLUMN),+ ];++ /// The rule a name stands for (the file's attribute name, any case).+ pub fn parse(name: &str) -> Option<u16> {+ NAMES.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, bit)| *bit)+ }+}++/// Cells whose error-checking `kinds` (a mask of [`error_rule`] bits) the user chose to ignore.+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]+pub struct IgnoredError {+ pub ranges: Vec<RangeRef>,+ pub kinds: u16,+}++/// What is left of `r` outside `cut`: up to four rectangles.+fn range_minus(r: RangeRef, cut: RangeRef) -> Vec<RangeRef> {+ let Some(i) = r.intersection(&cut) else { return vec![r] };+ let mut out = Vec::new();+ if r.start.row < i.start.row {+ out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));+ }+ if i.end.row < r.end.row {+ out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));+ }+ if r.start.col < i.start.col {+ out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));+ }+ if i.end.col < r.end.col {+ out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));+ }+ out+}++/// Ignores `kinds` in `range`.+pub fn ignore_errors(list: &mut Vec<IgnoredError>, range: RangeRef, kinds: u16) {+ let kinds = kinds & error_rule::ALL;+ if kinds == 0 {+ return;+ }+ if !list.iter().any(|e| e.kinds == kinds && e.ranges.iter().any(|r| r.contains_range(&range))) {+ add_ranges(list, kinds, [range]);+ }+}++/// Adds `ranges` to the record for `kinds` without checking for duplicates, so reading a file+/// with thousands of ranges stays linear.+pub fn add_ranges(list: &mut Vec<IgnoredError>, kinds: u16, ranges: impl IntoIterator<Item = RangeRef>) {+ let kinds = kinds & error_rule::ALL;+ let mut ranges = ranges.into_iter().peekable();+ if kinds == 0 || ranges.peek().is_none() {+ return;+ }+ match list.iter_mut().find(|e| e.kinds == kinds) {+ Some(e) => e.ranges.extend(ranges),+ None => list.push(IgnoredError { ranges: ranges.collect(), kinds }),+ }+}++/// Stops ignoring `kinds` in `range` (the cells outside it keep what they ignore).+pub fn unignore_errors(list: &mut Vec<IgnoredError>, range: RangeRef, kinds: u16) {+ let kinds = kinds & error_rule::ALL;+ let mut out: Vec<IgnoredError> = Vec::new();+ for e in std::mem::take(list) {+ if e.kinds & kinds == 0 || !e.ranges.iter().any(|r| r.intersects(&range)) {+ out.push(e);+ continue;+ }+ let kept = e.kinds & !kinds;+ for r in &e.ranges {+ // Outside `range` the rules stay as they were; inside, only those not being cleared.+ add_ranges(&mut out, e.kinds, range_minus(*r, range));+ add_ranges(&mut out, kept, r.intersection(&range));+ }+ }+ *list = out;+}++/// Whether `kind` (one [`error_rule`] bit) is ignored in `cell`.+pub fn is_error_ignored(list: &[IgnoredError], cell: CellRef, kind: u16) -> bool {+ list.iter().any(|e| e.kinds & kind != 0 && e.ranges.iter().any(|r| r.contains(cell)))+}+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Validation { pub ranges: Vec<RangeRef>,@@ -737,3 +846,52 @@ mod trendline_forecast_tests { assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST); } }++#[cfg(test)]+mod ignored_error_tests {+ use super::*;++ fn range(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef {+ RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))+ }++ fn ignored(list: &[IgnoredError], r: u32, c: u32, kind: u16) -> bool {+ is_error_ignored(list, CellRef::new(r, c), kind)+ }++ #[test]+ fn clearing_the_middle_of_a_block_leaves_the_ring_around_it() {+ let mut list = Vec::new();+ ignore_errors(&mut list, range(0, 0, 4, 4), error_rule::NUMBER_STORED_AS_TEXT | error_rule::EVAL_ERROR);+ unignore_errors(&mut list, range(1, 1, 3, 3), error_rule::NUMBER_STORED_AS_TEXT);+ for r in 0..5 {+ for c in 0..5 {+ let inside = (1..4).contains(&r) && (1..4).contains(&c);+ assert_eq!(ignored(&list, r, c, error_rule::NUMBER_STORED_AS_TEXT), !inside, "({r},{c})");+ assert!(ignored(&list, r, c, error_rule::EVAL_ERROR), "({r},{c}) keeps the other rule");+ }+ }+ }++ #[test]+ fn clearing_a_range_that_misses_or_covers_a_record() {+ let mut list = Vec::new();+ ignore_errors(&mut list, range(2, 2, 3, 3), error_rule::FORMULA);+ unignore_errors(&mut list, range(0, 0, 1, 1), error_rule::ALL);+ assert_eq!(list.len(), 1);+ unignore_errors(&mut list, range(0, 0, 9, 9), error_rule::FORMULA);+ assert!(list.is_empty());+ // No rule, or an unknown bit, ignores nothing.+ ignore_errors(&mut list, range(0, 0, 0, 0), 0);+ ignore_errors(&mut list, range(0, 0, 0, 0), 1 << 12);+ assert!(list.is_empty());+ }++ #[test]+ fn rule_names_are_the_files_attribute_names() {+ assert_eq!(error_rule::parse("numberStoredAsText"), Some(error_rule::NUMBER_STORED_AS_TEXT));+ assert_eq!(error_rule::parse("EVALERROR"), Some(error_rule::EVAL_ERROR));+ assert_eq!(error_rule::parse("bogus"), None);+ assert_eq!(error_rule::NAMES.iter().fold(0, |m, (_, bit)| m | bit), error_rule::ALL);+ }+}@@ -118,6 +118,9 @@ pub struct Sheet { /// Allow Edit Ranges (meaningful while the sheet is protected). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edit_ranges: Vec<crate::EditRange>,+ /// Error-checking rules the user ignored for cells (Ignore Error), saved as `ignoredErrors`.+ #[serde(default, skip_serializing_if = "Vec::is_empty")]+ pub ignored_errors: Vec<crate::IgnoredError>, pub print: PrintSettings, /// Last selection and scroll position, saved with the file. pub view_active: CellRef,@@ -185,6 +188,7 @@ impl Sheet { pivots: vec![], protection: None, edit_ranges: vec![],+ ignored_errors: vec![], print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(),@@ -275,5 +275,12 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { (11.5 * (zoom / DISPLAY_SCALE)).max(8.5) } +/// Whether background error checking flags the cell: a number stored as text the user has not+/// chosen to ignore.+fn is_number_stored_as_text(sh: &Sheet, c: CellRef) -> bool {+ sh.cell(c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some()+ && !gridcraft_engine::model::is_error_ignored(&sh.ignored_errors, c, gridcraft_engine::model::error_rule::NUMBER_STORED_AS_TEXT)+}+ #[cfg(test)] mod tests;@@ -418,7 +418,7 @@ pub(super) fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, return; } let Some(active) = app.session.active().map(|d| d.selection.active) else { return };- if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() {+ if !is_number_stored_as_text(sh, active) { return; } let cell = geo.cell_rect(sh, active);@@ -452,6 +452,10 @@ pub(super) fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, app.run_or_alert("formulas.convertToNumber", json!({})); ui.close(); }+ if ui.button(lang.tr("Ignore Error")).clicked() {+ app.run_or_alert("formulas.ignoreError", json!({}));+ ui.close();+ } }); } @@ -427,7 +427,7 @@ pub(crate) fn paint_quadrant( )); } // Background error checking: a number stored as text gets Excel's green corner (#1994).- if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() {+ if !sh.show_formulas && is_number_stored_as_text(sh, *c) { let s = 6.0 * z.max(0.7); p.add(egui::Shape::convex_polygon( vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)],@@ -63,6 +63,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format Chart Area…" => "Diagrammbereich formatieren…", "Number Stored as Text" => "Als Text gespeicherte Zahl", "Convert to Number" => "In eine Zahl umwandeln",+ "Ignore Error" => "Fehler ignorieren", "Delete Cells…" => "Zellen löschen…", "Insert Sheet Rows" => "Blattzeilen einfügen", "Insert Sheet Columns" => "Blattspalten einfügen",@@ -443,6 +443,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format Chart Area…" => "Formatar Área do Gráfico…", "Number Stored as Text" => "Número Armazenado como Texto", "Convert to Number" => "Converter em Número",+ "Ignore Error" => "Ignorar Erro", "Error checking options" => "Opções de verificação de erros", "Paste formulas that refer to the copied cells" => "Colar fórmulas que se referem às células copiadas", "Row Height" => "Altura da Linha",@@ -9,7 +9,7 @@ use gridcraft_formula::Expr; use gridcraft_model::{ AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, NoteBox, NoteCorner, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation,- ValidationKind, XmlColumnPr,+ ValidationKind, XmlColumnPr, add_ranges, error_rule, }; use crate::IoError;@@ -162,6 +162,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE } } "conditionalFormatting" => read_cf(cx, e, &mut sheet),+ "ignoredErrors" => {+ for ie in e.kids("ignoredError") {+ let kinds = error_rule::NAMES.iter().filter(|(n, _)| ie.flag(n, false)).fold(0, |m, (_, bit)| m | bit);+ // An element with no rule on (Excel leaves one behind after Ignore is turned off) ignores nothing.+ add_ranges(&mut sheet.ignored_errors, kinds, parse_sqref(ie.attr("sqref")));+ }+ } "dataValidations" => { for v in e.kids("dataValidation") { if let Some(v) = read_validation(v) {@@ -5,8 +5,8 @@ use std::fmt::Write as _; use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{- Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, Sheet,- SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,+ Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, IgnoredError, LineInfo, NoteBox, NoteCorner, Orientation,+ Sheet, SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, error_rule, icon_count, }; use crate::drawing::{Obj, image_content_type, image_ext};@@ -505,6 +505,21 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } } + let ignored: Vec<&IgnoredError> = sheet.ignored_errors.iter().filter(|e| e.kinds & error_rule::ALL != 0 && !e.ranges.is_empty()).collect();+ if !ignored.is_empty() {+ s.push_str("<ignoredErrors>");+ for e in ignored {+ let _ = write!(s, "<ignoredError sqref=\"{}\"", sqref(&e.ranges));+ for (name, bit) in error_rule::NAMES {+ if e.kinds & bit != 0 {+ let _ = write!(s, " {name}=\"1\"");+ }+ }+ s.push_str("/>");+ }+ s.push_str("</ignoredErrors>");+ }+ // Drawing: pictures, charts, shapes. if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { out.drawings += 1;@@ -9,6 +9,7 @@ mod chart_ref_offsets; mod csv_tests; mod encrypted_tests; mod fixtures;+mod ignored_errors; mod ink_roundtrip; mod literal_category_roundtrip; mod locale_numfmt;new file mode 100644@@ -0,0 +1,93 @@+//! Ignore Error: the sheet's `ignoredErrors` are read and written back. The element bodies below+//! are what Excel saved after `Range.Errors(n).Ignore = True` on synthetic workbooks.++use std::io::Read;++use gridcraft_core::{CellRef, RangeRef};+use gridcraft_model::{error_rule, is_error_ignored};++use super::minimal;+use crate::{read_xlsx, write_xlsx};++/// One record per rule, as Excel wrote it for B1 (evalError), C1 (text date), E3 (formula), A6+/// (number as text), D1 (formulaRange), E1 (unlockedFormula), F10 (emptyCellReference) and G1+/// (listDataValidation).+const EXCEL_EACH_RULE: &str = r#"<sheetData/><ignoredErrors><ignoredError sqref="B1" evalError="1"/><ignoredError sqref="C1" twoDigitTextYear="1"/><ignoredError sqref="E3" formula="1"/><ignoredError sqref="A6" numberStoredAsText="1"/><ignoredError sqref="D1" formulaRange="1"/><ignoredError sqref="E1" unlockedFormula="1"/><ignoredError sqref="F10" emptyCellReference="1"/><ignoredError sqref="G1" listDataValidation="1"/></ignoredErrors>"#;++/// Cells ignored in separate calls come back as one multi-area sqref; turning Ignore off on A1+/// leaves a record with no rule on it.+const EXCEL_MERGED: &str =+ r#"<sheetData/><ignoredErrors><ignoredError sqref="A2:A3 C1:D1" numberStoredAsText="1"/><ignoredError sqref="A1"/></ignoredErrors>"#;++fn cell(a1: &str) -> CellRef {+ CellRef::parse(a1).unwrap()+}++fn written_sheet(bytes: &[u8]) -> String {+ let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();+ let mut s = String::new();+ z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut s).unwrap();+ s+}++#[test]+fn each_rule_excel_saved_is_read_and_written_back() {+ let (wb, _) = read_xlsx(&minimal(EXCEL_EACH_RULE, &[], "", "")).unwrap();+ let sh = &wb.sheets[0];+ for (a1, rule) in [+ ("B1", error_rule::EVAL_ERROR),+ ("C1", error_rule::TWO_DIGIT_TEXT_YEAR),+ ("E3", error_rule::FORMULA),+ ("A6", error_rule::NUMBER_STORED_AS_TEXT),+ ("D1", error_rule::FORMULA_RANGE),+ ("E1", error_rule::UNLOCKED_FORMULA),+ ("F10", error_rule::EMPTY_CELL_REFERENCE),+ ("G1", error_rule::LIST_DATA_VALIDATION),+ ] {+ assert!(is_error_ignored(&sh.ignored_errors, cell(a1), rule), "{a1}");+ // Only that rule, and only that cell.+ assert!(!is_error_ignored(&sh.ignored_errors, cell(a1), error_rule::ALL & !rule), "{a1}");+ }+ assert!(!is_error_ignored(&sh.ignored_errors, cell("B2"), error_rule::ALL));++ let bytes = write_xlsx(&wb).unwrap();+ let xml = written_sheet(&bytes);+ for expected in [+ r#"<ignoredError sqref="B1" evalError="1"/>"#,+ r#"<ignoredError sqref="C1" twoDigitTextYear="1"/>"#,+ r#"<ignoredError sqref="E3" formula="1"/>"#,+ r#"<ignoredError sqref="A6" numberStoredAsText="1"/>"#,+ r#"<ignoredError sqref="D1" formulaRange="1"/>"#,+ r#"<ignoredError sqref="E1" unlockedFormula="1"/>"#,+ r#"<ignoredError sqref="F10" emptyCellReference="1"/>"#,+ r#"<ignoredError sqref="G1" listDataValidation="1"/>"#,+ ] {+ assert!(xml.contains(expected), "{expected} in {xml}");+ }+ let (back, _) = read_xlsx(&bytes).unwrap();+ assert_eq!(back.sheets[0].ignored_errors, wb.sheets[0].ignored_errors);+}++#[test]+fn a_multi_area_sqref_is_kept_and_a_record_with_no_rule_ignores_nothing() {+ let (wb, _) = read_xlsx(&minimal(EXCEL_MERGED, &[], "", "")).unwrap();+ let sh = &wb.sheets[0];+ let rule = error_rule::NUMBER_STORED_AS_TEXT;+ for a1 in ["A2", "A3", "C1", "D1"] {+ assert!(is_error_ignored(&sh.ignored_errors, cell(a1), rule), "{a1}");+ }+ for a1 in ["A1", "A4", "C2", "E1"] {+ assert!(!is_error_ignored(&sh.ignored_errors, cell(a1), rule), "{a1}");+ }+ assert_eq!(sh.ignored_errors.len(), 1);+ assert_eq!(sh.ignored_errors[0].ranges, [RangeRef::parse("A2:A3").unwrap(), RangeRef::parse("C1:D1").unwrap()]);+ let xml = written_sheet(&write_xlsx(&wb).unwrap());+ assert!(xml.contains(r#"<ignoredError sqref="A2:A3 C1:D1" numberStoredAsText="1"/>"#), "{xml}");+ assert!(!xml.contains(r#"<ignoredError sqref="A1""#), "{xml}");+}++#[test]+fn a_sheet_without_ignored_errors_writes_no_element() {+ let (wb, _) = read_xlsx(&minimal("<sheetData/>", &[], "", "")).unwrap();+ assert!(!written_sheet(&write_xlsx(&wb).unwrap()).contains("ignoredErrors"));+}@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Ignore Error, Reset Ignored Errors and the file's ignoredErrors; a euro sign is read as part of a number) · **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@@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Ignore Error (#1994): the warning button's menu gets Ignore Error, which stops flagging the cell (`formulas.ignoreError {range?, kinds?}`; `formulas.clearIgnoredErrors` is Reset Ignored Errors, `formulas.ignoredErrors` lists them). The sheet's `ignoredErrors` (all nine rules) are read and written; editing a cell forgets what it ignored and inserted or deleted rows and columns move it, as observed in Excel. A euro sign before or after the digits (`65.00€`, `€ 65`) is read as a number, as Excel with US settings does. Not done: the other error-check rules are not detected, so only Number Stored as Text and (through the command) the saved flags exist; Excel's per-rule on/off options and Error Checking Options dialog are missing; Convert to Number does not skip ignored cells (not observed) | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |