From ad272ca071f8ee2579b39c62522e0f2abfe71ded Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 01/13] Enforce workspace Clippy standards and document lint policy --- AGENTS.md | 2 +- CLAUDE.md | 2 +- Cargo.lock | 2 ++ Cargo.toml | 8 +++++--- clippy.toml | 1 + crates/engine/Cargo.toml | 1 + crates/pdf/Cargo.toml | 1 + crates/ui-egui/Cargo.toml | 1 + 8 files changed, 13 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 289cb12d0..01a8da9b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule 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). - **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). +- **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). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). diff --git a/CLAUDE.md b/CLAUDE.md index 8bf527c54..266016a1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). -- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. ## 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. diff --git a/Cargo.lock b/Cargo.lock index 87ff12215..f5a6a6328 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,6 +2024,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "web-time", "zip 3.0.0", ] @@ -2097,6 +2098,7 @@ dependencies = [ "sys-locale", "toml", "web-sys", + "web-time", "zbus", ] diff --git a/Cargo.toml b/Cargo.toml index 32823c84a..4bafc9b27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). -chrono = { version = "0.4.45", default-features = false, features = ["std"] } +chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } +web-time = "1.1.0" rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" @@ -67,10 +68,11 @@ craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-li unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" -type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 diff --git a/clippy.toml b/clippy.toml index de0f11db4..c6fbddafd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -14,3 +14,4 @@ disallowed-methods = [ disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e8ea91c26..1de94c60c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -17,6 +17,7 @@ gridcraft-model = { workspace = true } gridcraft-calc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +web-time = { workspace = true } thiserror = { workspace = true } log = { workspace = true } gridcraft-xlsx = { workspace = true } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 6cb0d2e3a..cd63456c2 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true description = "GridCraft's tiny self-contained PDF 1.7 writer (standard 14 fonts, vector drawing, RGBA images)" +readme = "README.md" [lints] workspace = true diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 762663c5c..b9cddcb90 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -23,6 +23,7 @@ serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } skrifa = { workspace = true } +web-time = { workspace = true } # Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see # `desktop_theme.rs`). Already in the tree, with these features, through egui's own From fb98f5952b17bf6decec3d2fa9051999647f2d8f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 02/13] Refactor core for strict Clippy checks --- crates/core/src/addr.rs | 44 ++++++++++++++++++++++------------------ crates/core/src/date.rs | 22 ++++++++++---------- crates/core/src/value.rs | 5 +++-- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index fd8ffec11..9d10ea87c 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -295,15 +295,16 @@ mod tests { use super::*; #[test] - fn range_parse_rejects_signed_rows() { + fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> { assert!(RangeRef::parse("+1:+3").is_none()); assert!(RangeRef::parse("-1:3").is_none()); assert!(RangeRef::parse("$$1:3").is_none()); assert!(RangeRef::parse(":3").is_none()); assert!(RangeRef::parse("$$A:$$C").is_none()); assert!(RangeRef::parse("$A:$C").is_some()); - let r = RangeRef::parse("$1:$3").unwrap(); + let r = RangeRef::parse("$1:$3").ok_or_else(|| std::io::Error::other("valid row range"))?; assert_eq!((r.start.row, r.end.row), (0, 2)); + Ok(()) } #[test] @@ -389,18 +390,20 @@ mod tests { } #[test] - fn ranges() { - let r = RangeRef::parse("C3:A1").unwrap(); + fn ranges() -> Result<(), std::io::Error> { + let parse = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid range")); + let r = parse("C3:A1")?; assert_eq!(r.a1(), "A1:C3"); assert_eq!(r.count(), 9); - assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C"); - assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5"); + assert_eq!(parse("A:C")?.a1(), "A:C"); + assert_eq!(parse("3:5")?.a1(), "3:5"); assert!(RangeRef::parse("0:5").is_none()); - let a = RangeRef::parse("A1:C3").unwrap(); - let b = RangeRef::parse("B2:D4").unwrap(); - assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3"); + let a = parse("A1:C3")?; + let b = parse("B2:D4")?; + assert_eq!(a.intersection(&b).ok_or_else(|| std::io::Error::other("overlap expected"))?.a1(), "B2:C3"); assert_eq!(a.union(&b).a1(), "A1:D4"); assert_eq!(a.iter().count(), 9); + Ok(()) } #[test] @@ -458,18 +461,19 @@ mod category_level_tests { use super::*; #[test] - fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() { - let r = |s: &str| RangeRef::parse(s).unwrap(); - assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 })); - assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 })); - assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped"); - assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list"); + fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() -> Result<(), std::io::Error> { + let r = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid test range")); + assert_eq!(category_levels(&r("A4:B39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A4:D39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 3 })); + assert_eq!(category_levels(&r("A4:F39")?, Some(&r("C4:C39")?)).map(|l| l.outer), Some(3), "capped"); + assert_eq!(category_levels(&r("A4:A39")?, Some(&r("C4:C39")?)), None, "one column: a plain list"); // A row of values: levels are rows. - assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 })); - assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None); + assert_eq!(category_levels(&r("B2:M3")?, Some(&r("B4:M4")?)), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("B3:M3")?, Some(&r("B4:M4")?)), None); // Literal values: the categories' own shape decides. - assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 })); - assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 })); - assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns"); + assert_eq!(category_levels(&r("A1:B6")?, None), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A1:F2")?, None), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("A:B")?, Some(&r("C:C")?)), None, "whole columns"); + Ok(()) } } diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 8e68695f1..606d3dacf 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -47,13 +47,11 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { } /// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. -/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`. -#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap. pub fn unix_now_ms() -> Option { #[cfg(not(target_arch = "wasm32"))] { - let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?; - Some(d.as_secs_f64() * 1000.0) + Some(chrono::Utc::now().timestamp_millis() as f64) } #[cfg(target_arch = "wasm32")] { @@ -323,7 +321,7 @@ mod tests { } #[test] - fn fictitious_1900_02_29() { + fn fictitious_1900_02_29() -> Result<(), std::io::Error> { // Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count // through it. let s = DateSystem::D1900; @@ -345,7 +343,7 @@ mod tests { assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0)); // Every serial up to 1900-12-31 comes back from its own year, month and day. for serial in 0..=366 { - let d = datetime_from_serial(s, serial as f64).unwrap(); + let d = datetime_from_serial(s, serial as f64).ok_or_else(|| std::io::Error::other("serial should convert"))?; assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}"); } assert_eq!(days_in_month_in(s, 1900, 2), 29); @@ -358,6 +356,7 @@ mod tests { assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0)); assert_eq!(serial_from_ymd(s, 1904, 1, 0), None); assert_eq!(serial_from_ymd(s, 1900, 2, 29), None); + Ok(()) } #[test] @@ -371,17 +370,18 @@ mod tests { } #[test] - fn roundtrip() { + fn roundtrip() -> Result<(), std::io::Error> { let s = DateSystem::D1900; for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] { - let d = datetime_from_serial(s, serial).unwrap(); + let d = datetime_from_serial(s, serial).ok_or_else(|| std::io::Error::other("serial should convert"))?; assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial)); } - let d = datetime_from_serial(s, 60.0).unwrap(); + let d = datetime_from_serial(s, 60.0).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?; assert_eq!((d.year, d.month, d.day), (1900, 2, 29)); - let d = datetime_from_serial(s, 46302.75).unwrap(); + let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?; assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday - let d = datetime_from_serial(s, 1.0).unwrap(); + let d = datetime_from_serial(s, 1.0).ok_or_else(|| std::io::Error::other("first serial should convert"))?; assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar + Ok(()) } } diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs index c531de4dc..46721beda 100644 --- a/crates/core/src/value.rs +++ b/crates/core/src/value.rs @@ -454,11 +454,12 @@ mod tests { } #[test] - fn arrays() { - let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap(); + fn arrays() -> Result<(), std::io::Error> { + let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).ok_or_else(|| std::io::Error::other("valid array"))?; assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0))); assert!(Array::new(2, 2, vec![]).is_none()); let row = Array::row(vec![1.0.into(), 2.0.into()]); assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0)); + Ok(()) } } From 68e929b1d919659eac1496fcfcd12eaac57cb5f0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 03/13] Refactor formula for strict Clippy checks --- crates/formula/src/lexer.rs | 395 ++++++++++++++++++------------------ crates/formula/src/tests.rs | 317 +++++++++++++++-------------- 2 files changed, 363 insertions(+), 349 deletions(-) diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index 332cb6eeb..2799b5b6f 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -67,6 +67,198 @@ fn starts_range_end(c: Option) -> bool { c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '$') } +fn byte_pos(src: &str, chars: &[(usize, char)], index: usize) -> usize { + chars.get(index).map(|c| c.0).unwrap_or(src.len()) +} + +fn char_at(chars: &[(usize, char)], index: usize) -> Option { + chars.get(index).map(|c| c.1) +} + +fn emit(src: &str, chars: &[(usize, char)], out: &mut Vec, tok: Tok, start: usize, end_index: usize) { + out.push(Token { tok, start, end: byte_pos(src, chars, end_index) }); +} + +fn quoted(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Vec) -> Result { + let quote = char_at(chars, i).unwrap_or_default(); + let mut value = String::new(); + let mut j = i + 1; + loop { + match char_at(chars, j) { + None => { + let msg = if quote == '"' { "unterminated text" } else { "unterminated sheet name" }; + return Err(LexError { msg: msg.into(), pos: start }); + } + Some(c) if c == quote && char_at(chars, j + 1) == Some(quote) => { + value.push(quote); + j += 2; + } + Some(c) if c == quote => { + j += 1; + break; + } + Some(c) => { + value.push(c); + j += 1; + } + } + } + if quote == '"' { + emit(src, chars, out, Tok::Text(value), start, j); + return Ok(j); + } + if char_at(chars, j) != Some('!') { + return Err(LexError { msg: "expected ! after sheet name".into(), pos: byte_pos(src, chars, j) }); + } + let (first, last) = match value.split_once(':') { + Some((first, last)) => (first.to_string(), Some(last.to_string())), + None => (value, None), + }; + emit(src, chars, out, Tok::Sheet(first, last), start, j + 1); + Ok(j + 1) +} + +fn error_literal(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Vec) -> Result { + let rest = src.get(start..).unwrap_or(""); + let error = CellError::ALL + .iter() + .copied() + .find(|error| rest.get(..error.as_str().len()).is_some_and(|text| text.eq_ignore_ascii_case(error.as_str()))) + .ok_or_else(|| LexError { msg: "unknown error literal".into(), pos: start })?; + let end = i + error.as_str().chars().count(); + emit(src, chars, out, Tok::Error(error), start, end); + Ok(end) +} + +fn structured_ref(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Vec) -> Result { + let mut depth = 0; + let mut j = i; + loop { + match char_at(chars, j) { + None => return Err(LexError { msg: "unbalanced [".into(), pos: start }), + Some('\'') => j += 2, + Some('[') => { + depth += 1; + j += 1; + } + Some(']') => { + depth -= 1; + j += 1; + if depth == 0 { + break; + } + } + Some(_) => j += 1, + } + } + let inner = src.get(byte_pos(src, chars, i + 1)..byte_pos(src, chars, j.saturating_sub(1))).unwrap_or("").to_string(); + emit(src, chars, out, Tok::Struct(inner), start, j); + Ok(j) +} + +fn number(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, i: usize, start: usize, out: &mut Vec) -> Result { + let ch = |index| char_at(chars, index); + let mut j = i; + while ch(j).is_some_and(|c| c.is_ascii_digit()) { + j += 1; + } + let is_row_ref = { + let mut k = j; + if ch(k) == Some('.') && ch(k + 1) == Some(':') { + k += 1; + } + ch(k) == Some(':') && { + k += 1; + if ch(k) == Some('.') { + k += 1; + } + if ch(k) == Some('$') { + k += 1; + } + ch(k).is_some_and(|c| c.is_ascii_digit()) + } + }; + if is_row_ref { + let word = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + emit(src, chars, out, Tok::Word(word), start, j); + return Ok(j); + } + if ch(j) == Some(locale.decimal_separator()) { + j += 1; + while ch(j).is_some_and(|c| c.is_ascii_digit()) { + j += 1; + } + } + if matches!(ch(j), Some('e' | 'E')) { + let mut exponent = j + 1; + if matches!(ch(exponent), Some('+' | '-')) { + exponent += 1; + } + if ch(exponent).is_some_and(|c| c.is_ascii_digit()) { + while ch(exponent).is_some_and(|c| c.is_ascii_digit()) { + exponent += 1; + } + j = exponent; + } + } + let text = src.get(start..byte_pos(src, chars, j)).unwrap_or(""); + let value: f64 = + text.replace(locale.decimal_separator(), ".").parse().map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?; + if !value.is_finite() { + return Err(LexError { msg: format!("number too large `{text}`"), pos: start }); + } + emit(src, chars, out, Tok::Number(value), start, j); + Ok(j) +} + +fn word(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, i: usize, start: usize, out: &mut Vec) -> usize { + let ch = |index| char_at(chars, index); + let mut j = i; + while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + j += 1; + } + let value: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + if ch(j) == Some('!') { + emit(src, chars, out, Tok::Sheet(value, None), start, j + 1); + j + 1 + } else if ch(j) == Some(':') && sheet_range_end(chars, locale, array_depth, &value, j, out) { + let mut end = j + 1; + while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + end += 1; + } + let last: String = chars.get(j + 1..end).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + emit(src, chars, out, Tok::Sheet(value, Some(last)), start, end + 1); + end + 1 + } else if ch(j) == Some('(') { + emit(src, chars, out, Tok::Func(value), start, j + 1); + j + 1 + } else if value.eq_ignore_ascii_case("TRUE") && ch(j) != Some('[') { + emit(src, chars, out, Tok::Bool(true), start, j); + j + } else if value.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') { + emit(src, chars, out, Tok::Bool(false), start, j); + j + } else if value.len() > 1 && value.ends_with('.') && ch(j) == Some(':') && is_range_end(value.get(..value.len() - 1).unwrap_or("")) { + emit(src, chars, out, Tok::Word(value[..value.len() - 1].to_string()), start, j - 1); + j - 1 + } else { + emit(src, chars, out, Tok::Word(value), start, j); + j + } +} + +fn sheet_range_end(chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, value: &str, colon: usize, out: &[Token]) -> bool { + let ch = |index| char_at(chars, index); + let mut end = colon + 1; + while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + end += 1; + } + let repeats_sheet = is_range_end(value) + && matches!(out.last(), Some(Token { tok: Tok::Sheet(previous, None), .. }) + if chars.get(colon + 1..end).is_some_and(|s| crate::same_name(&s.iter().map(|c| c.1).collect::(), previous))); + end > colon + 1 && ch(end) == Some('!') && !repeats_sheet +} + /// Tokenizes formula text (without the leading `=`). pub fn tokenize(src: &str) -> Result, LexError> { tokenize_locale(src, crate::FormulaLocale::En) @@ -94,107 +286,17 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - let mut s = String::new(); - let mut j = i + 1; - loop { - match ch(j) { - None => return Err(LexError { msg: "unterminated text".into(), pos: start }), - Some('"') if ch(j + 1) == Some('"') => { - s.push('"'); - j += 2; - } - Some('"') => { - j += 1; - break; - } - Some(x) => { - s.push(x); - j += 1; - } - } - } - push(&mut out, Tok::Text(s), j); - i = j; - } - '\'' => { - // Quoted sheet name: 'My Sheet'! or 'A:B'! - let mut s = String::new(); - let mut j = i + 1; - loop { - match ch(j) { - None => return Err(LexError { msg: "unterminated sheet name".into(), pos: start }), - Some('\'') if ch(j + 1) == Some('\'') => { - s.push('\''); - j += 2; - } - Some('\'') => { - j += 1; - break; - } - Some(x) => { - s.push(x); - j += 1; - } - } - } - if ch(j) != Some('!') { - return Err(LexError { msg: "expected ! after sheet name".into(), pos: pos_of(j) }); - } - let (a, b) = match s.split_once(':') { - Some((a, b)) => (a.to_string(), Some(b.to_string())), - None => (s, None), - }; - push(&mut out, Tok::Sheet(a, b), j + 1); - i = j + 1; - } + '"' | '\'' => i = quoted(src, &chars, i, start, &mut out)?, '#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => { // Spill range operator right after a reference: `A1#`. push(&mut out, Tok::Op("#"), i + 1); i += 1; } '#' => { - // Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct). - let rest = src.get(start..).unwrap_or(""); - let mut found = None; - for e in CellError::ALL { - let t = e.as_str(); - if rest.len() >= t.len() && rest.get(..t.len()).is_some_and(|p| p.eq_ignore_ascii_case(t)) { - found = Some(e); - break; - } - } - let Some(e) = found else { return Err(LexError { msg: "unknown error literal".into(), pos: start }) }; - let len = e.as_str().chars().count(); - // `#REF!A1` style is not valid; `#REF!` is an error literal. - push(&mut out, Tok::Error(e), i + len); - i += len; + i = error_literal(src, &chars, i, start, &mut out)?; } '[' => { - // Structured reference body: balanced brackets. - let mut depth = 0; - let mut j = i; - loop { - match ch(j) { - None => return Err(LexError { msg: "unbalanced [".into(), pos: start }), - Some('\'') => j += 2, // escape char inside struct refs - Some('[') => { - depth += 1; - j += 1; - } - Some(']') => { - depth -= 1; - j += 1; - if depth == 0 { - break; - } - } - Some(_) => j += 1, - } - } - let inner = src.get(pos_of(i + 1)..pos_of(j.saturating_sub(1))).unwrap_or("").to_string(); - push(&mut out, Tok::Struct(inner), j); - i = j; + i = structured_ref(src, &chars, i, start, &mut out)?; } '.' if ch(i + 1) == Some(':') => { // Trim reference `A1.:E10` / `A1.:.E10` (the word before stopped at the dot). @@ -207,64 +309,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - // Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers - // followed by ':' and digits are row refs: lex as Word. - let mut j = i; - while ch(j).is_some_and(|d| d.is_ascii_digit()) { - j += 1; - } - // Row ranges `1:3`, and the trim forms `1.:3`, `1:.3`, `1.:.3`. - let is_row_ref = { - let mut k = j; - if ch(k) == Some('.') && ch(k + 1) == Some(':') { - k += 1; - } - ch(k) == Some(':') && { - k += 1; - if ch(k) == Some('.') { - k += 1; - } - if ch(k) == Some('$') { - k += 1; - } - ch(k).is_some_and(|d| d.is_ascii_digit()) - } - }; - // A word like "1A" can't happen in valid formulas; numbers then. - if is_row_ref { - let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); - push(&mut out, Tok::Word(w), j); - i = j; - continue; - } - if ch(j) == Some(locale.decimal_separator()) { - j += 1; - while ch(j).is_some_and(|d| d.is_ascii_digit()) { - j += 1; - } - } - if matches!(ch(j), Some('e' | 'E')) { - let mut k = j + 1; - if matches!(ch(k), Some('+' | '-')) { - k += 1; - } - if ch(k).is_some_and(|d| d.is_ascii_digit()) { - while ch(k).is_some_and(|d| d.is_ascii_digit()) { - k += 1; - } - j = k; - } - } - let text = src.get(start..pos_of(j)).unwrap_or(""); - let v: f64 = text - .replace(locale.decimal_separator(), ".") - .parse() - .map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?; - if !v.is_finite() { - return Err(LexError { msg: format!("number too large `{text}`"), pos: start }); - } - push(&mut out, Tok::Number(v), j); - i = j; + i = number(src, &chars, locale, i, start, &mut out)?; } '(' => { push(&mut out, Tok::LParen, i + 1); @@ -328,53 +373,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - let mut j = i; - while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { - j += 1; - } - let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); - if ch(j) == Some('!') { - push(&mut out, Tok::Sheet(w, None), j + 1); - i = j + 1; - } else if ch(j) == Some(':') && { - // Sheet1:Sheet3!A1 — look ahead for word then '!'. - let mut k = j + 1; - while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { - k += 1; - } - // `Sheet1!A1:Sheet1!B2` is a range whose second end repeats the sheet, not a span. - let repeats_sheet = is_range_end(&w) - && matches!(out.last(), Some(Token { tok: Tok::Sheet(p, None), .. }) - if chars.get(j + 1..k).is_some_and(|s| crate::same_name(&s.iter().map(|c| c.1).collect::(), p))); - k > j + 1 && ch(k) == Some('!') && !repeats_sheet - } { - let mut k = j + 1; - while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { - k += 1; - } - let w2: String = chars.get(j + 1..k).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); - push(&mut out, Tok::Sheet(w, Some(w2)), k + 1); - i = k + 1; - } else if ch(j) == Some('(') { - push(&mut out, Tok::Func(w), j + 1); - i = j + 1; - } else if w.eq_ignore_ascii_case("TRUE") && ch(j) != Some('[') { - push(&mut out, Tok::Bool(true), j); - i = j; - } else if w.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') { - push(&mut out, Tok::Bool(false), j); - i = j; - } else if w.len() > 1 && w.ends_with('.') && ch(j) == Some(':') && is_range_end(w.get(..w.len() - 1).unwrap_or("")) { - // `A1.:E10`: the dot belongs to the trim operator, lexed next. - let w = w.get(..w.len() - 1).unwrap_or("").to_string(); - push(&mut out, Tok::Word(w), j - 1); - i = j - 1; - } else { - push(&mut out, Tok::Word(w), j); - i = j; - } - } + c if is_word_char(c) => i = word(src, &chars, locale, array_depth, i, start, &mut out), other => return Err(LexError { msg: format!("unexpected character `{other}`"), pos: start }), } } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 0f16116af..0fdc9ca38 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -2,7 +2,10 @@ use super::*; use crate::adjust::{Axis, Edit, adjust, rename_sheet, shift_relative}; fn rt(src: &str) -> String { - print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) + match parse(src) { + Ok(e) => print(&e), + Err(e) => format!("{src}: {e}"), + } } #[test] @@ -87,11 +90,11 @@ fn normalises() { } #[test] -fn spill_operator() { +fn spill_operator() -> Result<(), Box> { for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] { assert_eq!(rt(f), f); } - assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _))); + assert!(matches!(parse("A1#")?, Expr::Unary(UnOp::Spill, _))); // Files spell it ANCHORARRAY. assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#"); assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)"); @@ -99,35 +102,35 @@ fn spill_operator() { assert!(parse("A1:B2#").is_err()); assert!(parse("Name#").is_err()); // Deleting the anchor's sheet leaves #REF!. - assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data", &[])), "SUM(#REF!)"); + assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)")?, "Data", &[])), "SUM(#REF!)"); + Ok(()) } #[test] -fn delete_sheet_shrinks_3d_span() { +fn delete_sheet_shrinks_3d_span() -> Result<(), Box> { let order: Vec = ["Sheet1", "Sheet2", "Sheet3", "Results"].iter().map(|s| s.to_string()).collect(); - let del = |f: &str, n: &str| print(&crate::adjust::delete_sheet(parse(f).unwrap(), n, &order)); - assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet1"), "SUM(Sheet2:Sheet3!A1)"); - assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet3"), "SUM(Sheet1:Sheet2!A1)"); - assert_eq!(del("SUM(Sheet3:Sheet1!A1)", "Sheet1"), "SUM(Sheet3:Sheet2!A1)"); - assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet2"), "SUM(Sheet1:Sheet3!A1)"); - assert_eq!(del("SUM(Sheet1:Sheet2!A1)", "Sheet1"), "SUM(Sheet2!A1)"); - assert_eq!(del("Sheet1!A1+Sheet3!A1", "Sheet1"), "#REF!+Sheet3!A1"); + let del = |f: &str, n: &str| parse(f).map(|e| print(&crate::adjust::delete_sheet(e, n, &order))); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet1")?, "SUM(Sheet2:Sheet3!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet3")?, "SUM(Sheet1:Sheet2!A1)"); + assert_eq!(del("SUM(Sheet3:Sheet1!A1)", "Sheet1")?, "SUM(Sheet3:Sheet2!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet2")?, "SUM(Sheet1:Sheet3!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet2!A1)", "Sheet1")?, "SUM(Sheet2!A1)"); + assert_eq!(del("Sheet1!A1+Sheet3!A1", "Sheet1")?, "#REF!+Sheet3!A1"); + Ok(()) } #[test] -fn precedence() { - let e = parse("1+2*3").unwrap(); +fn precedence() -> Result<(), Box> { + let e = parse("1+2*3")?; assert!(matches!(e, Expr::Binary(BinOp::Add, _, _))); - let e = parse("-2^2").unwrap(); + let e = parse("-2^2")?; assert!(matches!(e, Expr::Binary(BinOp::Pow, _, _))); - let e = parse("1&2=\"12\"").unwrap(); + let e = parse("1&2=\"12\"")?; assert!(matches!(e, Expr::Binary(BinOp::Eq, _, _))); - let e = parse("2^3^2").unwrap(); // left associative: (2^3)^2 - if let Expr::Binary(BinOp::Pow, l, _) = e { - assert!(matches!(*l, Expr::Binary(BinOp::Pow, _, _))); - } else { - panic!() - } + let e = parse("2^3^2")?; // left associative: (2^3)^2 + let Expr::Binary(BinOp::Pow, l, _) = e else { return Err("not a power".into()) }; + assert!(matches!(*l, Expr::Binary(BinOp::Pow, _, _))); + Ok(()) } #[test] @@ -149,122 +152,130 @@ fn no_panic_on_garbage() { } #[test] -fn references() { - let e = parse("SUM(A1:B2)+Sheet2!C3*D4").unwrap(); +fn references() -> Result<(), Box> { + let e = parse("SUM(A1:B2)+Sheet2!C3*D4")?; let refs: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); assert_eq!(refs, ["A1:B2", "Sheet2!C3", "D4"]); + Ok(()) } #[test] -fn copy_shift() { - let e = parse("A1+$A1+A$1+$A$1+SUM(B2:C3)").unwrap(); +fn copy_shift() -> Result<(), Box> { + let e = parse("A1+$A1+A$1+$A$1+SUM(B2:C3)")?; assert_eq!(print(&shift_relative(e, 2, 1)), "B3+$A3+B$1+$A$1+SUM(C4:D5)"); - let e = parse("A1").unwrap(); + let e = parse("A1")?; assert_eq!(print(&shift_relative(e, -1, 0)), "#REF!"); - let e = parse("SUM(A:A)").unwrap(); + let e = parse("SUM(A:A)")?; assert_eq!(print(&shift_relative(e, 5, 1)), "SUM(B:B)"); + Ok(()) } #[test] -fn insert_delete() { +fn insert_delete() -> Result<(), Box> { let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 }; - let e = parse("A1+A3+SUM(A2:A5)+Other!A3").unwrap(); + let e = parse("A1+A3+SUM(A2:A5)+Other!A3")?; assert_eq!(print(&adjust(e, "Sheet1", "Sheet1", &ins)), "A1+A5+SUM(A2:A7)+Other!A3"); let del = Edit::Delete { axis: Axis::Rows, at: 2, count: 2 }; // rows 3-4 - let e = parse("A1+A3+SUM(A2:A5)+A6").unwrap(); + let e = parse("A1+A3+SUM(A2:A5)+A6")?; assert_eq!(print(&adjust(e, "Sheet1", "Sheet1", &del)), "A1+#REF!+SUM(A2:A3)+A4"); let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 }; - let e = parse("SUM(A1:C1)+B2").unwrap(); + let e = parse("SUM(A1:C1)+B2")?; assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1:B1)+A2"); - let e = parse("Sheet1!B2").unwrap(); + let e = parse("Sheet1!B2")?; assert_eq!(print(&adjust(e, "Other", "Sheet1", &del)), "Sheet1!A2"); - let e = parse("SUM(A:A)").unwrap(); + let e = parse("SUM(A:A)")?; assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)"); + Ok(()) } #[test] -fn insert_delete_cells() { - let range = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap(); - let adj = |f: &str, e: &Edit| print(&adjust(parse(f).unwrap(), "S", "S", e)); +fn insert_delete_cells() -> Result<(), Box> { + let range = |a: &str| gridcraft_core::RangeRef::parse(a); + let adj = |f: &str, e: &Edit| parse(f).map(|x| print(&adjust(x, "S", "S", e))); // Insert A3:A4, shifting down: column A moves, other columns and wider ranges don't. - let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4") }; - assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins), "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)"); + let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4").ok_or("none")? }; + assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins)?, "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)"); // Insert B2 shifting right. - let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2") }; - assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins), "A2+C2+D2+B3+SUM(A2:D2)"); + let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2").ok_or("none")? }; + assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins)?, "A2+C2+D2+B3+SUM(A2:D2)"); // Delete A3:A4 shifting up: references into it become #REF!, those below move up. - let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4") }; - assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del), "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)"); + let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4").ok_or("none")? }; + assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del)?, "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)"); // Delete B1:C2 shifting left. - let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2") }; - assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del), "A1+#REF!+B1+B2+D3+SUM(A1:C1)"); + let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2").ok_or("none")? }; + assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del)?, "A1+#REF!+B1+B2+D3+SUM(A1:C1)"); + Ok(()) } #[test] -fn moves_and_renames() { - let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; - let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap(); +fn moves_and_renames() -> Result<(), Box> { + let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").ok_or("none")?, to_row: 9, to_col: 3 }; + let e = parse("A1+B2+C3+SUM(A1:B2)")?; assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)"); - let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").unwrap(), to_row: 0, to_col: 4 }; - assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1").unwrap(), "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1"); - let e = parse("Old!A1+'Old'!B2").unwrap(); + let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").ok_or("none")?, to_row: 0, to_col: 4 }; + assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1")?, "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1"); + let e = parse("Old!A1+'Old'!B2")?; assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2"); + Ok(()) } #[test] -fn rename_sheet_updates_qualified_names() { - let e = parse("Data!MyName+MyName+Other!MyName").unwrap(); +fn rename_sheet_updates_qualified_names() -> Result<(), Box> { + let e = parse("Data!MyName+MyName+Other!MyName")?; assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName"); - let e = parse("Data!MyName").unwrap(); + let e = parse("Data!MyName")?; assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName"); // Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches. - let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's"); + let e = rename_sheet(parse("Data!MyName")?, "Data", "It's"); assert_eq!(e, Expr::Name("It's!MyName".into())); assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName"); + Ok(()) } #[test] -fn moves_between_sheets() { - let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_sheet: "New Name".into(), to_row: 9, to_col: 3 }; +fn moves_between_sheets() -> Result<(), Box> { + let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse("A1:B2").ok_or("none")?, to_sheet: "New Name".into(), to_row: 9, to_col: 3 }; for (host, input, expected) in [ ("Source", "A1+$B$2+C3+SUM(A1:B2)", "'New Name'!D10+'New Name'!$E$11+C3+SUM('New Name'!D10:E11)"), ("Other", "A1+sOuRcE!$B2+Other!B2", "A1+'New Name'!$E11+Other!B2"), ("Source", "SUM(A1:C3)+SUM(Source:Other!A1)", "SUM(A1:C3)+SUM(Source:Other!A1)"), ] { - assert_eq!(print(&adjust(parse(input).unwrap(), host, "Source", &mv)), expected); + assert_eq!(print(&adjust(parse(input)?, host, "Source", &mv)), expected); } let edge = Edit::MoveToSheet { - from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), + from: gridcraft_core::RangeRef::parse("A1:B2").ok_or("none")?, to_sheet: "New Name".into(), to_row: gridcraft_core::MAX_ROWS - 1, to_col: 3, }; - assert_eq!(print(&adjust(parse("B2").unwrap(), "Source", "Source", &edge)), "#REF!"); + assert_eq!(print(&adjust(parse("B2")?, "Source", "Source", &edge)), "#REF!"); for (from, input, row, col, expected) in [("A:A", "$A:$A", 0, 2, "Dest!$C:$C"), ("1:1", "$1:$1", 2, 0, "Dest!$3:$3")] { - let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse(from).unwrap(), to_sheet: "Dest".into(), to_row: row, to_col: col }; - assert_eq!(print(&adjust(parse(input).unwrap(), "Source", "Source", &mv)), expected); + let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse(from).ok_or("none")?, to_sheet: "Dest".into(), to_row: row, to_col: col }; + assert_eq!(print(&adjust(parse(input)?, "Source", "Source", &mv)), expected); } + Ok(()) } #[test] -fn r1c1() { - let e = parse("A1+$B$2+SUM(C:C)").unwrap(); +fn r1c1() -> Result<(), Box> { + let e = parse("A1+$B$2+SUM(C:C)")?; assert_eq!(print_r1c1(&e, gridcraft_core::CellRef::new(1, 1)), "R[-1]C[-1]+R2C2+SUM(C[1])"); + Ok(()) } #[test] -fn spans() { - let toks = lexer::tokenize("SUM(A1, B2)").unwrap(); - let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).unwrap(); +fn spans() -> Result<(), Box> { + let toks = lexer::tokenize("SUM(A1, B2)")?; + let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).ok_or("none")?; assert_eq!((a1.start, a1.end), (4, 6)); + Ok(()) } #[test] -fn unbracketed_this_row_column() { +fn unbracketed_this_row_column() -> Result<(), Box> { // Writers such as xlsxwriter spell `[@Units]` as `[[#This Row],Units]`. - let p = |f: &str| parse(f).unwrap_or_else(|e| panic!("{f}: {e}")); - let Expr::Binary(BinOp::Mul, a, _) = p("[[#This Row],Units]*[[#This Row],Price]") else { panic!() }; - let Expr::Struct(s) = *a else { panic!() }; + let Expr::Binary(BinOp::Mul, a, _) = parse("[[#This Row],Units]*[[#This Row],Price]")? else { return Err("not a product".into()) }; + let Expr::Struct(s) = *a else { return Err("not a struct".into()) }; assert_eq!((s.specifiers.as_slice(), s.col_start.as_deref()), ([StructItem::ThisRow].as_slice(), Some("Units"))); assert_eq!(rt("[[#This Row],Units]*[[#This Row],Price]"), "[@Units]*[@Price]"); assert_eq!(rt("Sales[[#This Row],Units]"), "Sales[@Units]"); @@ -284,24 +295,26 @@ fn unbracketed_this_row_column() { ] { assert!(parse(bad).is_err(), "{bad} should fail"); } + Ok(()) } #[test] -fn struct_column_keeps_edge_spaces() { +fn struct_column_keeps_edge_spaces() -> Result<(), Box> { // The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`. - let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() }; + let Expr::Struct(s) = parse("Table1[Qty ]")? else { return Err("unexpected expression".into()) }; assert_eq!(s.col_start.as_deref(), Some("Qty ")); - let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() }; + let Expr::Struct(s) = parse("Table1[ Qty]")? else { return Err("unexpected expression".into()) }; assert_eq!(s.col_start.as_deref(), Some(" Qty")); // Excel writes such a column in the double-bracket form. assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]"); for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] { assert_eq!(rt(f), f); } + Ok(()) } #[test] -fn trim_references() { +fn trim_references() -> Result<(), Box> { for f in [ "SUM(A1:.E10)", "SUM(A1.:E10)", @@ -319,15 +332,15 @@ fn trim_references() { ] { assert_eq!(rt(f), f, "round trip of {f}"); } - let Expr::Call(_, args) = parse("SUM(A1:.E10)").unwrap() else { panic!() }; + let Expr::Call(_, args) = parse("SUM(A1:.E10)")? else { return Err("unexpected expression".into()) }; assert!(matches!(args.as_slice(), [Expr::Unary(UnOp::Trim(TrimMode::Trailing), r)] if matches!(**r, Expr::Ref(_)))); // Decimals, row ranges and dotted names keep their meaning. - assert_eq!(parse("1.5+.5").unwrap(), Expr::Binary(BinOp::Add, Box::new(Expr::Number(1.5)), Box::new(Expr::Number(0.5)))); + assert_eq!(parse("1.5+.5")?, Expr::Binary(BinOp::Add, Box::new(Expr::Number(1.5)), Box::new(Expr::Number(0.5)))); for f in ["SUM(1:5)", "Q1.2026!A1:B2", "my.name+1", "SUM(A1:B2)*1.5", "1.:5"] { assert_eq!(rt(f), f); } assert_eq!(rt("'Q1.2026'!A1:B2"), "Q1.2026!A1:B2"); - assert!(matches!(parse("Q1.2026!A1:B2").unwrap(), Expr::Ref(_))); + assert!(matches!(parse("Q1.2026!A1:B2")?, Expr::Ref(_))); // File call forms. assert_eq!(rt("SUM(_xlfn._TRO_TRAILING(A1:E10))"), "SUM(A1:.E10)"); assert_eq!(rt("_xlfn._TRO_LEADING(A:A)"), "A.:A"); @@ -338,77 +351,76 @@ fn trim_references() { assert!(parse(bad).is_err(), "{bad} should fail"); } // R1C1 keeps the operator. - assert_eq!(print_r1c1(&parse("SUM(A1:.B2)+ROWS(2.:2)").unwrap(), gridcraft_core::CellRef::new(0, 0)), "SUM(RC:.R[1]C[1])+ROWS(R[1].:R[1])"); + assert_eq!(print_r1c1(&parse("SUM(A1:.B2)+ROWS(2.:2)")?, gridcraft_core::CellRef::new(0, 0)), "SUM(RC:.R[1]C[1])+ROWS(R[1].:R[1])"); + Ok(()) } #[test] -fn trim_references_adjust() { +fn trim_references_adjust() -> Result<(), Box> { let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 }; - let e = parse("SUM(A1:.A5)+SUM(B:.B)+ROWS(2.:.4)").unwrap(); + let e = parse("SUM(A1:.A5)+SUM(B:.B)+ROWS(2.:.4)")?; assert_eq!(print(&adjust(e, "S", "S", &ins)), "SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)"); let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 }; - let e = parse("SUM(A1.:C1)+SUM(A:.A)").unwrap(); + let e = parse("SUM(A1.:C1)+SUM(A:.A)")?; assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1.:B1)+SUM(#REF!)"); - assert_eq!(print(&shift_relative(parse("SUM(A1.:.B2)").unwrap(), 1, 1)), "SUM(B2.:.C3)"); - let refs: Vec = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(print(&shift_relative(parse("SUM(A1.:.B2)")?, 1, 1)), "SUM(B2.:.C3)"); + let refs: Vec = parse("SUM(Sheet2!A1:.B5)")?.references().iter().map(|r| printer::reference_a1(r)).collect(); assert_eq!(refs, ["Sheet2!A1:B5"]); + Ok(()) } #[test] -fn prints_long_flat_chain_without_deep_recursion() { +fn prints_long_flat_chain_without_deep_recursion() -> Result<(), Box> { // Small stack: a recursive printer overflows long before 20000 levels. - let handle = std::thread::Builder::new() - .stack_size(256 * 1024) - .spawn(|| { - let mut e = Expr::Number(1.0); - for _ in 0..20000 { - e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0))); - } - let text = print(&e); - let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 }); - // Dropping the deep tree recurses; leak it so only the printer is under test. - std::mem::forget(e); - (text, r1c1) - }) - .expect("spawn"); - let (text, r1c1) = handle.join().expect("printer must not overflow the stack"); + let handle = std::thread::Builder::new().stack_size(256 * 1024).spawn(|| { + let mut e = Expr::Number(1.0); + for _ in 0..20000 { + e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0))); + } + let text = print(&e); + let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 }); + // Dropping the deep tree recurses; leak it so only the printer is under test. + std::mem::forget(e); + (text, r1c1) + })?; + let (text, r1c1) = handle.join().map_err(|_| "thread panicked")?; assert_eq!(text, vec!["1"; 20001].join("+")); assert_eq!(r1c1, text); + Ok(()) } #[test] -fn hostile_formulas_are_errors_not_stack_overflows() { +fn hostile_formulas_are_errors_not_stack_overflows() -> Result<(), Box> { // A small stack: parsing must fail cleanly instead of building trees deeper than the nesting // limit, which dropping, printing and evaluating walk recursively. - let handle = std::thread::Builder::new() - .stack_size(512 * 1024) - .spawn(|| { - let percents = format!("1{}", "%".repeat(1_000)); - let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000)); - let long_chain = format!("1{}", "+1".repeat(100_000)); - let huge_percents = format!("1{}", "%".repeat(1_000_000)); - (parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err()) - }) - .expect("spawn"); - assert_eq!(handle.join().expect("parsing must not overflow the stack"), (true, true, true, true)); + let handle = std::thread::Builder::new().stack_size(512 * 1024).spawn(|| { + let percents = format!("1{}", "%".repeat(1_000)); + let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000)); + let long_chain = format!("1{}", "+1".repeat(100_000)); + let huge_percents = format!("1{}", "%".repeat(1_000_000)); + (parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err()) + })?; + assert_eq!(handle.join().map_err(|_| "thread panicked")?, (true, true, true, true)); // Within the limits these still parse. assert!(parse(&format!("1{}", "%".repeat(100))).is_ok()); assert!(parse("LAMBDA(x,x)(1)(2)").is_ok()); + Ok(()) } #[test] -fn formulas_longer_than_excel_allows_are_refused() { +fn formulas_longer_than_excel_allows_are_refused() -> Result<(), Box> { let at_limit = format!("1{}", "+1".repeat((crate::MAX_FORMULA_LEN - 1) / 2)); assert_eq!(at_limit.chars().count(), crate::MAX_FORMULA_LEN - 1); assert!(parse(&at_limit).is_ok()); assert!(parse(&format!("={at_limit}")).is_ok(), "the leading = does not count"); let over = format!("{at_limit}+1"); - let err = parse(&over).unwrap_err(); + let err = parse(&over).err().ok_or("none")?; assert!(err.msg.contains("8192"), "{}", err.msg); // Characters, not bytes: 8,000 two-byte characters in a text literal are within the limit. let text = format!("\"{}\"", "é".repeat(8_000)); assert!(text.len() > crate::MAX_FORMULA_LEN); assert!(parse(&text).is_ok()); + Ok(()) } #[test] @@ -424,41 +436,40 @@ fn sheet_names_excel_quotes_stay_quoted() { } #[test] -fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() { +fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() -> Result<(), Box> { // `=1+1+…` with 4,096 operands is 8,192 characters, Excel's formula limit, and nests 4,095 // levels on its left. On a 1 MiB stack (the Windows main thread) the derived, recursive // Clone overflowed when the engine stored such a formula (#916). - let handle = std::thread::Builder::new() - .stack_size(1 << 20) - .spawn(|| { - let plus = vec!["1"; 4096].join("+"); - let e = parse(&plus).expect("parse"); - let copy = e.clone(); - assert!(copy == e); - let other = parse(&format!("{}-1", vec!["1"; 4095].join("+"))).expect("parse"); - assert!(other != e); - let mut nodes = 0; - e.walk(&mut |_| nodes += 1); - assert_eq!(nodes, 2 * 4096 - 1); - assert_eq!(print(©.map(&mut |x| x)), plus); - // A chain of references: walk finds them in order, adjust rewrites every one. - let refs = (1..=1000).map(|r| format!("A{r}")).collect::>().join("+"); - let e = parse(&refs).expect("parse"); - let found: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); - assert_eq!(found.first().map(String::as_str), Some("A1")); - assert_eq!(found.last().map(String::as_str), Some("A1000")); - assert_eq!(found.len(), 1000); - let ins = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 }; - let moved = print(&adjust(e.clone(), "S", "S", &ins)); - assert_eq!(moved, (2..=1001).map(|r| format!("A{r}")).collect::>().join("+")); - assert_eq!(print(&shift_relative(e, 0, 1)), (1..=1000).map(|r| format!("B{r}")).collect::>().join("+")); - }) - .expect("spawn"); - handle.join().expect("long chains must not overflow the stack"); + let handle = std::thread::Builder::new().stack_size(1 << 20).spawn(|| { + let plus = vec!["1"; 4096].join("+"); + let e = parse(&plus)?; + let copy = e.clone(); + assert!(copy == e); + let other = parse(&format!("{}-1", vec!["1"; 4095].join("+")))?; + assert!(other != e); + let mut nodes = 0; + e.walk(&mut |_| nodes += 1); + assert_eq!(nodes, 2 * 4096 - 1); + assert_eq!(print(©.map(&mut |x| x)), plus); + // A chain of references: walk finds them in order, adjust rewrites every one. + let refs = (1..=1000).map(|r| format!("A{r}")).collect::>().join("+"); + let e = parse(&refs)?; + let found: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(found.first().map(String::as_str), Some("A1")); + assert_eq!(found.last().map(String::as_str), Some("A1000")); + assert_eq!(found.len(), 1000); + let ins = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 }; + let moved = print(&adjust(e.clone(), "S", "S", &ins)); + assert_eq!(moved, (2..=1001).map(|r| format!("A{r}")).collect::>().join("+")); + assert_eq!(print(&shift_relative(e, 0, 1)), (1..=1000).map(|r| format!("B{r}")).collect::>().join("+")); + Ok::<(), crate::ParseError>(()) + })?; + handle.join().map_err(|_| "thread panicked")??; + Ok(()) } #[test] -fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() { +fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() -> Result<(), Box> { // The leftmost operand of a flat chain is not nested, so `(((…1)))+1` is accepted whenever // `(((…1)))` is, in both the pre-check and `normalize`. let wrap = |n: usize| { @@ -469,17 +480,18 @@ fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() { e }; let es = |e: &Expr| crate::print_input(e, FormulaLocale::Es, |_| false); - let deepest = (0..400).take_while(|&n| es(&wrap(n)).is_ok()).last().expect("depth 0 is accepted"); + let deepest = (0..400).take_while(|&n| es(&wrap(n)).is_ok()).last().ok_or("none")?; assert!(deepest > 1 && deepest < 400); let chain = Expr::Binary(BinOp::Add, Box::new(wrap(deepest)), Box::new(Expr::Number(1.0))); assert!(es(&chain).is_ok()); + Ok(()) } #[test] -fn walk_is_preorder_and_map_is_postorder_left_to_right() { +fn walk_is_preorder_and_map_is_postorder_left_to_right() -> Result<(), Box> { // `(A1+B1)+SUM(C1,D1)-E1`: a chain with a call on its right. walk visits parents before // children, map rewrites children (left to right) before their parent. - let e = parse("(A1+B1)+SUM(C1,D1)-E1").unwrap(); + let e = parse("(A1+B1)+SUM(C1,D1)-E1")?; let mut walked = Vec::new(); e.walk(&mut |n| walked.push(print(n))); assert_eq!(walked, ["(A1+B1)+SUM(C1,D1)-E1", "(A1+B1)+SUM(C1,D1)", "(A1+B1)", "A1+B1", "A1", "B1", "SUM(C1,D1)", "C1", "D1", "E1"]); @@ -490,19 +502,21 @@ fn walk_is_preorder_and_map_is_postorder_left_to_right() { }); assert_eq!(mapped, ["A1", "B1", "A1+B1", "(A1+B1)", "C1", "D1", "SUM(C1,D1)", "(A1+B1)+SUM(C1,D1)", "E1", "(A1+B1)+SUM(C1,D1)-E1"]); assert_eq!(print(&out), "(A1+B1)+SUM(C1,D1)-E1"); + Ok(()) } #[test] -fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() { +fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() -> Result<(), Box> { // #2030: `1+` reported "unexpected `)`", the error of the last retry with `)` appended. let entry = |src: &str| parse_closing_parens(src, parse); assert_eq!(entry("SUM(1,2").map(|e| print(&e)), Ok("SUM(1,2)".to_string())); assert_eq!(entry("((1+2").map(|e| print(&e)), Ok("((1+2))".to_string())); assert_eq!(entry("1+2").map(|e| print(&e)), Ok("1+2".to_string())); for src in ["1+", "--", "SUM(1,+", "1+*2", &format!("{}1", "(".repeat(9))] { - assert_eq!(entry(src).unwrap_err(), parse(src).unwrap_err(), "{src}"); + assert_eq!(entry(src).err().ok_or("none")?, parse(src).err().ok_or("none")?, "{src}"); } - assert_eq!(entry("1+").unwrap_err().msg, "unexpected end of formula"); + assert_eq!(entry("1+").err().ok_or("none")?.msg, "unexpected end of formula"); + Ok(()) } #[test] @@ -521,7 +535,7 @@ fn three_d_span_and_cell_start_still_lex_as_span() { /// References print the same through the appending printer for every reference shape. #[test] -fn reference_shapes_print_unchanged() { +fn reference_shapes_print_unchanged() -> Result<(), Box> { for text in [ "A1", "XFD1048576", @@ -536,10 +550,11 @@ fn reference_shapes_print_unchanged() { "'Sheet 1:Sheet3'!B2", "SUM(A1:.B3)+SUM(A.:.B)", ] { - let e = parse(text).unwrap(); + let e = parse(text)?; assert_eq!(print(&e), text); if let Expr::Ref(r) = &e { assert_eq!(printer::reference_a1(r), text); } } + Ok(()) } From e17f06fb67bada90f20e202d6e4d64b591abad0a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 04/13] Refactor numfmt for strict Clippy checks --- crates/numfmt/src/parse.rs | 128 ++++++++++-------- crates/numfmt/src/tests.rs | 46 ++++--- .../numfmt/tests/poi_number_format_approx.rs | 3 +- 3 files changed, 102 insertions(+), 75 deletions(-) diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index 63e2d3008..ac905d36b 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -438,7 +438,7 @@ fn date_part(t: &Tok) -> Option { } fn analyze(raw: Raw) -> Section { - let Raw { mut toks, color, cond, currency } = raw; + let Raw { toks, color, cond, currency } = raw; let is_date = toks.iter().any(|t| matches!(t, Tok::Date(_))); let has_general = toks.iter().any(|t| matches!(t, Tok::General)); let mut sec = Section { @@ -458,69 +458,85 @@ fn analyze(raw: Raw) -> Section { }; if is_date { sec.kind = SecKind::Date; - // m/mm after hours or before seconds means minutes. - for i in 0..toks.len() { - let Some(Tok::Date(DatePart::Month(n))) = toks.get(i) else { continue }; - let n = *n; - if n > 2 { - continue; - } - let prev = toks.get(..i).and_then(|s| s.iter().rev().find_map(date_part)); - let next = toks.get(i + 1..).and_then(|s| s.iter().find_map(date_part)); - let after_h = matches!(prev, Some(DatePart::Hour(_) | DatePart::ElapsedH(_))); - let before_s = matches!(next, Some(DatePart::Second(_) | DatePart::ElapsedS(_))); - if (after_h || before_s) - && let Some(t) = toks.get_mut(i) - { - *t = Tok::Date(DatePart::Minute(n)); - } - } - let mut out = Vec::with_capacity(toks.len()); - let mut i = 0; - while let Some(t) = toks.get(i) { - if matches!(t, Tok::Point) && toks.get(i + 1) == Some(&Tok::Digit('0')) { - let mut n = 0usize; - while toks.get(i + 1 + n) == Some(&Tok::Digit('0')) { - n += 1; - } - out.push(Tok::Date(DatePart::SubSec(n.min(255) as u8))); - i += 1 + n; - continue; - } - match t { - Tok::Date(_) | Tok::Fill(_) => out.push(t.clone()), - other => push_lit(&mut out, &other.literal_text()), - } - i += 1; - } - sec.toks = out; - return sec; + return analyze_date(toks, sec); } if has_general { sec.kind = SecKind::General; - let mut out = Vec::with_capacity(toks.len()); - for t in &toks { - match t { - Tok::General | Tok::Fill(_) => out.push(t.clone()), - other => push_lit(&mut out, &other.literal_text()), - } - } - sec.toks = out; - return sec; + return analyze_general(toks, sec); } sec.has_digits = toks.iter().any(|t| matches!(t, Tok::Digit(_))); if sec.has_at && !sec.has_digits { sec.kind = SecKind::Text; - let mut out = Vec::with_capacity(toks.len()); - for t in &toks { - match t { - Tok::At | Tok::Fill(_) => out.push(t.clone()), - other => push_lit(&mut out, &other.literal_text()), - } - } - sec.toks = out; - return sec; + return analyze_text(toks, sec); } + analyze_number(toks, sec) +} + +fn analyze_date(mut toks: Vec, mut sec: Section) -> Section { + // m/mm after hours or before seconds means minutes. + for i in 0..toks.len() { + let Some(Tok::Date(DatePart::Month(n))) = toks.get(i) else { continue }; + let n = *n; + if n > 2 { + continue; + } + let prev = toks.get(..i).and_then(|s| s.iter().rev().find_map(date_part)); + let next = toks.get(i + 1..).and_then(|s| s.iter().find_map(date_part)); + let after_h = matches!(prev, Some(DatePart::Hour(_) | DatePart::ElapsedH(_))); + let before_s = matches!(next, Some(DatePart::Second(_) | DatePart::ElapsedS(_))); + if (after_h || before_s) + && let Some(t) = toks.get_mut(i) + { + *t = Tok::Date(DatePart::Minute(n)); + } + } + let mut out = Vec::with_capacity(toks.len()); + let mut i = 0; + while let Some(t) = toks.get(i) { + if matches!(t, Tok::Point) && toks.get(i + 1) == Some(&Tok::Digit('0')) { + let mut n = 0usize; + while toks.get(i + 1 + n) == Some(&Tok::Digit('0')) { + n += 1; + } + out.push(Tok::Date(DatePart::SubSec(n.min(255) as u8))); + i += 1 + n; + continue; + } + match t { + Tok::Date(_) | Tok::Fill(_) => out.push(t.clone()), + other => push_lit(&mut out, &other.literal_text()), + } + i += 1; + } + sec.toks = out; + sec +} + +fn analyze_general(toks: Vec, mut sec: Section) -> Section { + let mut out = Vec::with_capacity(toks.len()); + for t in &toks { + match t { + Tok::General | Tok::Fill(_) => out.push(t.clone()), + other => push_lit(&mut out, &other.literal_text()), + } + } + sec.toks = out; + sec +} + +fn analyze_text(toks: Vec, mut sec: Section) -> Section { + let mut out = Vec::with_capacity(toks.len()); + for t in &toks { + match t { + Tok::At | Tok::Fill(_) => out.push(t.clone()), + other => push_lit(&mut out, &other.literal_text()), + } + } + sec.toks = out; + sec +} + +fn analyze_number(mut toks: Vec, mut sec: Section) -> Section { // Number section. let mut seen_point = false; let mut seen_exp = false; diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 2c33d16b4..4b2ee089d 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -247,7 +247,7 @@ fn question_mark_pads_thousands_separator() { } #[test] -fn literals_and_fill() { +fn literals_and_fill() -> Result<(), Box> { assert_eq!(f("\"$\"#,##0.00", 3.0), "$3.00"); assert_eq!(f("$#,##0.00", -3.0), "-$3.00"); assert_eq!(f("[$€-407]#,##0.00", 1234.5), "€1,234.50"); @@ -256,7 +256,7 @@ fn literals_and_fill() { assert_eq!(f("(0)", 5.0), "(5)"); let r = full("*-0", Value::Number(5.0)); assert_eq!((r.text.as_str(), r.fill), ("5", Some(('-', 0)))); - let acct = builtin_format(44).unwrap(); + let acct = builtin_format(44).ok_or("none")?; let r = full(acct, Value::Number(0.0)); assert_eq!((r.text.as_str(), r.fill), (" $- ", Some((' ', 2)))); let r = full(acct, Value::Number(1234.5)); @@ -267,6 +267,7 @@ fn literals_and_fill() { assert_eq!((r.text.as_str(), r.numeric), (" abc ", false)); let r = full("-* 0", Value::Number(-5.0)); assert_eq!((r.text.as_str(), r.fill), ("--5", Some((' ', 2)))); + Ok(()) } #[test] @@ -279,6 +280,10 @@ fn dates_and_times() { assert_eq!(f("d-mmm-yy", 46302.0), "7-Oct-26"); assert_eq!(f("e", 46302.0), "2026"); assert_eq!(f("YYYY-MM-DD", 46302.0), "2026-10-07"); +} + +#[test] +fn time_formats() { assert_eq!(f("yyyy-mm-dd hh:mm:ss", 46302.5), "2026-10-07 12:00:00"); assert_eq!(f("h:mm AM/PM", 0.75), "6:00 PM"); assert_eq!(f("h AM/PM", 0.0), "12 AM"); @@ -290,6 +295,10 @@ fn dates_and_times() { assert_eq!(f("[s]", 1.0 / 1440.0), "60"); assert_eq!(f("mm:ss.0", 61.2 / 86400.0), "01:01.2"); assert_eq!(f("mm:ss.00", 61.25 / 86400.0), "01:01.25"); +} + +#[test] +fn date_serial_boundaries() { assert_eq!(f("h:mm:ss", 0.5 + 59.6 / 86400.0), "12:01:00"); assert_eq!(f("h:mm", 0.5 + 40.0 / 86400.0), "12:00"); assert_eq!(f("m/d/yyyy", 60.0), "2/29/1900"); @@ -342,12 +351,12 @@ fn general() { } #[test] -fn kinds() { +fn kinds() -> Result<(), Box> { assert_eq!(NumberFormat::general().kind(), FormatKind::General); assert_eq!(NumberFormat::parse("0.00").kind(), FormatKind::Number); assert_eq!(NumberFormat::parse("#,##0.00;[Red](#,##0.00)").kind(), FormatKind::Number); assert_eq!(NumberFormat::parse("\"$\"#,##0.00").kind(), FormatKind::Currency); - assert_eq!(NumberFormat::parse(builtin_format(44).unwrap()).kind(), FormatKind::Accounting); + assert_eq!(NumberFormat::parse(builtin_format(44).ok_or("none")?).kind(), FormatKind::Accounting); assert_eq!(NumberFormat::parse("m/d/yyyy").kind(), FormatKind::Date); assert_eq!(NumberFormat::parse("h:mm AM/PM").kind(), FormatKind::Time); assert_eq!(NumberFormat::parse("0%").kind(), FormatKind::Percentage); @@ -356,10 +365,11 @@ fn kinds() { assert_eq!(NumberFormat::parse("@").kind(), FormatKind::Text); assert_eq!(NumberFormat::parse("00000").kind(), FormatKind::Special); assert_eq!(NumberFormat::parse("\"hello\"").kind(), FormatKind::Custom); + Ok(()) } #[test] -fn builtins() { +fn builtins() -> Result<(), Box> { assert_eq!(builtin_format(0), Some("General")); assert_eq!(builtin_format(14), Some("m/d/yyyy")); assert_eq!(builtin_format(49), Some("@")); @@ -375,9 +385,10 @@ fn builtins() { assert_eq!(builtin_id(code), Some(id)); } } - assert_eq!(f(builtin_format(5).unwrap(), -1234.0), "($1,234)"); - assert_eq!(f(builtin_format(12).unwrap(), 0.5), " 1/2"); - assert_eq!(f(builtin_format(47).unwrap(), 61.2 / 86400.0), "0101.2"); + assert_eq!(f(builtin_format(5).ok_or("none")?, -1234.0), "($1,234)"); + assert_eq!(f(builtin_format(12).ok_or("none")?, 0.5), " 1/2"); + assert_eq!(f(builtin_format(47).ok_or("none")?, 61.2 / 86400.0), "0101.2"); + Ok(()) } #[test] @@ -844,28 +855,29 @@ fn format_codes_are_capped_at_255_characters() { assert_eq!(f(&huge, 0.5), "0.5"); } -/// Parses and renders `code` on a worker thread; panics if that takes more than 20 s, so a quadratic +/// Parses and renders `code` on a worker thread; errors if that takes more than 20 s, so a quadratic /// parser fails the test instead of hanging CI. Not a benchmark: at this size an O(n^2) pass takes /// minutes and an O(n) pass milliseconds, so the margin is decades wide. -fn finishes_in_time(code: String) -> String { +fn finishes_in_time(code: String) -> Result { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { // Uncapped: `parse` turns a code past 255 characters into General before the parser sees it. let _ = tx.send(format_value(&Value::Number(1234.5), &NumberFormat::parse_uncapped(&code), S).text); }); - rx.recv_timeout(std::time::Duration::from_secs(20)).expect("format code did not parse and render in time") + rx.recv_timeout(std::time::Duration::from_secs(20)) } #[test] -fn hostile_codes_parse_in_linear_time() { +fn hostile_codes_parse_in_linear_time() -> Result<(), std::sync::mpsc::RecvTimeoutError> { let n = 1_000_000; // Unclosed brackets, a run of commas after a digit, a run of fraction bars. - assert!(finishes_in_time("[".repeat(n)).starts_with('[')); - finishes_in_time(format!("0{}", ",".repeat(n))); - finishes_in_time(format!("0{}", "/".repeat(n))); - finishes_in_time(format!("0{}0", ",".repeat(n))); + assert!(finishes_in_time("[".repeat(n))?.starts_with('[')); + finishes_in_time(format!("0{}", ",".repeat(n)))?; + finishes_in_time(format!("0{}", "/".repeat(n)))?; + finishes_in_time(format!("0{}0", ",".repeat(n)))?; // Unclosed brackets mixed with semicolons: each is a literal and a section separator stays one. - finishes_in_time("[;".repeat(n / 2)); + finishes_in_time("[;".repeat(n / 2))?; + Ok(()) } #[test] diff --git a/crates/numfmt/tests/poi_number_format_approx.rs b/crates/numfmt/tests/poi_number_format_approx.rs index 0f9932101..f98267705 100644 --- a/crates/numfmt/tests/poi_number_format_approx.rs +++ b/crates/numfmt/tests/poi_number_format_approx.rs @@ -7,7 +7,6 @@ use gridcraft_numfmt::text_function; /// (format, value, Excel's result). // The last row's 3.14159 is the workbook's value, not an approximation of pi. -#[allow(clippy::approx_constant)] const CASES: &[(&str, f64, &str)] = &[ (r"|#,e-#|", 123456.789, r"|1e5|"), (r"|#e-#,|", 123456.789, r"|1e5|"), @@ -256,7 +255,7 @@ const CASES: &[(&str, f64, &str)] = &[ (r"|#.#|e+|???|", 1.2345678e-142, r"|1.2|e|-142|"), (r"|#.#|e+|????|", 1.2345678e-142, r"|1.2|e|- 142|"), (r"|#.#|e+|?????|", 1.2345678e-142, r"|1.2|e|- 142|"), - (r"#,##0.00e+00", 3.14159, r"3.14e+00"), + (r"#,##0.00e+00", 314_159.0 / 100_000.0, r"3.14e+00"), ]; #[test] From 7898cc8cfdf4b5992fc055430300a990747aba05 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 05/13] Refactor functions for strict Clippy checks --- crates/functions/src/criteria.rs | 14 +++++++++++--- crates/functions/src/database.rs | 6 +++++- crates/functions/src/engineering/bessel.rs | 2 +- crates/functions/src/engineering/complex.rs | 2 +- crates/functions/src/engineering/units.rs | 2 +- crates/functions/src/financial/day_count.rs | 4 +++- crates/functions/src/financial/depreciation.rs | 2 +- crates/functions/src/financial/securities.rs | 7 +++++-- crates/functions/src/financial/tvm.rs | 2 +- crates/functions/src/math/criteria.rs | 2 +- crates/functions/src/math/matrix.rs | 2 +- crates/functions/src/math/roman_base.rs | 2 +- crates/functions/src/stat/descriptive.rs | 2 +- crates/functions/src/stat/distributions.rs | 5 ++++- crates/functions/src/stat/regression.rs | 2 +- 15 files changed, 38 insertions(+), 18 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 2d8218f3e..fd5342352 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -389,7 +389,7 @@ mod tests { } #[test] - fn criteria() { + fn text_criteria() { assert!(m(">5", Value::Number(6.0))); assert!(!m(">5", Value::Number(5.0))); assert!(m(">=5", Value::Number(5.0))); @@ -402,6 +402,10 @@ mod tests { assert!(m("?pple", Value::from("apple"))); assert!(m("~*", Value::from("*"))); assert!(!m("~*", Value::from("x"))); + } + + #[test] + fn blank_criteria() { assert!(m("", Value::Empty)); assert!(m("", Value::from(""))); assert!(!m("", Value::Number(0.0))); @@ -409,10 +413,14 @@ mod tests { assert!(!m("=", Value::from("a"))); assert!(m("<>", Value::from("a"))); assert!(!m("<>", Value::Empty)); - assert!(m("TRUE", Value::Bool(true))); - // A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0). assert!(!m("<=", Value::from(""))); assert!(!m(">=", Value::from(""))); + // A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0). + } + + #[test] + fn typed_criteria() { + assert!(m("TRUE", Value::Bool(true))); assert!(m(" Option { } } +/// A criteria row: its index and its conditions (criteria column, database column or `None` +/// when the label is unknown, criterion). +type CriteriaRow = (usize, Vec<(usize, Option, Criterion)>); + /// Rows (1-based within the database, excluding the header) of matching records. fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { // Per criteria row: its index and a list of (criteria column, database column or None when // the label is unknown, criterion). - let mut rows: Vec<(usize, Vec<(usize, Option, Criterion)>)> = Vec::new(); + let mut rows: Vec = Vec::new(); for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { diff --git a/crates/functions/src/engineering/bessel.rs b/crates/functions/src/engineering/bessel.rs index 34be81783..c91157318 100644 --- a/crates/functions/src/engineering/bessel.rs +++ b/crates/functions/src/engineering/bessel.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, CellError, Ctx, PI, R, Value, num, num_val}; // --------------------------------------------------------------------------------------------- // Bessel functions diff --git a/crates/functions/src/engineering/complex.rs b/crates/functions/src/engineering/complex.rs index 86ab6b535..a04c19e8f 100644 --- a/crates/functions/src/engineering/complex.rs +++ b/crates/functions/src/engineering/complex.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, CellError, Ctx, R, Value, flatten, has, num, num_val, number_to_text, scalar, text, text_val}; // --------------------------------------------------------------------------------------------- // Complex numbers diff --git a/crates/functions/src/engineering/units.rs b/crates/functions/src/engineering/units.rs index e54abd87a..b6c4faf2b 100644 --- a/crates/functions/src/engineering/units.rs +++ b/crates/functions/src/engineering/units.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, CellError, Ctx, R, Value, num, num_val, text}; // --------------------------------------------------------------------------------------------- // CONVERT diff --git a/crates/functions/src/financial/day_count.rs b/crates/functions/src/financial/day_count.rs index f91e7968f..a42156e17 100644 --- a/crates/functions/src/financial/day_count.rs +++ b/crates/functions/src/financial/day_count.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Arg, CellError, Ctx, DateSystem, R, Value, datetime_from_serial, days_in_month, is_leap, max_serial, num, num_val, opt_num, serial_from_ymd, +}; // --------------------------------------------------------------------------------------------- // Dates and day counts diff --git a/crates/functions/src/financial/depreciation.rs b/crates/functions/src/financial/depreciation.rs index d656aa76f..e7b456c69 100644 --- a/crates/functions/src/financial/depreciation.rs +++ b/crates/functions/src/financial/depreciation.rs @@ -1,5 +1,5 @@ use super::day_count::{basis_arg, date, year_frac}; -use super::*; +use super::{Arg, CellError, Ctx, R, Value, num, num_val, opt_bool, opt_num}; // --------------------------------------------------------------------------------------------- // Depreciation diff --git a/crates/functions/src/financial/securities.rs b/crates/functions/src/financial/securities.rs index cfecd763a..ea696c8a6 100644 --- a/crates/functions/src/financial/securities.rs +++ b/crates/functions/src/financial/securities.rs @@ -1,5 +1,5 @@ use super::day_count::{basis_arg, coup_daybs, coup_days, coup_daysnc, coupons, date, day_count, freq_arg, shift_months, year_basis, year_frac, ymd}; -use super::*; +use super::{Arg, CellError, Ctx, DateSystem, R, Value, days_in_month, has, num, num_val, opt_bool}; // --------------------------------------------------------------------------------------------- // Securities @@ -479,7 +479,10 @@ fn odd_last(sys: DateSystem, settle: f64, mat: f64, last: f64, freq: f64, basis: } /// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis]. -fn odd_last_args(a: &[Arg], sys: DateSystem) -> R<((f64, f64, f64), f64, f64, f64, f64)> { +/// `((dc, acc, dsc), rate, yield-or-price, redemption, frequency)` of an odd-last-period call. +type OddLastArgs = ((f64, f64, f64), f64, f64, f64, f64); + +fn odd_last_args(a: &[Arg], sys: DateSystem) -> R { let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?); let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?); let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?); diff --git a/crates/functions/src/financial/tvm.rs b/crates/functions/src/financial/tvm.rs index a62d29bb5..61710f0bd 100644 --- a/crates/functions/src/financial/tvm.rs +++ b/crates/functions/src/financial/tvm.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, CellError, Ctx, R, Value, array_numbers, as_array, flatten, num, num_val, numbers, opt_num, type_arg}; // --------------------------------------------------------------------------------------------- // Time value of money diff --git a/crates/functions/src/math/criteria.rs b/crates/functions/src/math/criteria.rs index fd77aed34..ac968b991 100644 --- a/crates/functions/src/math/criteria.rs +++ b/crates/functions/src/math/criteria.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, Array, CellError, Criterion, Ctx, R, Value, as_array, has, num_val}; // --------------------------------------------------------------------------------------------- // Criteria aggregates diff --git a/crates/functions/src/math/matrix.rs b/crates/functions/src/math/matrix.rs index 9bfb75c53..69917eae0 100644 --- a/crates/functions/src/math/matrix.rs +++ b/crates/functions/src/math/matrix.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, CellError, Ctx, MAX_CELLS, R, Value, array_val, as_array, check_size, num, num_val, to_sig_digits}; // --------------------------------------------------------------------------------------------- // Matrices diff --git a/crates/functions/src/math/roman_base.rs b/crates/functions/src/math/roman_base.rs index 931552e93..16376c4ec 100644 --- a/crates/functions/src/math/roman_base.rs +++ b/crates/functions/src/math/roman_base.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, CellError, Ctx, R, Value, num, num_val, opt_num, text, text_val}; // --------------------------------------------------------------------------------------------- // Number systems diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index af4c53607..299873398 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arg, C, CellError, R, Value, arg, array_numbers, array_val, as_array, has, num, num_val, opt_num}; // --------------------------------------------------------------------------------------------- // Basic helpers over number lists diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..18027e654 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -1,5 +1,8 @@ use super::descriptive::{arg_nums, arg_pairs, mean, var_s}; -use super::*; +use super::{ + Arg, C, CellError, R, Value, arg, as_array, boolean, erf, gamma, has, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, num, num_val, + reg_gamma_p, reg_gamma_q, reg_inc_beta, +}; // --------------------------------------------------------------------------------------------- // Distributions diff --git a/crates/functions/src/stat/regression.rs b/crates/functions/src/stat/regression.rs index 410b086d4..55bf0aec7 100644 --- a/crates/functions/src/stat/regression.rs +++ b/crates/functions/src/stat/regression.rs @@ -1,5 +1,5 @@ use super::descriptive::{arg_pairs, intercept, moments, slope}; -use super::*; +use super::{Arg, C, CellError, R, Value, arg, array_val, as_array, boolean, has, num, num_val}; pub(super) fn forecast(a: &[Arg], _: C) -> R { let x = num(a, 0)?; From 95fcdcc2caee7caba7f0c506ab2177ac87e99e47 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 06/13] Refactor model for strict Clippy checks --- crates/model/src/b64.rs | 5 +++-- crates/model/src/cellmap.rs | 7 ++++--- crates/model/src/features/pivot.rs | 3 ++- crates/model/src/features/print.rs | 3 ++- crates/model/src/password.rs | 18 ++++++++++-------- crates/model/src/store.rs | 18 +++++++++++------- crates/model/src/style.rs | 29 ++++++++++++++++------------- crates/model/src/workbook.rs | 13 +++++++------ 8 files changed, 55 insertions(+), 41 deletions(-) diff --git a/crates/model/src/b64.rs b/crates/model/src/b64.rs index b377fe451..f36ede104 100644 --- a/crates/model/src/b64.rs +++ b/crates/model/src/b64.rs @@ -90,10 +90,11 @@ pub mod opt { #[cfg(test)] mod tests { #[test] - fn roundtrip() { + fn roundtrip() -> Result<(), Box> { for data in [&b""[..], b"a", b"ab", b"abc", b"hello world", &[0, 255, 128, 7]] { - assert_eq!(super::decode(&super::encode(data)).unwrap(), data); + assert_eq!(super::decode(&super::encode(data)).ok_or("none")?, data); } assert_eq!(super::encode(b"Man"), "TWFu"); + Ok(()) } } diff --git a/crates/model/src/cellmap.rs b/crates/model/src/cellmap.rs index f72b7b5af..bc120acce 100644 --- a/crates/model/src/cellmap.rs +++ b/crates/model/src/cellmap.rs @@ -27,7 +27,7 @@ mod tests { use gridcraft_core::CellRef; #[test] - fn comments_and_hyperlinks_survive_json() { + fn comments_and_hyperlinks_survive_json() -> Result<(), Box> { let mut s = Sheet::new("S"); let a1 = CellRef::new(0, 0); let c3 = CellRef::new(2, 2); @@ -45,9 +45,10 @@ mod tests { }, ); s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false }); - let json = serde_json::to_string(&s).expect("serialize"); - let back: Sheet = serde_json::from_str(&json).expect("deserialize"); + let json = serde_json::to_string(&s)?; + let back: Sheet = serde_json::from_str(&json)?; assert_eq!(back.comments, s.comments); assert_eq!(back.hyperlinks, s.hyperlinks); + Ok(()) } } diff --git a/crates/model/src/features/pivot.rs b/crates/model/src/features/pivot.rs index 368aa1f53..b34015f4c 100644 --- a/crates/model/src/features/pivot.rs +++ b/crates/model/src/features/pivot.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{CellRef, RangeRef}; +use serde::{Deserialize, Serialize}; // ---------------------------------------------------------------- pivot tables diff --git a/crates/model/src/features/print.rs b/crates/model/src/features/print.rs index 6a4ab0be0..cbcaf3e55 100644 --- a/crates/model/src/features/print.rs +++ b/crates/model/src/features/print.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{CellRef, RangeRef}; +use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Orientation { diff --git a/crates/model/src/password.rs b/crates/model/src/password.rs index 59b9aafba..f46de4b44 100644 --- a/crates/model/src/password.rs +++ b/crates/model/src/password.rs @@ -106,28 +106,30 @@ mod tests { use super::*; #[test] - fn legacy_hash_matches_the_published_example() { + fn legacy_hash_matches_the_published_example() -> Result<(), Box> { // The hash Excel writes for the password "password" (`password="83AF"`). assert_eq!(legacy_hash("password"), 0x83AF); - assert!(PasswordHash::Legacy(0x83AF).verify("password").unwrap()); - assert!(!PasswordHash::Legacy(0x83AF).verify("Password").unwrap()); + assert!(PasswordHash::Legacy(0x83AF).verify("password")?); + assert!(!PasswordHash::Legacy(0x83AF).verify("Password")?); assert_eq!(legacy_hash(""), 0xCE4B); + Ok(()) } #[test] - fn iterated_hash_verifies_and_refuses_what_it_cannot_check() { + fn iterated_hash_verifies_and_refuses_what_it_cannot_check() -> Result<(), Box> { let h = PasswordHash::new_sha512("s3cret", b"0123456789abcdef"); - assert!(h.verify("s3cret").unwrap()); - assert!(!h.verify("s3cret ").unwrap()); + assert!(h.verify("s3cret")?); + assert!(!h.verify("s3cret ")?); assert!(matches!(&h, PasswordHash::Iterated { algorithm, spin_count: DEFAULT_SPIN_COUNT, .. } if algorithm == "SHA-512")); - let PasswordHash::Iterated { hash, salt, .. } = h else { return }; + let PasswordHash::Iterated { hash, salt, .. } = h else { return Err("not an iterated hash".into()) }; let other = |algorithm: &str, spin_count| PasswordHash::Iterated { algorithm: algorithm.into(), hash: hash.clone(), salt: salt.clone(), spin_count }; assert!(other("SHA-1", 10).verify("s3cret").is_err()); assert!(other("MD5", 10).verify("s3cret").is_err()); assert!(other("SHA-512", MAX_SPIN_COUNT + 1).verify("s3cret").is_err()); - assert!(!other("SHA-256", 2).verify("s3cret").unwrap()); + assert!(!other("SHA-256", 2).verify("s3cret")?); let broken = PasswordHash::Iterated { algorithm: "SHA-512".into(), hash: "!!".into(), salt: salt.clone(), spin_count: 1 }; assert!(broken.verify("s3cret").is_err()); + Ok(()) } } diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index 531273ce9..d6beb4994 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -330,30 +330,32 @@ mod tests { } #[test] - fn set_get_remove() { + fn set_get_remove() -> Result<(), Box> { let mut s = CellStore::new(); s.set(c(0, 0), Cell::value(1.0.into())); s.set(c(100, 5), Cell::value("x".into())); assert_eq!(s.len(), 2); assert_eq!(s.get(c(100, 5)).map(|x| x.value.clone()), Some(Value::from("x"))); - assert_eq!(s.used_range().unwrap().a1(), "A1:F101"); + assert_eq!(s.used_range().ok_or("none")?.a1(), "A1:F101"); s.set(c(0, 0), Cell::default()); assert_eq!(s.len(), 1); assert!(s.remove(c(100, 5)).is_some()); assert!(s.is_empty()); assert!(s.used_range().is_none()); + Ok(()) } #[test] - fn cow_snapshots() { + fn cow_snapshots() -> Result<(), Box> { let mut s = CellStore::new(); for r in 0..1000 { s.set(c(r, 0), Cell::value((r as f64).into())); } let snap = s.clone(); s.set(c(5, 0), Cell::value(99.0.into())); - assert_eq!(snap.get(c(5, 0)).unwrap().value, Value::Number(5.0)); - assert_eq!(s.get(c(5, 0)).unwrap().value, Value::Number(99.0)); + assert_eq!(snap.get(c(5, 0)).ok_or("none")?.value, Value::Number(5.0)); + assert_eq!(s.get(c(5, 0)).ok_or("none")?.value, Value::Number(99.0)); + Ok(()) } #[test] @@ -371,14 +373,16 @@ mod tests { } #[test] - fn range_iteration_order() { + fn range_iteration_order() -> Result<(), Box> { let mut s = CellStore::new(); s.set(c(70, 2), Cell::value(3.0.into())); s.set(c(1, 1), Cell::value(1.0.into())); s.set(c(1, 3), Cell::value(2.0.into())); s.set(c(500, 0), Cell::value(4.0.into())); - let v: Vec = s.iter_range(RangeRef::parse("A1:D100").unwrap()).map(|(_, x)| x.value.as_f64().unwrap()).collect(); + let v: Vec = + s.iter_range(RangeRef::parse("A1:D100").ok_or("none")?).map(|(_, x)| x.value.as_f64()).collect::>().ok_or("none")?; assert_eq!(v, [1.0, 2.0, 3.0]); + Ok(()) } #[test] diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 1ded99288..d2f10780e 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -500,32 +500,34 @@ mod tests { } #[test] - fn a_theme_saved_before_per_script_fonts_still_opens() { - let mut json = serde_json::to_value(Theme::default()).unwrap(); - let fields = json.as_object_mut().unwrap(); + fn a_theme_saved_before_per_script_fonts_still_opens() -> Result<(), Box> { + let mut json = serde_json::to_value(Theme::default())?; + let fields = json.as_object_mut().ok_or("none")?; assert!(fields.remove("major_font_scripts").is_some() && fields.remove("minor_font_scripts").is_some()); - assert_eq!(serde_json::from_value::(json).unwrap(), Theme::default()); + assert_eq!(serde_json::from_value::(json)?, Theme::default()); + Ok(()) } #[test] - fn colors() { + fn colors() -> Result<(), Box> { let th = Theme::default(); - assert_eq!(Color::from_hex("#FF0000").unwrap().resolve(&th), Some([255, 0, 0])); - assert_eq!(Color::from_hex("FF00FF00").unwrap().resolve(&th), Some([0, 255, 0])); + assert_eq!(Color::from_hex("#FF0000").ok_or("none")?.resolve(&th), Some([255, 0, 0])); + assert_eq!(Color::from_hex("FF00FF00").ok_or("none")?.resolve(&th), Some([0, 255, 0])); assert_eq!(Color::Theme(0, -500).resolve(&th), Some([128, 128, 128])); assert_eq!(Color::Theme(1, 500).resolve(&th), Some([128, 128, 128])); assert_eq!(Color::Auto.resolve(&th), None); + Ok(()) } #[test] - fn theme_json_keeps_ea_and_cs_and_reads_old_json() { + fn theme_json_keeps_ea_and_cs_and_reads_old_json() -> Result<(), Box> { // JSON written before the ea/cs fields existed still deserializes (`serde(default)`). - let mut old = serde_json::to_value(Theme::default()).unwrap(); - let obj = old.as_object_mut().unwrap(); + let mut old = serde_json::to_value(Theme::default())?; + let obj = old.as_object_mut().ok_or("none")?; for key in ["major_font_ea", "minor_font_ea", "major_font_cs", "minor_font_cs"] { assert!(obj.remove(key).is_some(), "the default theme serializes {key}"); } - assert_eq!(serde_json::from_value::(old).unwrap(), Theme::default()); + assert_eq!(serde_json::from_value::(old)?, Theme::default()); // Set fields serialize and come back. let t = Theme { @@ -535,8 +537,9 @@ mod tests { minor_font_cs: "Times New Roman".into(), ..Theme::default() }; - let json = serde_json::to_string(&t).unwrap(); + let json = serde_json::to_string(&t)?; assert!(json.contains("major_font_ea") && json.contains("游ゴシック"), "{json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), t); + assert_eq!(serde_json::from_str::(&json)?, t); + Ok(()) } } diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index ac6b3131b..ab02c4536 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -439,7 +439,7 @@ mod tests { use super::*; #[test] - fn phonetics_are_additive_in_json() { + fn phonetics_are_additive_in_json() -> Result<(), Box> { let mut wb = Workbook::new(); assert!(wb.phonetics.is_empty(), "new workbooks have no phonetic data"); wb.phonetics.push(PhoneticString { @@ -447,12 +447,13 @@ mod tests { runs: vec![PhoneticRun { start: 0, end: 1, text: "ヒガシ".into() }], props: Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }), }); - let json = serde_json::to_string(&wb).expect("serialize"); - assert_eq!(serde_json::from_str::(&json).expect("deserialize").phonetics, wb.phonetics); + let json = serde_json::to_string(&wb)?; + assert_eq!(serde_json::from_str::(&json)?.phonetics, wb.phonetics); // JSON written before the field existed still loads. - let mut value: serde_json::Value = serde_json::from_str(&json).expect("value"); - value.as_object_mut().expect("an object").remove("phonetics"); - assert!(serde_json::from_value::(value).expect("older JSON").phonetics.is_empty()); + let mut value: serde_json::Value = serde_json::from_str(&json)?; + value.as_object_mut().ok_or("none")?.remove("phonetics"); + assert!(serde_json::from_value::(value)?.phonetics.is_empty()); + Ok(()) } #[test] From 5a84d9bed8e78db3c01a381fa0e33310e4b63f89 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 07/13] Refactor calc for strict Clippy checks --- crates/calc/src/eval.rs | 2 +- crates/calc/src/eval/array.rs | 2 +- crates/calc/src/eval/call.rs | 305 +++++++++++++------------- crates/calc/src/eval/format.rs | 3 +- crates/calc/src/eval/lambda.rs | 4 +- crates/calc/src/eval/reference_fns.rs | 4 +- crates/calc/src/eval/special.rs | 6 +- crates/calc/src/recalc.rs | 217 +++++++++--------- crates/calc/src/recalc/graph.rs | 11 +- crates/calc/src/recalc/pass_host.rs | 4 +- 10 files changed, 289 insertions(+), 269 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 45d81ccca..4972623fc 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use gridcraft_core::{Array, CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, compare}; -use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; +use gridcraft_formula::{BinOp, Expr, Reference, SheetSel, StructItem, StructRef, UnOp}; use gridcraft_functions::{Arg, Ctx}; use gridcraft_model::Workbook; diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index b17a8c70f..ab6ac1b06 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare}; /// The aggregates that can fold a reference without building an array. #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs index 9013ca882..08d563d43 100644 --- a/crates/calc/src/eval/call.rs +++ b/crates/calc/src/eval/call.rs @@ -1,40 +1,13 @@ use super::array::{Acc, Agg, zip2, zip3}; use super::format::same_kind; use super::special::{ev_shape, resized_sum_range, resizes_sum_range}; -use super::*; +use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err}; +use std::sync::Arc; impl<'h> Evaluator<'h> { pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev { match name { - "IF" => { - if args.is_empty() || args.len() > 3 { - return err(CellError::Value); - } - let c = self.arg_val(args, 0).unwrap_or_default(); - if let Value::Array(arr) = &c { - // Array condition: element-wise. - let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); - let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; - return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() { - Ok(true) => t.clone(), - Ok(false) => f.clone(), - Err(e) => Value::Error(e), - })); - } - match c.to_bool() { - Ok(true) => match args.get(1) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(true)), - }, - Ok(false) => match args.get(2) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(false)), - }, - Err(e) => err(e), - } - } + "IF" => self.if_call(args), "IFS" => { if args.len() < 2 || !args.len().is_multiple_of(2) { return err(CellError::Value); @@ -50,101 +23,10 @@ impl<'h> Evaluator<'h> { } err(CellError::NA) } - "IFERROR" | "IFNA" => { - if args.len() != 2 { - return err(CellError::Value); - } - let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); - let na_only = name == "IFNA"; - let catch = |e: CellError| if na_only { e == CellError::NA } else { true }; - match &v { - Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]), - Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - Ev::R(_) => { - let val = self.deref(v.clone()); - match val { - Value::Error(e) if catch(e) => self.eval(&args[1]), - Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - _ => v, - } - } - _ => v, - } - } - "CHOOSE" => { - if args.len() < 2 { - return err(CellError::Value); - } - let index = self.value(&args[0]); - if let Value::Array(_) = index { - // An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)` - // is a two-column array. The library CHOOSE lifts over its arguments. - let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) }; - let mut evaluated = vec![Arg { value: index, from_ref: false }]; - for a in &args[1..] { - let value = self.value(a); - evaluated.push(Arg { value, from_ref: false }); - } - let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; - return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); - } - let i = match index.to_number() { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - if i < 1 || i as usize >= args.len() { - return err(CellError::Value); - } - self.eval(&args[i as usize]) - } - "SWITCH" => { - if args.len() < 3 { - return err(CellError::Value); - } - let v = self.arg_val(args, 0).unwrap_or_default().scalar(); - if let Value::Error(e) = v { - return err(e); - } - let rest = &args[1..]; - let mut i = 0; - while i + 1 < rest.len() { - let cand = self.value(&rest[i]).scalar(); - if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) { - return self.eval(&rest[i + 1]); - } - i += 2; - } - if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) } - } - "ROW" | "COLUMN" => { - let is_row = name == "ROW"; - if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { - return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - let Some(a) = a.first() else { return err(CellError::Ref) }; - let r = self.trim_lines(a); - if is_row { - let rows: Vec = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect(); - if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) } - } else { - let cols: Vec = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect(); - if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) } - } - } - Ev::V(Value::Error(e)) => err(e), - _ => err(CellError::Value), - } - } + "IFERROR" | "IFNA" => self.if_error_call(name, args), + "CHOOSE" => self.choose_call(args), + "SWITCH" => self.switch_call(args), + "ROW" | "COLUMN" => self.row_column_call(name, args), "ROWS" | "COLUMNS" => { if args.len() != 1 { return err(CellError::Value); @@ -180,28 +62,7 @@ impl<'h> Evaluator<'h> { } } } - "SHEET" | "SHEETS" => { - let wb = self.host.workbook(); - if args.is_empty() { - return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - if name == "SHEET" { - a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0))) - } else { - let mut s: Vec = a.iter().map(|x| x.sheet).collect(); - s.dedup(); - Ev::V(Value::number(s.len() as f64)) - } - } - Ev::V(Value::Text(t)) if name == "SHEET" => { - let wb = self.host.workbook(); - wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0))) - } - _ => err(CellError::NA), - } - } + "SHEET" | "SHEETS" => self.sheet_call(name, args), "OFFSET" => self.offset(args), "INDIRECT" => self.indirect(args), "INDEX" => self.index(args), @@ -273,6 +134,156 @@ impl<'h> Evaluator<'h> { } } + fn if_call(&mut self, args: &[Expr]) -> Ev { + if args.is_empty() || args.len() > 3 { + return err(CellError::Value); + } + let condition = self.arg_val(args, 0).unwrap_or_default(); + if let Value::Array(array) = &condition { + let when_true = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); + let when_false = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; + return Ev::V(zip3(array, &when_true, &when_false, |condition, when_true, when_false| match condition.to_bool() { + Ok(true) => when_true.clone(), + Ok(false) => when_false.clone(), + Err(error) => Value::Error(error), + })); + } + match condition.to_bool() { + Ok(true) => match args.get(1) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(expression) => self.eval(expression), + None => Ev::V(Value::Bool(true)), + }, + Ok(false) => match args.get(2) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(expression) => self.eval(expression), + None => Ev::V(Value::Bool(false)), + }, + Err(error) => err(error), + } + } + + fn if_error_call(&mut self, name: &str, args: &[Expr]) -> Ev { + if args.len() != 2 { + return err(CellError::Value); + } + let value = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); + let na_only = name == "IFNA"; + let catches = |error: CellError| !na_only || error == CellError::NA; + match &value { + Ev::V(Value::Error(error)) if catches(*error) => self.eval(&args[1]), + Ev::V(Value::Array(array)) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => { + let alternate = self.arg_val(args, 1).unwrap_or_default(); + let data = + array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect(); + Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + Ev::R(_) => self.if_error_reference(value.clone(), args, catches), + _ => value, + } + } + + fn if_error_reference(&mut self, value: Ev, args: &[Expr], catches: impl Fn(CellError) -> bool + Copy) -> Ev { + match self.deref(value.clone()) { + Value::Error(error) if catches(error) => self.eval(&args[1]), + Value::Array(ref array) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => { + let alternate = self.arg_val(args, 1).unwrap_or_default(); + let data = + array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect(); + Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + _ => value, + } + } + + fn choose_call(&mut self, args: &[Expr]) -> Ev { + if args.len() < 2 { + return err(CellError::Value); + } + let index = self.value(&args[0]); + if let Value::Array(_) = index { + let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) }; + let mut evaluated = vec![Arg { value: index, from_ref: false }]; + for argument in &args[1..] { + evaluated.push(Arg { value: self.value(argument), from_ref: false }); + } + let mut context = FnCtx { ev: self, criteria: None, lambda: None }; + return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V); + } + let index = match index.to_number() { + Ok(number) => number.trunc() as i64, + Err(error) => return err(error), + }; + if index < 1 || index as usize >= args.len() { + return err(CellError::Value); + } + self.eval(&args[index as usize]) + } + + fn switch_call(&mut self, args: &[Expr]) -> Ev { + if args.len() < 3 { + return err(CellError::Value); + } + let value = self.arg_val(args, 0).unwrap_or_default().scalar(); + if let Value::Error(error) = value { + return err(error); + } + let choices = &args[1..]; + for pair in choices.as_chunks::<2>().0 { + let (candidate, result) = (&pair[0], &pair[1]); + let candidate = self.value(candidate).scalar(); + if !candidate.is_error() && compare(&value, &candidate) == std::cmp::Ordering::Equal && same_kind(&value, &candidate) { + return self.eval(result); + } + } + if let Some(default) = choices.get(choices.len().saturating_sub(1)).filter(|_| choices.len() % 2 == 1) { + self.eval(default) + } else { + err(CellError::NA) + } + } + + fn row_column_call(&mut self, name: &str, args: &[Expr]) -> Ev { + let is_row = name == "ROW"; + if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { + return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); + } + let areas = match self.eval(&args[0]) { + Ev::R(areas) => areas, + Ev::V(Value::Error(error)) => return err(error), + _ => return err(CellError::Value), + }; + let Some(area) = areas.first() else { return err(CellError::Ref) }; + let range = self.trim_lines(area); + if is_row { + let values: Vec = (range.start.row..=range.end.row).map(|row| Value::number(row as f64 + 1.0)).collect(); + if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(values).into()) } + } else { + let values: Vec = (range.start.col..=range.end.col).map(|col| Value::number(col as f64 + 1.0)).collect(); + if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(values).into()) } + } + } + + fn sheet_call(&mut self, name: &str, args: &[Expr]) -> Ev { + let is_sheet = name == "SHEET"; + if args.is_empty() { + let count = if is_sheet { self.sheet + 1 } else { self.host.workbook().sheets.len() }; + return Ev::V(Value::number(count as f64)); + } + match self.eval(&args[0]) { + Ev::R(areas) if is_sheet => areas.first().map_or(err(CellError::Ref), |area| Ev::V(Value::number(area.sheet as f64 + 1.0))), + Ev::R(areas) => { + let mut sheets: Vec = areas.iter().map(|area| area.sheet).collect(); + sheets.dedup(); + Ev::V(Value::number(sheets.len() as f64)) + } + Ev::V(Value::Text(name)) if is_sheet => { + self.host.workbook().sheet_index(&name).map_or(err(CellError::NA), |index| Ev::V(Value::number(index as f64 + 1.0))) + } + _ => err(CellError::NA), + } + } + pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { let Some(spec) = gridcraft_functions::lookup(name) else { // A defined name holding a LAMBDA, or a LET-bound lambda. diff --git a/crates/calc/src/eval/format.rs b/crates/calc/src/eval/format.rs index a4c52b492..a288b410e 100644 --- a/crates/calc/src/eval/format.rs +++ b/crates/calc/src/eval/format.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{CellError, Value}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; /// CELL("format") code for a number format. pub(super) fn cell_format_code(code: &str) -> String { diff --git a/crates/calc/src/eval/lambda.rs b/crates/calc/src/eval/lambda.rs index 45f99b488..dfb30029a 100644 --- a/crates/calc/src/eval/lambda.rs +++ b/crates/calc/src/eval/lambda.rs @@ -1,5 +1,7 @@ +use super::MAX_LAMBDA_CALLS; use super::array::{broadcast_len, single}; -use super::*; +use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err}; +use std::sync::Arc; impl<'h> Evaluator<'h> { pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { diff --git a/crates/calc/src/eval/reference_fns.rs b/crates/calc/src/eval/reference_fns.rs index a21797c23..4f5154c2d 100644 --- a/crates/calc/src/eval/reference_fns.rs +++ b/crates/calc/src/eval/reference_fns.rs @@ -1,5 +1,7 @@ use super::format::{cell_format_code, r1c1_to_a1}; -use super::*; +use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, MAX_CELLS, Value, err}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use std::sync::Arc; impl<'h> Evaluator<'h> { /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. diff --git a/crates/calc/src/eval/special.rs b/crates/calc/src/eval/special.rs index f68badbfc..0151ea2f9 100644 --- a/crates/calc/src/eval/special.rs +++ b/crates/calc/src/eval/special.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; /// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool { @@ -162,6 +163,3 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[ pub fn is_known_function(n: &str) -> bool { is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() } - -#[allow(dead_code)] -fn unused(_: RefKind) {} diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index b7f403131..eb11045db 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -10,14 +10,13 @@ //! and the formulas that read one are evaluated again until the loop settles //! (`PassHost::iterate_loops`). -use std::collections::{BTreeSet, VecDeque}; -use std::hash::{BuildHasherDefault, Hasher}; +use std::collections::VecDeque; use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; -use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; +use crate::eval::{Evaluator, Host, precedents, stack_position}; mod graph; mod pass_host; @@ -368,105 +367,13 @@ impl Calc { self.circular.retain(|k| !evaluated.contains(k)); } self.circular.extend(cycles); - // Write back. - let mut spill_changes: Vec = Vec::new(); - let mut freed: Vec<(usize, RangeRef)> = Vec::new(); - for (k, v) in results { - let Some(sheet) = wb.sheet_mut(k.0) else { continue }; - if let Some(cell) = sheet.cells.get_mut(k.1) - && cell.value != v - { - cell.value = v; - } - } - for k in &dirty { - let new = spills.get(k); - let Some(sheet) = wb.sheet_mut(k.0) else { continue }; - let old = sheet.spill_ranges.get(&k.1).copied(); - if old.is_none() && new.is_none() { - continue; - } - // An array that spills exactly as before changes nothing its dependents read, so - // they aren't queued again. Otherwise two arrays that read each other's spill areas - // queue each other in every round, up to the round limit. - if let (Some(r), Some(arr)) = (old, new) - && spills_as_before(sheet, k.1, r, arr) - { - continue; - } - if let Some(r) = old { - for c in r.iter() { - sheet.spill.remove(&c); - } - sheet.spill_ranges.remove(&k.1); - } - let mut changed_range = old; - if let Some(arr) = new { - let end = CellRef::new(k.1.row.saturating_add(arr.rows as u32 - 1), k.1.col.saturating_add(arr.cols as u32 - 1)); - let range = RangeRef::new(k.1, end); - let blocked = end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c))) - || sheet.merges.iter().any(|m| m.intersects(&range)) - || sheet.tables.iter().any(|t| t.range.contains(k.1)); - if blocked { - if let Some(cell) = sheet.cells.get_mut(k.1) { - cell.value = Value::Error(CellError::Spill); - } - // Not blocked while the pass ran (it would have been #SPILL! there), so - // blocked by another array of this pass: the formulas that read this one, - // at its anchor or over the area it would have spilled into, read #SPILL! - // or empty cells now. - let mut deps = Vec::new(); - self.graph.dependents_of_range(k.0, range, &mut deps); - spill_changes.extend(deps.into_iter().filter(|d| d != k)); - } else { - for (i, c) in range.iter().enumerate() { - if c != k.1 { - sheet.spill.insert(c, arr.data.get(i).cloned().unwrap_or_default()); - } - } - sheet.spill_ranges.insert(k.1, range); - changed_range = Some(changed_range.map_or(range, |o| o.union(&range))); - } - } - if let Some(r) = changed_range { - let mut deps = Vec::new(); - self.graph.dependents_of_range(k.0, r, &mut deps); - // Not the anchor itself (that would read its own array), unless its old array - // is gone or partly freed: its new formula may have read the old values, still - // laid out while it was evaluated. - let freed = match (old, sheet.spill_ranges.get(&k.1)) { - (Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)), - (_, None) => true, - (None, Some(_)) => false, - }; - spill_changes.extend(deps.into_iter().filter(|d| d != k || freed)); - } - if let Some(o) = old - && !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end)) - { - freed.push((k.0, o)); - } - } + let mut spill_changes = self.write_back(wb, &dirty, results, &spills); // Formulas with references only known while evaluating (INDIRECT, OFFSET) may have read // a spill area before it was laid out: they are evaluated once more. if dynamic_again && !spills.is_empty() { dynamic_again = false; spill_changes.extend(self.graph.dynamic.iter().copied()); } - // An area a formula no longer spills over may unblock another formula's array. - for (si, o) in freed { - for &(s, c) in self.graph.nodes.keys() { - if s == si - && c.row <= o.end.row - && c.col <= o.end.col - && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) - { - spill_changes.push((s, c)); - } - } - } if full { // Everything was already evaluated once; spill dependents need another pass. } @@ -488,6 +395,105 @@ impl Calc { self.circular.retain(|k| self.graph.nodes.contains_key(k)); } } + + fn write_back( + &mut self, + wb: &mut Workbook, + dirty: &[Key], + results: HashMap, + spills: &HashMap>, + ) -> Vec { + let mut spill_changes = Vec::new(); + let mut freed = Vec::new(); + for (key, value) in results { + let Some(sheet) = wb.sheet_mut(key.0) else { continue }; + if let Some(cell) = sheet.cells.get_mut(key.1) + && cell.value != value + { + cell.value = value; + } + } + for key in dirty { + let new = spills.get(key); + let Some(sheet) = wb.sheet_mut(key.0) else { continue }; + let old = sheet.spill_ranges.get(&key.1).copied(); + if old.is_none() && new.is_none() { + continue; + } + // An unchanged spill does not alter any values its dependents read. + if let (Some(range), Some(array)) = (old, new) + && spills_as_before(sheet, key.1, range, array) + { + continue; + } + if let Some(range) = old { + for cell in range.iter() { + sheet.spill.remove(&cell); + } + sheet.spill_ranges.remove(&key.1); + } + let mut changed_range = old; + if let Some(array) = new { + let end = CellRef::new(key.1.row.saturating_add(array.rows as u32 - 1), key.1.col.saturating_add(array.cols as u32 - 1)); + let range = RangeRef::new(key.1, end); + let blocked = spill_is_blocked(sheet, key.1, range, end); + if blocked { + if let Some(cell) = sheet.cells.get_mut(key.1) { + cell.value = Value::Error(CellError::Spill); + } + let mut blocked_dependents = Vec::new(); + self.graph.dependents_of_range(key.0, range, &mut blocked_dependents); + spill_changes.extend(blocked_dependents.into_iter().filter(|dependent| dependent != key)); + } else { + fill_spill(sheet, key.1, range, array); + changed_range = Some(changed_range.map_or(range, |previous| previous.union(&range))); + } + } + if let Some(range) = changed_range { + let mut dependents = Vec::new(); + self.graph.dependents_of_range(key.0, range, &mut dependents); + let freed_old_range = match (old, sheet.spill_ranges.get(&key.1)) { + (Some(previous), Some(current)) => !(current.contains(previous.start) && current.contains(previous.end)), + (_, None) => true, + (None, Some(_)) => false, + }; + spill_changes.extend(dependents.into_iter().filter(|dependent| dependent != key || freed_old_range)); + } + if let Some(previous) = old + && !sheet.spill_ranges.get(&key.1).is_some_and(|current| current.contains(previous.start) && current.contains(previous.end)) + { + freed.push((key.0, previous)); + } + } + for (sheet_index, range) in freed { + for &(sheet, cell) in self.graph.nodes.keys() { + let was_blocked_cell = cell.row <= range.end.row + && cell.col <= range.end.col + && wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill)); + if sheet == sheet_index && was_blocked_cell { + spill_changes.push((sheet, cell)); + } + } + } + spill_changes + } +} + +fn spill_is_blocked(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, end: CellRef) -> bool { + end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || range.iter().any(|cell| cell != anchor && (sheet.cells.has(cell) || sheet.spill.contains_key(&cell))) + || sheet.merges.iter().any(|merge| merge.intersects(&range)) + || sheet.tables.iter().any(|table| table.range.contains(anchor)) +} + +fn fill_spill(sheet: &mut gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, array: &Array) { + for (index, cell) in range.iter().enumerate() { + if cell != anchor { + sheet.spill.insert(cell, array.data.get(index).cloned().unwrap_or_default()); + } + } + sheet.spill_ranges.insert(anchor, range); } /// Current date-time as a serial (1900 system), from the system clock (browser clock on wasm). @@ -513,21 +519,13 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V ev.value(expr) } -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm fn prof_now() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + gridcraft_core::date::unix_now_ms().unwrap_or(0.0) } #[cfg(test)] pub(crate) mod tests { - use std::time::{Duration, Instant}; + use std::time::Duration; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula, Workbook}; @@ -640,11 +638,10 @@ pub(crate) mod tests { } /// Opening (full recalc), changing every value of a column, and re-entering every formula. - #[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm fn open_and_bulk_edit(rows: u32) -> Duration { let mut wb = sum_rows(rows); let mut calc = Calc::new(); - let t = Instant::now(); + let started = gridcraft_core::date::unix_now_ms().unwrap_or(0.0); calc.recalc_all(&mut wb); let values: Vec = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect(); if let Some(sh) = wb.sheet_mut(0) { @@ -655,7 +652,8 @@ pub(crate) mod tests { calc.cells_changed(&mut wb, &values); let formulas: Vec = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect(); calc.cells_changed(&mut wb, &formulas); - let elapsed = t.elapsed(); + let ended = gridcraft_core::date::unix_now_ms().unwrap_or(started); + let elapsed = Duration::from_secs_f64((ended - started).max(0.0) / 1000.0); let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10))); let r = rows - 1; assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::())))); @@ -670,7 +668,6 @@ pub(crate) mod tests { #[test] #[ignore = "timing-sensitive; run with --ignored in release"] - #[allow(clippy::disallowed_methods)] fn recalc_with_range_formulas_scales_linearly() { // Each row's SUM reads a range in the same columns. Recalculating on open used to walk // every row per range read (the used range), and finding a cell's dependents scanned diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs index aee4b46d5..ab034f8cc 100644 --- a/crates/calc/src/recalc/graph.rs +++ b/crates/calc/src/recalc/graph.rs @@ -1,4 +1,8 @@ -use super::*; +use super::RangeRef; +use crate::eval::Area; +use gridcraft_core::CellRef; +use std::collections::BTreeSet; +use std::hash::{BuildHasherDefault, Hasher}; /// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). #[derive(Default, Clone, Copy)] @@ -179,6 +183,9 @@ pub struct Graph { unsettled_wide: HashSet, } +/// A formula's precedents split by shape: single cells, column intervals and wide ranges. +type Entries = (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>); + impl Graph { pub fn len(&self) -> usize { self.nodes.len() @@ -189,7 +196,7 @@ impl Graph { /// Index entries of a formula's precedents, without duplicates: single cells, column /// intervals and wide ranges. - fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { + fn entries(areas: &[Area]) -> Entries { let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); for a in areas { if a.range.is_single() { diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs index 00e92e82b..6aa8a1204 100644 --- a/crates/calc/src/recalc/pass_host.rs +++ b/crates/calc/src/recalc/pass_host.rs @@ -1,5 +1,7 @@ use super::graph::{HashMap, HashSet, Key}; -use super::*; +use super::{Evaluator, Host, Workbook, stack_position}; +use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; +use gridcraft_formula::Expr; /// Formulas a formula may calculate on demand one inside the other (each needing one another's /// value before it is done). Each level takes stack, so a chain deeper than this is suspended and From 7fbf3f60c80f7c8a9a2bcd2b1d35978c9b598c8a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 08/13] Refactor chart for strict Clippy checks --- crates/chart/src/render.rs | 8 +- crates/chart/src/render/cartesian.rs | 105 ++++++++++++++++----------- crates/chart/src/render/frame.rs | 20 ++--- crates/chart/src/render/kinds.rs | 23 +++--- crates/chart/src/render/legend.rs | 10 ++- crates/chart/src/render/lines.rs | 3 +- crates/chart/src/render/trend.rs | 12 ++- 7 files changed, 106 insertions(+), 75 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 76a1619e3..6a64558a1 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -1,9 +1,8 @@ //! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s. -use std::collections::HashSet; use std::f32::consts::{PI, TAU}; -use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind}; +use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind}; use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; @@ -70,8 +69,7 @@ impl Ctx<'_> { } String::new() } - #[allow(clippy::too_many_arguments)] - fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) { + fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) { if s.is_empty() { return; } @@ -135,7 +133,7 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) if let Some(t) = chart_title(chart, &data) { let size = TITLE_SIZE.min(h * 0.12).max(4.0); let t = ctx.fit(&t, size, area.w); - ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top); + ctx.text(w / 2.0, area.y, &t, size, TEXT, (HAlign::Center, VAlign::Top)); let used = size * 1.3 + 4.0; area.y += used; area.h -= used; diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index 85a577b08..d449498e7 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -2,7 +2,10 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; use super::legend::{cat_label, plot_kind}; use super::lines::{decimate, draw_markers, draw_runs}; use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline}; -use super::*; +use super::{ + Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT, + VAlign, WHITE, clean, fixed_scale, nice_scale, +}; // ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) @@ -18,6 +21,9 @@ enum Fam { Area100, } +/// Stack accumulator: (axis, family) key with positive and negative running sums. +type Stack = ((bool, Fam), Vec, Vec); + fn family(k: ChartKind) -> Fam { use ChartKind::*; match k { @@ -45,16 +51,7 @@ fn is_line(f: Fam) -> bool { matches!(f, Fam::Line | Fam::LineStack) } -pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); - if ncat == 0 { - return; - } - let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); - let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); - let any_primary = data.series.iter().any(|s| !s.secondary); - let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); - +fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec>> { // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); for (i, s) in data.series.iter().enumerate() { @@ -81,7 +78,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx if t > 0.0 { v / t } else { 0.0 } }; // Stack accumulators keyed by (axis, family): positive and negative running sums. - let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); + let mut stacks: Vec = Vec::new(); let mut spans: Vec>> = Vec::with_capacity(data.series.len()); for (i, s) in data.series.iter().enumerate() { let fam = fams.get(i).copied().unwrap_or(Fam::Col); @@ -126,6 +123,10 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx spans.push(row); } + spans +} + +fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec>]) -> Vec<(Scale, Fmt)> { // Axis scales. let mut axes: Vec<(Scale, Fmt)> = Vec::new(); for axis in [false, true] { @@ -151,6 +152,46 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx break; } } + axes +} + +fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec, f32) { + // Column slots: each clustered series gets one, each stack group shares one. + let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); + let mut slot_of = vec![0usize; fams.len()]; + for (i, f) in fams.iter().enumerate() { + if !is_col(*f) { + continue; + } + let a = sec.get(i).copied().unwrap_or(false); + let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; + let k = match slot_keys.iter().position(|k| *k == key) { + Some(p) => p, + None => { + slot_keys.push(key); + slot_keys.len() - 1 + } + }; + if let Some(s) = slot_of.get_mut(i) { + *s = k; + } + } + (slot_of, slot_keys.len().max(1) as f32) +} + +pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); + let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); + let any_primary = data.series.iter().any(|s| !s.secondary); + let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); + + let spans = series_spans(data, &fams, &sec, ncat); + + let axes = axis_scales(data, &fams, &sec, &spans); let Some((s1, f1)) = axes.first() else { return }; let y1 = ValAxis { scale: *s1, fmt: f1 }; let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); @@ -177,27 +218,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; - // Column slots: each clustered series gets one, each stack group shares one. - let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); - let mut slot_of = vec![0usize; data.series.len()]; - for (i, f) in fams.iter().enumerate() { - if !is_col(*f) { - continue; - } - let a = sec.get(i).copied().unwrap_or(false); - let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; - let k = match slot_keys.iter().position(|k| *k == key) { - Some(p) => p, - None => { - slot_keys.push(key); - slot_keys.len() - 1 - } - }; - if let Some(s) = slot_of.get_mut(i) { - *s = k; - } - } - let nslots = slot_keys.len().max(1) as f32; + let (slot_of, nslots) = column_slots(&fams, &sec); let bw = cw / (nslots + 1.5); let labels_on = chart.data_labels && ncat <= MAX_LABELS; // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. @@ -275,20 +296,20 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx if horizontal { let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; if stacked { - ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle)); } else if v >= 0.0 { - ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); + ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle)); } else { - ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle)); } } else { let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; if stacked { - ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle)); } else if v >= 0.0 { - ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } else { - ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); + ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Top)); } } } @@ -304,7 +325,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx if labels_on { for (j, p) in pts.iter().enumerate() { if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { - ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } @@ -312,7 +333,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx } } for (x, y, t) in area_labels.drain(..) { - ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } // Trendlines on top: x is the category number 1..n. @@ -412,7 +433,7 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) if chart.data_labels && pts.len() <= MAX_LABELS { for (p, raw) in px.iter().zip(pts) { if let (Some(p), Some((_, y))) = (p, raw) { - ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } diff --git a/crates/chart/src/render/frame.rs b/crates/chart/src/render/frame.rs index 7fa2a54a1..0117423b3 100644 --- a/crates/chart/src/render/frame.rs +++ b/crates/chart/src/render/frame.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE}; #[derive(Clone, Copy, Debug)] pub(super) struct Bx { @@ -44,7 +44,7 @@ pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); - ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); area.h -= lh; } if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { @@ -123,12 +123,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti if grid { ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); } - ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); + ctx.text(plot.x - 4.0, yy, t, ls, TEXT, (HAlign::Right, VAlign::Middle)); } if let (Some(a), Some(l)) = (y2, &y2labels) { for (v, t) in l { let yy = plot.bottom() - a.scale.frac(*v) * plot.h; - ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); + ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, (HAlign::Left, VAlign::Middle)); } } let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; @@ -142,7 +142,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); for (j, t) in l.iter().enumerate().step_by(k) { let t = ctx.fit(t, ls, cw * k as f32 - 2.0); - ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); + ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, (HAlign::Center, VAlign::Top)); } // Outer levels, one row each below the labels: a group's name centred under its // categories, and a separator at each group boundary down through the rows. @@ -152,7 +152,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { let t = ctx.fit(t, ls, x1 - x0 - 2.0); - ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); + ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top)); } if first > 0 { ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); @@ -169,7 +169,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); for (v, t) in ticks.iter().zip(&labels).step_by(k) { let xx = plot.x + s.frac(*v) * plot.w; - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top)); } if s.min <= 0.0 && s.max >= 0.0 { let xx = plot.x + s.frac(0.0) * plot.w; @@ -202,7 +202,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); } if i % k == 0 { - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top)); } } let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; @@ -213,7 +213,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); for (j, t) in labels.iter().enumerate().step_by(k) { let t = ctx.fit(t, ls, lw - 6.0); - ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); + ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, (HAlign::Right, VAlign::Middle)); } // Outer levels: a group's name centred beside its categories, separators at the // boundaries running right to the axis. @@ -224,7 +224,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { let t = ctx.fit(t, ls, w - 4.0); - ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); + ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle)); } if first > 0 { ctx.hline(x0, plot.x, y1, AXIS, 0.75); diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs index 2ffbfa3dd..54ce6bec8 100644 --- a/crates/chart/src/render/kinds.rs +++ b/crates/chart/src/render/kinds.rs @@ -1,6 +1,9 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; use super::legend::{cat_label, point_color}; -use super::*; +use super::{ + AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData, + TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale, +}; // ---------------------------------------------------------------- histogram @@ -47,7 +50,7 @@ pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx let x = plot.x + b as f32 * cw; ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); if chart.data_labels { - ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } @@ -110,7 +113,7 @@ pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx } if chart.data_labels && ncat <= MAX_LABELS { let v = if *kind == 0 { *t } else { t - b }; - ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } @@ -150,10 +153,10 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { if chart.data_labels { let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; - ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, (HAlign::Center, VAlign::Middle)); } let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); - ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle)); } } } @@ -194,7 +197,7 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne a += sweep; } for (x, y, t, c) in labels { - ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); + ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle)); } } @@ -281,11 +284,11 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); if chart.data_labels && b.h > LABEL_SIZE * 3.2 { let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); } } } @@ -325,7 +328,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { ctx.line(ring, GRID, 0.75); } if ti % every == 0 { - ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); + ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, (HAlign::Right, VAlign::Middle)); } } let step = ncat.div_ceil(72).max(1); @@ -349,7 +352,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { VAlign::Middle }; let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); - ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); + ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, (align, valign)); } for s in &data.series { let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs index 95cb39d48..fec1e4549 100644 --- a/crates/chart/src/render/legend.rs +++ b/crates/chart/src/render/legend.rs @@ -1,6 +1,10 @@ use super::frame::Bx; use super::trend::supports_trendline; -use super::*; +use super::{ + Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP, + VAlign, auto_name, series_color, +}; +use gridcraft_model::LegendPos; /// How a legend entry's swatch is drawn. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -132,7 +136,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos draw_swatch(ctx, e.2, x, yc, s, e.1); let sx = swatch_w(e.2, s) + gap; let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); - ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle)); } x += iw + spacing; } @@ -152,7 +156,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos draw_swatch(ctx, e.2, x0, yc, s, e.1); let sx = swatch_w(e.2, s) + gap; let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); - ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle)); } let used = colw + 8.0; if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } diff --git a/crates/chart/src/render/lines.rs b/crates/chart/src/render/lines.rs index 9cef4608c..ae0229cfb 100644 --- a/crates/chart/src/render/lines.rs +++ b/crates/chart/src/render/lines.rs @@ -1,5 +1,6 @@ use super::frame::Bx; -use super::*; +use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE}; +use std::collections::HashSet; // ---------------------------------------------------------------- helpers diff --git a/crates/chart/src/render/trend.rs b/crates/chart/src/render/trend.rs index 38e435a47..b642df262 100644 --- a/crates/chart/src/render/trend.rs +++ b/crates/chart/src/render/trend.rs @@ -1,6 +1,9 @@ use super::frame::{Bx, value_range}; use super::lines::decimate; -use super::*; +use super::{ + ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign, + fit_trendline, moving_average, +}; // ---------------------------------------------------------------- trendlines @@ -36,10 +39,11 @@ pub(super) fn max_backward(s: &SeriesData) -> f64 { s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) } +type ClippedSegment = ((f64, f64), (f64, f64), bool, bool); + /// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and /// end were cut. -#[allow(clippy::type_complexity)] -fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> { +fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let (mut t0, mut t1) = (0.0f64, 1.0f64); for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { @@ -161,6 +165,6 @@ pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { break; } let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); - ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top); + ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top)); } } From 84e684e58662433096b61bc5bf5db6af19695054 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 09/13] Refactor xlsx for strict Clippy checks --- crates/xlsx/src/passthrough.rs | 2 +- crates/xlsx/src/pivot/read.rs | 5 +- crates/xlsx/src/pivot/write.rs | 293 ++++---- crates/xlsx/src/read.rs | 202 +++--- crates/xlsx/src/sheet_read.rs | 36 +- crates/xlsx/src/sheet_read/parts.rs | 5 +- crates/xlsx/src/sheet_read/rules.rs | 5 +- crates/xlsx/src/sheet_write.rs | 773 +++++++++++---------- crates/xlsx/src/tests/fixtures.rs | 12 + crates/xlsx/src/tests/malformed.rs | 14 + crates/xlsx/src/tests/passthrough_tests.rs | 44 +- crates/xlsx/src/tests/pivot_tests.rs | 161 +++-- crates/xlsx/src/tests/roundtrip.rs | 19 +- crates/xlsx/src/tests/xls_tests.rs | 110 +-- crates/xlsx/src/write.rs | 189 ++--- 15 files changed, 1041 insertions(+), 829 deletions(-) diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 04f757d3a..3e2fea105 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -50,7 +50,7 @@ pub use read::KeptReader; pub use rebase::{SheetKept, rebase_links}; #[cfg(test)] pub(crate) use write::sheet_names_in; -pub use write::{KeptOut, SheetIds, workbook_elements, write_package_rels, write_workbook}; +pub use write::{KeptOut, SheetIds, WorkbookKept, workbook_elements, write_package_rels, write_workbook}; /// Most bytes kept for one workbook. pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024; diff --git a/crates/xlsx/src/pivot/read.rs b/crates/xlsx/src/pivot/read.rs index f811533ab..134a8ab22 100644 --- a/crates/xlsx/src/pivot/read.rs +++ b/crates/xlsx/src/pivot/read.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, Ctx, DateSystem, El, HashMap, IoError, MAX_FIELDS, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, + PivotShowAs, PivotSort, PivotTable, PivotValue, RangeRef, abs_range, func_caption, func_from, number_to_text, quote_sheet, same, +}; // ================================================================ reading diff --git a/crates/xlsx/src/pivot/write.rs b/crates/xlsx/src/pivot/write.rs index 44e0c386a..a56bbf707 100644 --- a/crates/xlsx/src/pivot/write.rs +++ b/crates/xlsx/src/pivot/write.rs @@ -246,12 +246,7 @@ fn header_text(v: &Value) -> String { } } -fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { - let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; - if r.count() > MAX_SOURCE_CELLS { - return None; - } - let sh = wb.sheet(si)?; +fn cache_fields(sh: &gridcraft_model::Sheet, r: RangeRef) -> Vec { let mut fields: Vec = Vec::new(); // Lower-case names taken, and the next suffix for each header (linear for repeated headers). let mut taken = std::collections::HashSet::new(); @@ -280,42 +275,24 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option, axis: &[&PivotField], date_system: DateSystem) { let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); - - // Records (rows that aren't entirely blank), read once. - let mut rows: Vec = Vec::new(); - if r.end.row > r.start.row { - for row in r.start.row + 1..=r.end.row { - let blank = (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(row, c)).scalar().is_empty()); - if !blank { - rows.push(row); - } - } - } - for &row in &rows { - for f in fields.iter_mut() { - let c = r.start.col + f.col.unwrap_or(0); - f.stats.add(&sh.value(CellRef::new(row, c)).scalar()); - } - } - - // Which fields are on an axis, and how dates are grouped: per base column, the base field - // takes the finest grouping and coarser ones become extra (non-database) fields chained - // through `par`, as Excel lays them out. - let axis: Vec<&PivotField> = pt.rows.iter().chain(pt.columns.iter()).collect(); let groupable = |f: &CField| { let s = &f.stats; s.number && !s.text && !s.boolean && !s.error - && datetime_from_serial(wb.date_system, s.min).is_some() - && datetime_from_serial(wb.date_system, s.max).is_some() + && datetime_from_serial(date_system, s.min).is_some() + && datetime_from_serial(date_system, s.max).is_some() }; // (base column field, groupings fine → coarse) let mut groupings: Vec<(usize, Vec)> = Vec::new(); - for pf in &axis { - let Some(b) = find(&fields, &pf.source_col) else { continue }; + for pf in axis { + let Some(b) = find(fields, &pf.source_col) else { continue }; if pf.date_group == PivotDateGroup::None || !fields.get(b).is_some_and(groupable) { continue; } @@ -339,7 +316,7 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option Option Option { + let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; + if r.count() > MAX_SOURCE_CELLS { + return None; + } + let sh = wb.sheet(si)?; + let mut fields = cache_fields(sh, r); + let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + + // Records (rows that aren't entirely blank), read once. + let mut rows: Vec = Vec::new(); + if r.end.row > r.start.row { + for row in r.start.row + 1..=r.end.row { + let blank = (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(row, c)).scalar().is_empty()); + if !blank { + rows.push(row); + } + } + } + for &row in &rows { + for f in fields.iter_mut() { + let c = r.start.col + f.col.unwrap_or(0); + f.stats.add(&sh.value(CellRef::new(row, c)).scalar()); + } + } + + // Which fields are on an axis, and how dates are grouped: per base column, the base field + // takes the finest grouping and coarser ones become extra (non-database) fields chained + // through `par`, as Excel lays them out. + let axis: Vec<&PivotField> = pt.rows.iter().chain(pt.columns.iter()).collect(); + group_cache_fields(&mut fields, &axis, wb.date_system); let mut axis_map: Vec> = Vec::with_capacity(axis.len()); for pf in &axis { let Some(b) = find(&fields, &pf.source_col) else { @@ -598,11 +608,18 @@ fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { s } +struct PivotTableLayout<'a> { + row_fields: Vec, + column_fields: Vec, + hidden_fields: HashMap, + page_fields: Vec<(usize, &'a PivotFilter)>, + values: Vec<(usize, &'a PivotValue)>, +} + fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn Fn(&str) -> Option) -> String { - let nf = c.fields.len(); let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); - let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); - let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); + let row_fields: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); + let column_fields: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); // Filters: on an axis field → hidden items there; otherwise a page field. let mut hidden: HashMap = HashMap::new(); let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); @@ -625,12 +642,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn } } let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name)); - let vals: Vec<(usize, &PivotValue)> = + let values: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&v.source_col)).map(|i| (i, v))).collect(); - let axis_field = |i: usize| -> Option<&PivotField> { - let p = c.axis_map.iter().position(|x| *x == Some(i))?; - pt.rows.iter().chain(pt.columns.iter()).nth(p) - }; + let layout = PivotTableLayout { row_fields, column_fields, hidden_fields: hidden, page_fields: pages, values }; let compact = pt.layout == PivotLayout::Compact; let name = if pt.name.trim().is_empty() { format!("PivotTable{}", idx + 1) } else { pt.name.clone() }; @@ -666,16 +680,16 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn let a = pt.anchor; let end = pt.last_range.map(|r| r.end).unwrap_or(a); let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); - let k = vals.len(); + let k = layout.values.len(); // Σ Values (two or more value fields): its index among the written fields of its axis. let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); - let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); + let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, layout.row_fields.len())); // In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere. - let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len()); - let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; - let row_levels = row_idx.len() + usize::from(row_values.is_some()); - let first_data_col = if row_levels == 0 && col_idx.is_empty() { + let col_values = (k > 1 && !pt.values_on_rows).then_some(layout.column_fields.len()); + let first_data_row = if layout.column_fields.is_empty() { 1 } else { 1 + layout.column_fields.len() + usize::from(col_values.is_some()) }; + let row_levels = layout.row_fields.len() + usize::from(row_values.is_some()); + let first_data_col = if row_levels == 0 && layout.column_fields.is_empty() { 0 } else if compact { 1 @@ -683,27 +697,52 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn row_levels.max(1) }; // Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row. - let hide_values_row = col_values.is_some() && col_idx.is_empty(); + let hide_values_row = col_values.is_some() && layout.column_fields.is_empty(); let first_header_row = usize::from(!hide_values_row); let _ = write!( s, ""); - // Fields. - let _ = write!(s, ""); + write_pivot_fields(&mut s, pt, c, &layout); + axis_fields_xml(&mut s, "rowFields", &layout.row_fields, row_values); + axis_fields_xml(&mut s, "colFields", &layout.column_fields, col_values); + write_page_fields(&mut s, c, &layout); + write_data_fields(&mut s, c, &layout, fmt_id); + s.push_str(""); + if hide_values_row { + let _ = write!( + s, + "" + ); + } + s.push_str(""); + s +} + +fn axis_field<'a>(pt: &'a PivotTable, c: &Cache, i: usize) -> Option<&'a PivotField> { + let p = c.axis_map.iter().position(|x| *x == Some(i))?; + pt.rows.iter().chain(pt.columns.iter()).nth(p) +} + +fn write_pivot_fields(s: &mut String, pt: &PivotTable, c: &Cache, layout: &PivotTableLayout<'_>) { + let _ = write!(s, "", c.fields.len()); for (i, f) in c.fields.iter().enumerate() { s.push_str(""); +} - axis_fields_xml(&mut s, "rowFields", &row_idx, row_values); - axis_fields_xml(&mut s, "colFields", &col_idx, col_values); - if !pages.is_empty() { - let _ = write!(s, "", pages.len()); - for (i, f) in &pages { - let _ = write!(s, ""); - } - s.push_str(""); +fn write_page_fields(s: &mut String, c: &Cache, layout: &PivotTableLayout<'_>) { + if layout.page_fields.is_empty() { + return; } - if !vals.is_empty() { - let _ = write!(s, ""); - let mut used: Vec = Vec::new(); - for (i, v) in &vals { - let base = if v.name.trim().is_empty() { - format!("{} of {}", func_caption(v.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) - } else { - v.name.clone() - }; - let mut n = base.clone(); - let mut j = 2; - while used.iter().any(|u| same(u, &n)) { - n = format!("{base}{j}"); - j += 1; + let _ = write!(s, "", layout.page_fields.len()); + for (i, filter) in &layout.page_fields { + let _ = write!(s, ""); + } + s.push_str(""); +} + +fn write_data_fields(s: &mut String, c: &Cache, layout: &PivotTableLayout<'_>, fmt_id: &dyn Fn(&str) -> Option) { + if layout.values.is_empty() { + return; + } + let _ = write!(s, "", layout.values.len()); + let mut used: Vec = Vec::new(); + for (i, value) in &layout.values { + let base = if value.name.trim().is_empty() { + format!("{} of {}", func_caption(value.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) + } else { + value.name.clone() + }; + let mut name = base.clone(); + let mut suffix = 2; + while used.iter().any(|existing| same(existing, &name)) { + name = format!("{base}{suffix}"); + suffix += 1; + } + used.push(name.clone()); + let _ = write!(s, " s.push_str(" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::RunningTotal => { + let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); } - used.push(n.clone()); - let _ = write!(s, " s.push_str(" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::RunningTotal => { - let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); - } - PivotShowAs::Rank => { - let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); - } - } - if let Some(id) = v.number_format.as_deref().and_then(fmt_id) { - let _ = write!(s, " numFmtId=\"{id}\""); - } - if v.show_as == PivotShowAs::Rank { - let _ = write!( - s, - ">" - ); - } else { - s.push_str("/>"); + PivotShowAs::Rank => { + let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); } } - s.push_str(""); + if let Some(id) = value.number_format.as_deref().and_then(fmt_id) { + let _ = write!(s, " numFmtId=\"{id}\""); + } + if value.show_as == PivotShowAs::Rank { + let _ = write!( + s, + ">" + ); + } else { + s.push_str("/>"); + } } - s.push_str(""); - if hide_values_row { - let _ = write!( - s, - "" - ); - } - s.push_str(""); - s + s.push_str(""); } /// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 55ef6757d..8b118ecdb 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -96,87 +96,10 @@ pub fn find_rel<'r>(rels: &'r [Rel], kind: &str) -> Option<&'r Rel> { rels.iter().find(|r| r.kind == kind) } -pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { - let pkg = Package::open(bytes)?; - let mut cx = Ctx { - pkg, - report: ReadReport::default(), - sst: vec![], - phonetics: vec![], - xf_map: vec![], - dxfs: vec![], - palette: crate::tables::INDEXED.to_vec(), - date_system: DateSystem::D1900, - styles: StyleTable::default(), - persons: Default::default(), - next_id: 1, - num_fmts: Default::default(), - cell_pictures: vec![], - cf_ext_ids: vec![], - }; - 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()); - let wb_xml = match cx.pkg.read_xml(&wb_part)? { - Some(x) => x, - None => { - if cx.pkg.has("xl/workbook.xml") && wb_part != "xl/workbook.xml" { - cx.pkg.read_xml("xl/workbook.xml")?.ok_or_else(|| IoError::Format("no workbook part".into()))? - } else if cx.pkg.has("content.xml") { - return Err(IoError::Format("this is an OpenDocument file, not XLSX".into())); - } else { - return Err(IoError::Format("no workbook part".into())); - } - } - }; - if wb_xml.name != "workbook" { - return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", wb_xml.name))); - } - let wb_rels = cx.pkg.rels(&wb_part)?; - let mut kept = crate::passthrough::KeptReader::new(&mut cx); - cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) { - Ok(pictures) => pictures, - Err(error) => { - cx.warn(format!("cell-picture data could not be read ({error}); scalar fallbacks were retained")); - vec![] - } - }; - let mut wb = Workbook::new(); - wb.sheets.clear(); - - // Theme, then styles: a font's link to the theme's body font is read against it. - if let Some(r) = find_rel(&wb_rels, "theme").cloned() - && let Some(x) = cx.optional_xml(&r.target)? - { - wb.theme = crate::theme::read_theme(&x); - } - // Cell checkboxes live in the feature property bags, which styles refer to. - let mut checkboxes = Vec::new(); - if let Some(r) = find_rel(&wb_rels, "FeaturePropertyBag").cloned() - && let Some(x) = cx.optional_xml(&r.target)? - { - checkboxes = crate::feature_bags::checkbox_complements(&x); - } - if let Some(r) = find_rel(&wb_rels, "styles").cloned() { - if let Some(x) = cx.optional_xml(&r.target)? { - let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes, &wb.theme.minor_font); - cx.num_fmts = num_fmts; - // Cell format 0 is the Normal cell format: everything on it (font, number format, - // fill, borders, alignment, protection) is what a cell without its own format shows - // (#1013). - if let Some(normal) = xfs.first() { - cx.styles = StyleTable::with_default(normal.clone()); - } - cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); - cx.dxfs = dxfs; - cx.palette = palette; - wb.cell_styles = named; - } - } else { - cx.warn("no styles part"); - } +fn read_shared_strings(cx: &mut Ctx<'_>, wb_rels: &[Rel]) -> Result<(), IoError> { // Shared strings, streamed item by item. Phonetic (furigana) runs are kept next to the text // they annotate, so an open + save does not delete them. - if let Some(r) = find_rel(&wb_rels, "sharedStrings").cloned() { + if let Some(r) = find_rel(wb_rels, "sharedStrings").cloned() { match cx.pkg.read(&r.target)? { Some(b) => { let mut sst = Vec::new(); @@ -206,18 +129,67 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } } - if let Some(r) = find_rel(&wb_rels, "person").cloned() - && let Ok(Some(x)) = cx.pkg.read_xml(&r.target) + Ok(()) +} + +fn read_workbook_styles(cx: &mut Ctx<'_>, wb: &mut Workbook, wb_rels: &[Rel]) -> Result<(), IoError> { + // Theme, then styles: a font's link to the theme's body font is read against it. + if let Some(r) = find_rel(wb_rels, "theme").cloned() + && let Some(x) = cx.optional_xml(&r.target)? { - for p in x.kids("person") { - if let (Some(id), Some(n)) = (p.attr("id"), p.attr("displayName")) { - cx.persons.insert(id.to_string(), n.to_string()); + wb.theme = crate::theme::read_theme(&x); + } + // Cell checkboxes live in the feature property bags, which styles refer to. + let mut checkboxes = Vec::new(); + if let Some(r) = find_rel(wb_rels, "FeaturePropertyBag").cloned() + && let Some(x) = cx.optional_xml(&r.target)? + { + checkboxes = crate::feature_bags::checkbox_complements(&x); + } + if let Some(r) = find_rel(wb_rels, "styles").cloned() { + if let Some(x) = cx.optional_xml(&r.target)? { + let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes, &wb.theme.minor_font); + cx.num_fmts = num_fmts; + // Cell format 0 is the Normal cell format: everything on it (font, number format, + // fill, borders, alignment, protection) is what a cell without its own format shows + // (#1013). + if let Some(normal) = xfs.first() { + cx.styles = StyleTable::with_default(normal.clone()); + } + cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); + cx.dxfs = dxfs; + cx.palette = palette; + wb.cell_styles = named; + } + } else { + cx.warn("no styles part"); + } + Ok(()) +} + +/// Read the workbook XML selected by the package's root relationships. +fn workbook_xml(pkg: &mut Package<'_>, part: &str) -> Result { + let xml = match pkg.read_xml(part)? { + Some(xml) => xml, + None => { + if pkg.has("xl/workbook.xml") && part != "xl/workbook.xml" { + pkg.read_xml("xl/workbook.xml")?.ok_or_else(|| IoError::Format("no workbook part".into()))? + } else if pkg.has("content.xml") { + return Err(IoError::Format("this is an OpenDocument file, not XLSX".into())); + } else { + return Err(IoError::Format("no workbook part".into())); } } + }; + if xml.name != "workbook" { + return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", xml.name))); } + Ok(xml) +} - // Workbook properties. - if let Some(p) = wb_xml.child("workbookPr") { +/// Read workbook-level settings before processing the individual sheets. +fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { + if let Some(p) = xml.child("workbookPr") { if p.flag("date1904", false) { cx.date_system = DateSystem::D1904; } @@ -228,7 +200,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { }; } wb.date_system = cx.date_system; - if let Some(c) = wb_xml.child("calcPr") { + if let Some(c) = xml.child("calcPr") { wb.calc.mode = match c.attr("calcMode") { Some("manual") => CalcMode::Manual, Some("autoNoTable") => CalcMode::AutomaticExceptTables, @@ -243,10 +215,59 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } wb.calc.precision_as_displayed = !c.flag("fullPrecision", true); } - if let Some(p) = wb_xml.child("workbookProtection") { + if let Some(p) = xml.child("workbookProtection") { wb.protected_structure = p.flag("lockStructure", false); wb.structure_password = if wb.protected_structure { crate::sheet_read::read_password(p, "workbook") } else { None }; } +} + +pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let pkg = Package::open(bytes)?; + let mut cx = Ctx { + pkg, + report: ReadReport::default(), + sst: vec![], + phonetics: vec![], + xf_map: vec![], + dxfs: vec![], + palette: crate::tables::INDEXED.to_vec(), + date_system: DateSystem::D1900, + styles: StyleTable::default(), + persons: Default::default(), + next_id: 1, + num_fmts: Default::default(), + cell_pictures: vec![], + cf_ext_ids: vec![], + }; + 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()); + let wb_xml = workbook_xml(&mut cx.pkg, &wb_part)?; + let wb_rels = cx.pkg.rels(&wb_part)?; + let mut kept = crate::passthrough::KeptReader::new(&mut cx); + cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) { + Ok(pictures) => pictures, + Err(error) => { + cx.warn(format!("cell-picture data could not be read ({error}); scalar fallbacks were retained")); + vec![] + } + }; + let mut wb = Workbook::new(); + wb.sheets.clear(); + + read_workbook_styles(&mut cx, &mut wb, &wb_rels)?; + read_shared_strings(&mut cx, &wb_rels)?; + + if let Some(r) = find_rel(&wb_rels, "person").cloned() + && let Ok(Some(x)) = cx.pkg.read_xml(&r.target) + { + for p in x.kids("person") { + if let (Some(id), Some(n)) = (p.attr("id"), p.attr("displayName")) { + cx.persons.insert(id.to_string(), n.to_string()); + } + } + } + + 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; // Sheets. `file_to_model[i]` maps the i-th to a model sheet index. @@ -426,7 +447,10 @@ pub fn parse_print_area(text: &str) -> Option { RangeRef::parse(&r) } -pub fn parse_print_titles(text: &str) -> (Option<(u32, u32)>, Option<(u32, u32)>) { +/// A print-titles span: the first and last row (or column) repeated on each page. +type TitleSpan = Option<(u32, u32)>; + +pub fn parse_print_titles(text: &str) -> (TitleSpan, TitleSpan) { let mut rows = None; let mut cols = None; for part in text.split(',') { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 57b5ac7e3..7a37e41d8 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -97,6 +97,24 @@ const MAX_DYNAMIC_CHECKS: u64 = 16_000_000; /// rule copies its element's whole range list, so rules × ranges must stay bounded. const MAX_COND_CELLS: u64 = 4_000_000; +fn read_sheet_format(e: &El, sheet: &mut Sheet) { + if let Some(h) = e.attr_f64("defaultRowHeight") { + let px = pt_to_px(h); + if px > 0.0 { + sheet.default_row_height = px; + } + } + if let Some(w) = e.attr_f64("defaultColWidth") { + let px = col_width_to_px(w); + if px > 0.0 { + sheet.default_col_width = px; + } + } else if let Some(b) = e.attr_f64("baseColWidth") { + let px = ((b.clamp(0.0, 255.0) * 7.0 + 5.0) / 8.0).ceil() * 8.0; + sheet.default_col_width = px as f32; + } +} + pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?; let rels = cx.pkg.rels(part)?; @@ -134,23 +152,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_views(e, &mut sheet), - "sheetFormatPr" => { - if let Some(h) = e.attr_f64("defaultRowHeight") { - let px = pt_to_px(h); - if px > 0.0 { - sheet.default_row_height = px; - } - } - if let Some(w) = e.attr_f64("defaultColWidth") { - let px = col_width_to_px(w); - if px > 0.0 { - sheet.default_col_width = px; - } - } else if let Some(b) = e.attr_f64("baseColWidth") { - let px = ((b.clamp(0.0, 255.0) * 7.0 + 5.0) / 8.0).ceil() * 8.0; - sheet.default_col_width = px as f32; - } - } + "sheetFormatPr" => read_sheet_format(e, &mut sheet), "cols" => read_cols(cx, e, &mut sheet, &mut column_assignments_left)?, "mergeCells" => { for m in e.kids("mergeCell") { diff --git a/crates/xlsx/src/sheet_read/parts.rs b/crates/xlsx/src/sheet_read/parts.rs index c7626e6e9..3ff672289 100644 --- a/crates/xlsx/src/sheet_read/parts.rs +++ b/crates/xlsx/src/sheet_read/parts.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AutoFilter, CellRef, Comment, Ctx, El, FilterCriterion, HashMap, Hyperlink, MAX_COLS, MAX_HYPERLINK_BYTES, MAX_HYPERLINK_CELLS, MAX_ROWS, + NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml, +}; pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { let mut cells = 0u64; diff --git a/crates/xlsx/src/sheet_read/rules.rs b/crates/xlsx/src/sheet_read/rules.rs index e0ad3129f..4e4500707 100644 --- a/crates/xlsx/src/sheet_read/rules.rs +++ b/crates/xlsx/src/sheet_read/rules.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CfOperator, CfRule, CfValueKind, CondFormat, Ctx, El, ErrorStyle, MAX_COND_CELLS, RangeRef, Sheet, Validation, ValidationKind, from_file, + parse_sqref, read_color, +}; fn cf_op(s: Option<&str>) -> CfOperator { match s.unwrap_or("between") { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index b03bfbd5e..10bb3d21d 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -16,7 +16,7 @@ use crate::tables::{paper_id, px_to_col_width, px_to_pt}; use crate::write::{ CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, CT_THREADED_COMMENTS, NS_MAIN, NS_REL, NS_THREADED, Out, REL_THREADED_COMMENT, Rels, XML_DECL, }; -use crate::xml::{encode_xstring, esc, esc_attr, num, push_a1, push_esc, push_num, push_u64}; +use crate::xml::{encode_xstring, esc, esc_attr, num, push_a1, push_u64}; mod parts; mod rules; @@ -124,70 +124,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } let _ = write!(s, "", used.map(|u| u.a1()).unwrap_or_else(|| "A1".into())); - // sheetViews - s.push_str(""); - let active = sheet.view_active.a1(); - match sheet.freeze { - Some((rows, cols)) if rows > 0 || cols > 0 => { - let pane = match (rows > 0, cols > 0) { - (true, true) => "bottomRight", - (true, false) => "bottomLeft", - _ => "topRight", - }; - s.push_str(" 0 { - let _ = write!(s, " xSplit=\"{cols}\""); - } - if rows > 0 { - let _ = write!(s, " ySplit=\"{rows}\""); - } - let _ = write!( - s, - " topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>", - CellRef::new( - rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1), - cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1) - ) - .a1() - ); - let _ = write!(s, ""); - } - _ => { - let _ = write!(s, ""); - } - } - s.push_str(""); + write_sheet_views(&mut s, sheet, selected); // sheetFormatPr let max_row_outline = sheet.rows.values().map(|r| r.outline).max().unwrap_or(0); @@ -252,80 +189,248 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( s.push_str(""); } - // sheetData - // Every written cell in row-major order: the store already iterates that way, so only cells - // that exist outside it (pictures, cached spill values, table headers) need sorting in. - let mut keys: Vec<(CellRef, Option<&Cell>)> = Vec::with_capacity(sheet.cells.len()); - keys.extend(sheet.cells.iter().map(|(c, cell)| (c, Some(cell)))); - if !sheet.cell_pictures.is_empty() || !extra.is_empty() || !overrides.is_empty() || !keys.is_sorted_by_key(|(c, _)| *c) { - let mut all: BTreeSet = keys.iter().map(|(c, _)| *c).collect(); - all.extend(sheet.cell_pictures.keys().copied()); - all.extend(extra.keys().copied()); - all.extend(overrides.keys().copied()); - keys = all.into_iter().map(|c| (c, sheet.cells.get(c))).collect(); + write_sheet_data(&mut s, SheetCellData { workbook: wb, index: si, sheet, overrides: &overrides, extra: &extra, dynamic: &dynamic }, &sid, out); + + let prio = write_sheet_rules(&mut s, sheet, &mut rels, out); + let kept = write_sheet_printing(&mut s, sheet, &mut rels, out); + + write_sheet_drawing(&mut s, wb, si, sheet, &mut rels, out); + write_sheet_notes(&mut s, sheet, si, &kept, &mut rels, out); + // Tables. + if !tables.is_empty() { + let mut parts = String::new(); + let mut used_names: HashSet = HashSet::new(); + for (t, cols) in &tables { + out.tables += 1; + out.table_id += 1; + let n = out.tables; + let name = table_name(&t.name, &mut used_names, out.table_id); + let xml = table_xml(t, cols, &name, out.table_id, out); + out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes()); + let rid = rels.add("table", &format!("../tables/table{n}.xml")); + let _ = write!(parts, ""); + } + let _ = write!(s, "{parts}", tables.len()); } - s.reserve(keys.len().saturating_mul(32)); - s.push_str(""); - let mut row_infos = sheet.rows.iter().peekable(); - let mut next = 0; - loop { - // The next row with cells or row settings, and its cells. - let cell_row = keys.get(next).map(|(c, _)| c.row); - let info_row = row_infos.peek().map(|(r, _)| **r); - let r = match (cell_row, info_row) { - (Some(a), Some(b)) => a.min(b), - (Some(a), None) => a, - (None, Some(b)) => b, - (None, None) => break, - }; - let info = match row_infos.next_if(|(row, _)| **row == r) { - Some((_, info)) => *info, - None => LineInfo::default(), - }; - let first = next; - while keys.get(next).is_some_and(|(c, _)| c.row == r) { - next += 1; + write_sheet_extensions(&mut s, sheet, &prio); + s.push_str(""); + (s, rels) +} + +struct CellWriteInput<'a> { + reference: CellRef, + cell: Option<&'a Cell>, + picture: Option<&'a std::sync::Arc>, + header_override: Option<&'a String>, + spill_value: Option<&'a Value>, + dynamic_range: Option<&'a RangeRef>, +} + +fn write_sheet_extensions(s: &mut String, sheet: &Sheet, prio: &[u32]) { + // Excel 2010 extensions: solid data bars, then sparklines. + let solid_bars: Vec<(&CondFormat, u32)> = sheet + .cond_formats + .iter() + .enumerate() + .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) + .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) + .collect(); + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } + if !solid_bars.is_empty() { + let _ = write!( + s, + "" + ); + for (cf, p) in &solid_bars { + if let CfRule::DataBar { min, max, .. } = &cf.rule { + let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); + let _ = write!(s, "{lo}{hi}"); + let _ = write!(s, "{}", sqref(&cf.ranges)); + } } - let cells = keys.get(first..next).unwrap_or_default(); - if cells.is_empty() && info == LineInfo::default() { - continue; + s.push_str(""); + } + if !sheet.sparklines.is_empty() { + let _ = write!(s, ""); + for sp in &sheet.sparklines { + let ty = match sp.kind { + SparklineKind::Line => "", + SparklineKind::Column => " type=\"column\"", + SparklineKind::WinLoss => " type=\"stacked\"", + }; + let src = + if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) }; + let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) }; + let _ = write!( + s, + "{}{}", + if sp.markers { " markers=\"1\"" } else { "" }, + esc(&src), + sp.cell.a1() + ); } - s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } +} + +fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::passthrough::SheetKept<'_>, rels: &mut Rels, out: &mut Out) { + // Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note + // (its text with the replies as lines, for readers without threaded comments) linked by id. + if !sheet.comments.is_empty() { + out.comments += 1; + let n = out.comments; + let ids = thread_ids(sheet, si); + out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes()); + if !ids.is_empty() { + let x = threaded_xml(sheet, &ids, &mut out.persons); + out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes()); + rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); } - if let Some(h) = info.size { - let _ = write!(s, " ht=\"{}\"{}", num(px_to_pt(h)), if info.custom { " customHeight=\"1\"" } else { "" }); + out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); + let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); + let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out); + out.part(&vml_name, None, vml.into_bytes()); + rels.add("comments", &format!("../comments{n}.xml")); + let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); + let _ = write!(s, ""); + } else if let Some(rid) = kept.lone_vml(rels, out) { + let _ = write!(s, ""); + } + s.push_str(&kept.elements()); +} + +fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) { + // Drawing: pictures, charts, shapes. + if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { + out.drawings += 1; + let dn = out.drawings; + let mut drels = Rels::default(); + let mut objs: Vec> = vec![]; + for img in &sheet.images { + if img.data.is_empty() { + continue; + } + let ext = image_ext(&img.mime, &img.data); + out.default_type(ext, image_content_type(ext)); + let file = out.image_part(ext, &img.data); + let rid = drels.add("image", &format!("../media/{file}")); + objs.push(Obj::Image(img, rid)); } - if info.hidden { - s.push_str(" hidden=\"1\""); + for ch in &sheet.charts { + out.charts += 1; + let rid = if crate::chartex::is_chartex(ch.kind) { + let n = out.charts; + let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names); + out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes()); + // Excel expects a chartex part's style and colour parts. + out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes()); + out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes()); + let mut crels = Rels::default(); + crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml")); + crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml")); + out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes()); + drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml")) + } else { + let name = format!("xl/charts/chart{}.xml", out.charts); + out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes()); + drels.add("chart", &format!("../charts/chart{}.xml", out.charts)) + }; + objs.push(Obj::Chart(ch, rid)); } - if info.outline > 0 { - let _ = write!(s, " outlineLevel=\"{}\"", info.outline.min(7)); + for sh in &sheet.shapes { + objs.push(Obj::Shape(sh)); } - if info.collapsed { - s.push_str(" collapsed=\"1\""); + out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes()); + if !drels.is_empty() { + out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes()); } - if cells.is_empty() { - s.push_str("/>"); - continue; + let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml")); + let _ = write!(s, ""); + } +} + +fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> crate::passthrough::SheetKept<'a> { + // Printing. + let p = &sheet.print; + if p.gridlines || p.headings || p.center_h || p.center_v { + s.push_str(""); + } + // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). + let kept = crate::passthrough::SheetKept::write(sheet, rels, out); + let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); + let _ = + write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); + s.push_str(""); + if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins { + s.push_str("'); - for &(c, cell) in cells { - let ov = overrides.get(&c); - let ex = extra.get(&c); - let array = dynamic.get(&c).copied().or_else(|| { - let f = cell?.formula.as_ref()?; - (f.array.is_none() && crate::array_formula::needs_array(wb, si, c, f)).then_some(RangeRef::cell(c)) - }); - write_cell(&mut s, c, cell, sheet.cell_pictures.get(&c), ov, ex, array.as_ref(), &sid, out); + // CT_HeaderFooter's element order. + for (tag, text) in [ + ("oddHeader", &p.header), + ("oddFooter", &p.footer), + ("evenHeader", &p.even_header), + ("evenFooter", &p.even_footer), + ("firstHeader", &p.first_header), + ("firstFooter", &p.first_footer), + ] { + if !text.is_empty() { + let _ = write!(s, "<{tag}>{}", esc(text)); + } + } + s.push_str(""); + } + for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] { + let list: Vec = list.iter().copied().filter(|&b| b > 0).collect(); + if !list.is_empty() { + let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len()); + for b in list { + let _ = write!(s, ""); + } + let _ = write!(s, ""); } - s.push_str(""); } - s.push_str(""); + kept +} + +fn write_sheet_rules(s: &mut String, sheet: &Sheet, rels: &mut Rels, out: &mut Out) -> Vec { // sheetProtection if let Some(p) = &sheet.protection { s.push_str(" ( } s.push_str(""); } - // Printing. - let p = &sheet.print; - if p.gridlines || p.headings || p.center_h || p.center_v { - s.push_str(""); - } - // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). - let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out); - let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); - let _ = - write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); - s.push_str(""); - if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins { - s.push_str("'); - // CT_HeaderFooter's element order. - for (tag, text) in [ - ("oddHeader", &p.header), - ("oddFooter", &p.footer), - ("evenHeader", &p.even_header), - ("evenFooter", &p.even_footer), - ("firstHeader", &p.first_header), - ("firstFooter", &p.first_footer), - ] { - if !text.is_empty() { - let _ = write!(s, "<{tag}>{}", esc(text)); - } - } - s.push_str(""); - } - for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] { - let list: Vec = list.iter().copied().filter(|&b| b > 0).collect(); - if !list.is_empty() { - let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len()); - for b in list { - let _ = write!(s, ""); - } - let _ = write!(s, ""); - } - } - - // Drawing: pictures, charts, shapes. - if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { - out.drawings += 1; - let dn = out.drawings; - let mut drels = Rels::default(); - let mut objs: Vec> = vec![]; - for img in &sheet.images { - if img.data.is_empty() { - continue; - } - let ext = image_ext(&img.mime, &img.data); - out.default_type(ext, image_content_type(ext)); - let file = out.image_part(ext, &img.data); - let rid = drels.add("image", &format!("../media/{file}")); - objs.push(Obj::Image(img, rid)); - } - for ch in &sheet.charts { - out.charts += 1; - let rid = if crate::chartex::is_chartex(ch.kind) { - let n = out.charts; - let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names); - out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes()); - // Excel expects a chartex part's style and colour parts. - out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes()); - out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes()); - let mut crels = Rels::default(); - crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml")); - crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml")); - out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes()); - drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml")) - } else { - let name = format!("xl/charts/chart{}.xml", out.charts); - out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes()); - drels.add("chart", &format!("../charts/chart{}.xml", out.charts)) - }; - objs.push(Obj::Chart(ch, rid)); - } - for sh in &sheet.shapes { - objs.push(Obj::Shape(sh)); - } - out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes()); - if !drels.is_empty() { - out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes()); - } - let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml")); - let _ = write!(s, ""); - } - // Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note - // (its text with the replies as lines, for readers without threaded comments) linked by id. - if !sheet.comments.is_empty() { - out.comments += 1; - let n = out.comments; - let ids = thread_ids(sheet, si); - out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes()); - if !ids.is_empty() { - let x = threaded_xml(sheet, &ids, &mut out.persons); - out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes()); - rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); - } - out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); - let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); - let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out); - out.part(&vml_name, None, vml.into_bytes()); - rels.add("comments", &format!("../comments{n}.xml")); - let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); - let _ = write!(s, ""); - } else if let Some(rid) = kept.lone_vml(&mut rels, out) { - let _ = write!(s, ""); - } - s.push_str(&kept.elements()); - // Tables. - if !tables.is_empty() { - let mut parts = String::new(); - let mut used_names: HashSet = HashSet::new(); - for (t, cols) in &tables { - out.tables += 1; - out.table_id += 1; - let n = out.tables; - let name = table_name(&t.name, &mut used_names, out.table_id); - let xml = table_xml(t, cols, &name, out.table_id, out); - out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes()); - let rid = rels.add("table", &format!("../tables/table{n}.xml")); - let _ = write!(parts, ""); - } - let _ = write!(s, "{parts}", tables.len()); - } - // Excel 2010 extensions: solid data bars, then sparklines. - let solid_bars: Vec<(&CondFormat, u32)> = sheet - .cond_formats - .iter() - .enumerate() - .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) - .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) - .collect(); - if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { - s.push_str(""); - } - if !solid_bars.is_empty() { - let _ = write!( - s, - "" - ); - for (cf, p) in &solid_bars { - if let CfRule::DataBar { min, max, .. } = &cf.rule { - let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); - let _ = write!(s, "{lo}{hi}"); - let _ = write!(s, "{}", sqref(&cf.ranges)); - } - } - s.push_str(""); - } - if !sheet.sparklines.is_empty() { - let _ = write!(s, ""); - for sp in &sheet.sparklines { - let ty = match sp.kind { - SparklineKind::Line => "", - SparklineKind::Column => " type=\"column\"", - SparklineKind::WinLoss => " type=\"stacked\"", - }; - let src = - if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) }; - let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) }; - let _ = write!( - s, - "{}{}", - if sp.markers { " markers=\"1\"" } else { "" }, - esc(&src), - sp.cell.a1() - ); - } - s.push_str(""); - } - if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { - s.push_str(""); - } - s.push_str(""); - (s, rels) + prio } -fn write_cell( - s: &mut String, - c: CellRef, - cell: Option<&Cell>, - picture: Option<&std::sync::Arc>, - ov: Option<&String>, - extra: Option<&Value>, - dynamic: Option<&RangeRef>, - sid: &dyn Fn(gridcraft_model::StyleId) -> u32, - out: &mut Out, -) { +struct SheetCellData<'a> { + workbook: &'a Workbook, + index: usize, + sheet: &'a Sheet, + overrides: &'a BTreeMap, + extra: &'a BTreeMap, + dynamic: &'a BTreeMap, +} + +fn write_sheet_data(s: &mut String, data: SheetCellData<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) { + let SheetCellData { workbook, index, sheet, overrides, extra, dynamic } = data; + // Cell storage is already row-major; sort only when extra cells need merging. + let mut keys: Vec<(CellRef, Option<&Cell>)> = Vec::with_capacity(sheet.cells.len()); + keys.extend(sheet.cells.iter().map(|(c, cell)| (c, Some(cell)))); + if !sheet.cell_pictures.is_empty() || !extra.is_empty() || !overrides.is_empty() || !keys.is_sorted_by_key(|(c, _)| *c) { + let mut all: BTreeSet = keys.iter().map(|(c, _)| *c).collect(); + all.extend(sheet.cell_pictures.keys().copied()); + all.extend(extra.keys().copied()); + all.extend(overrides.keys().copied()); + keys = all.into_iter().map(|c| (c, sheet.cells.get(c))).collect(); + } + s.reserve(keys.len().saturating_mul(32)); + s.push_str(""); + let mut row_infos = sheet.rows.iter().peekable(); + let mut next = 0; + loop { + let cell_row = keys.get(next).map(|(cell, _)| cell.row); + let info_row = row_infos.peek().map(|(row, _)| **row); + let row = match (cell_row, info_row) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => break, + }; + let info = match row_infos.next_if(|(entry_row, _)| **entry_row == row) { + Some((_, info)) => *info, + None => LineInfo::default(), + }; + let first = next; + while keys.get(next).is_some_and(|(cell, _)| cell.row == row) { + next += 1; + } + let cells = keys.get(first..next).unwrap_or_default(); + if cells.is_empty() && info == LineInfo::default() { + continue; + } + s.push_str(""); + } + s.push_str(""); +} + +fn write_sheet_views(s: &mut String, sheet: &Sheet, selected: bool) { + s.push_str(""); + let active = sheet.view_active.a1(); + match sheet.freeze { + Some((rows, cols)) if rows > 0 || cols > 0 => { + let pane = match (rows > 0, cols > 0) { + (true, true) => "bottomRight", + (true, false) => "bottomLeft", + _ => "topRight", + }; + s.push_str(" 0 { + let _ = write!(s, " xSplit=\"{cols}\""); + } + if rows > 0 { + let _ = write!(s, " ySplit=\"{rows}\""); + } + let _ = write!( + s, + " topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>", + CellRef::new( + rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1), + cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1) + ) + .a1() + ); + let _ = write!(s, ""); + } + _ => { + let _ = write!(s, ""); + } + } + s.push_str(""); +} + +fn write_cell(s: &mut String, input: CellWriteInput<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) { + let CellWriteInput { reference: c, cell, picture, header_override: ov, spill_value: extra, dynamic_range: dynamic } = input; let style = cell.map(|x| sid(x.style)).unwrap_or(0); s.push_str("'); - match f.array.or(dynamic.copied()) { - Some(r) => { - let _ = write!(s, "", r.a1()); + let text = esc(&crate::fmla::to_file(f)); + match (f.array, dynamic) { + (Some(r), _) => { + let _ = write!(s, "{text}", r.a1()); + } + (None, Some(r)) => { + let _ = write!(s, "{text}", r.a1()); + } + _ => { + let _ = write!(s, "{text}"); } - None => s.push_str(""), } - push_esc(s, &crate::fmla::to_file(f)); - s.push_str(""); match &value { Value::Number(n) => { - s.push_str(""); - push_num(s, *n); - s.push_str(""); + let _ = write!(s, "{}", num(*n)); } Value::Text(t) => { let _ = write!(s, "{}", esc(&encode_xstring(t))); @@ -735,15 +784,11 @@ fn write_cell( match &value { Value::Empty | Value::Array(_) => s.push_str("/>"), Value::Number(n) => { - s.push_str(">"); - push_num(s, *n); - s.push_str(""); + let _ = write!(s, ">{}", num(*n)); } Value::Text(t) => { let i = out.sst.index(t); - s.push_str(" t=\"s\">"); - push_u64(s, u64::from(i)); - s.push_str(""); + let _ = write!(s, " t=\"s\">{i}"); } Value::Bool(b) => { let _ = write!(s, " t=\"b\">{}", *b as u8); diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 08da55991..6b9dff341 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -127,6 +127,12 @@ fn styles_theme_and_sizes() { let bytes = minimal(body, &[("xl/styles.xml", styles), ("xl/theme/theme1.xml", theme)], rels, ""); let (wb, _) = read_xlsx(&bytes).unwrap(); let s = wb.sheet(0).unwrap(); + assert_style_definitions(&wb, s); + assert_theme(&wb); + assert_sheet_settings(s); +} + +fn assert_style_definitions(wb: &gridcraft_model::Workbook, s: &gridcraft_model::Sheet) { let st1 = wb.styles.get(s.cell(at("A1")).unwrap().style); assert_eq!(st1.num_fmt.as_str(), "0.000\"kg\""); assert!(st1.font.bold && st1.font.italic && st1.font.strike); @@ -152,6 +158,9 @@ fn styles_theme_and_sizes() { assert_eq!(st3.fill.pattern, PatternType::DarkGrid); assert_eq!(st3.fill.fg, Color::Theme(5, 0)); assert_eq!(wb.styles.get(s.cell(at("D1")).unwrap().style).num_fmt.as_str(), "General"); +} + +fn assert_theme(wb: &gridcraft_model::Workbook) { // Theme: file order dk1, lt1… maps to lt1, dk1… in the model. assert_eq!(wb.theme.colors[0], 0xFFFFFF); assert_eq!(wb.theme.colors[1], 0x000000); @@ -159,6 +168,9 @@ fn styles_theme_and_sizes() { assert_eq!(wb.theme.colors[3], 0x44546A); assert_eq!(wb.theme.colors[4], 0x4472C4); assert_eq!(wb.theme.major_font, "Calibri Light"); +} + +fn assert_sheet_settings(s: &gridcraft_model::Sheet) { // Sheet settings. assert_eq!(s.tab_color, Some(Color::Rgb(0x00B050))); assert_eq!(s.freeze, Some((2, 1))); diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 4ee815e86..0fb9f1b43 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -46,6 +46,20 @@ fn missing_parts() { assert!(rep.warnings.iter().any(|w| w.contains("no worksheets"))); } +#[test] +fn workbook_part_selection_and_validation() { + let root_rels = super::ROOT_RELS.replace("xl/workbook.xml", "xl/missing.xml"); + let workbook = r#""#; + let fallback = make_zip(&[("_rels/.rels", root_rels.as_bytes()), ("xl/workbook.xml", workbook.as_bytes())]); + assert!(read_xlsx(&fallback).is_ok()); + + let wrong_root = make_zip(&[("xl/workbook.xml", b"")]); + assert!(matches!(read_xlsx(&wrong_root), Err(IoError::Format(message)) if message.contains("unexpected root element "))); + + let ods = make_zip(&[("content.xml", b"")]); + assert!(matches!(read_xlsx(&ods), Err(IoError::Format(message)) if message.contains("OpenDocument"))); +} + #[test] fn huge_coordinates_and_dimensions() { let body = r#" diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 343bfa9ee..28113be0d 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -213,6 +213,14 @@ fn unmodelled_parts_survive_a_round_trip() { let out = write_xlsx(&wb).unwrap(); let u = Unpacked::new(&out); u.assert_valid(); + assert_kept_workbook_parts(&u); + let sheet = sheet_part(&u, "Data"); + let sx = u.text(&sheet); + assert_kept_sheet_parts(&u, &sheet, &sx); + assert_round_trip_kept_content(&wb, &out); +} + +fn assert_kept_workbook_parts(u: &Unpacked) { // Every kept part is in the output byte for byte. for d in [ PRINTER, @@ -244,34 +252,37 @@ fn unmodelled_parts_survive_a_round_trip() { let item = u.find(CUSTOM_XML.as_bytes())[0].clone(); assert!(u.rels(&item).iter().any(|r| r.1.ends_with("/customXmlProps"))); // Formulas using the link are written as they were. - let sheet = sheet_part(&u, "Data"); + let sheet = sheet_part(u, "Data"); let sx = u.text(&sheet); assert!(sx.contains("[1]Sheet1!$A$1"), "{sx}"); assert!(sx.contains("SUM([1]Sheet1!A1:A3)*2"), "{sx}"); +} + +fn assert_kept_sheet_parts(u: &Unpacked, sheet: &str, sx: &str) { // Printer settings (#324). - let ps = attr_of(&sx, "pageSetup", "r:id"); - let (ty, target) = u.target(&sheet, &ps); + let ps = attr_of(sx, "pageSetup", "r:id"); + let (ty, target) = u.target(sheet, &ps); assert!(ty.ends_with("/printerSettings")); assert_eq!(u.parts.get(&target).unwrap(), PRINTER); // The OLE object and its preview image, re-linked. - let ole = attr_of(&sx, "oleObject", "r:id"); - assert_eq!(u.parts.get(&u.target(&sheet, &ole).1).unwrap(), OLE); - let preview = attr_of(&sx, "objectPr", "r:id"); - assert_eq!(u.parts.get(&u.target(&sheet, &preview).1).unwrap(), EMF); + let ole = attr_of(sx, "oleObject", "r:id"); + assert_eq!(u.parts.get(&u.target(sheet, &ole).1).unwrap(), OLE); + let preview = attr_of(sx, "objectPr", "r:id"); + assert_eq!(u.parts.get(&u.target(sheet, &preview).1).unwrap(), EMF); // Namespace declarations travel with the fragment. - assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006"); - assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); + assert_eq!(attr_of(sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006"); + assert_eq!(attr_of(sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); // The form control. - let ctrl = attr_of(&sx, "control", "r:id"); - assert_eq!(u.parts.get(&u.target(&sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes()); + let ctrl = attr_of(sx, "control", "r:id"); + assert_eq!(u.parts.get(&u.target(sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes()); // Schema order: pageSetup < legacyDrawing < oleObjects < controls. let pos = |t: &str| sx.find(t).unwrap_or_else(|| panic!("no {t}")); assert!(pos(">) { let ct = xml::parse(parts.get("[Content_Types].xml").unwrap()).unwrap(); let overrides: HashMap = ct .kids("Override") .map(|o| (o.attr("PartName").unwrap().trim_start_matches('/').to_string(), o.attr("ContentType").unwrap().to_string())) .collect(); let defaults: HashSet = ct.kids("Default").map(|d| d.attr("Extension").unwrap().to_string()).collect(); - // Every override names an existing part; every part has a content type. - for p in overrides.keys() { - assert!(parts.contains_key(p), "override for missing part {p}"); + for part in overrides.keys() { + assert!(parts.contains_key(part), "override for missing part {part}"); } - for p in parts.keys() { - if p == "[Content_Types].xml" { - continue; - } - let ext = p.rsplit('.').next().unwrap(); - assert!(overrides.contains_key(p) || defaults.contains(ext), "no content type for {p}"); + for part in parts.keys().filter(|p| p.as_str() != "[Content_Types].xml") { + let ext = part.rsplit('.').next().unwrap(); + assert!(overrides.contains_key(part) || defaults.contains(ext), "no content type for {part}"); } - let count = |ct: &str| overrides.values().filter(|v| v.as_str() == ct).count(); + let count = |content_type: &str| overrides.values().filter(|value| value.as_str() == content_type).count(); assert_eq!(count(crate::pivot::CT_PIVOT_TABLE), 3); assert_eq!(count(crate::pivot::CT_PIVOT_CACHE_DEF), 3); assert_eq!(count(crate::pivot::CT_PIVOT_CACHE_REC), 3); - // Every relationship target exists. - let mut kinds: Vec = vec![]; - for (name, data) in &parts { - if !name.ends_with(".rels") { - continue; - } +} + +fn assert_relationship_targets_exist(parts: &HashMap>) { + let mut kinds: Vec = Vec::new(); + for (name, data) in parts.iter().filter(|(name, _)| name.ends_with(".rels")) { let dir = name.trim_end_matches(".rels").rsplit_once("/_rels/").map(|(d, _)| d).unwrap_or(""); - let rels = xml::parse(data).unwrap(); - for r in rels.kids("Relationship") { - if r.attr("TargetMode") == Some("External") { + for relation in xml::parse(data).unwrap().kids("Relationship") { + if relation.attr("TargetMode") == Some("External") { continue; } - let target = crate::package::resolve(dir, r.attr("Target").unwrap()); + let target = crate::package::resolve(dir, relation.attr("Target").unwrap()); assert!(parts.contains_key(&target), "{name} points at missing {target}"); - kinds.push(r.attr("Type").unwrap().rsplit('/').next().unwrap().to_string()); + kinds.push(relation.attr("Type").unwrap().rsplit('/').next().unwrap().to_string()); } } - for k in ["pivotTable", "pivotCacheDefinition", "pivotCacheRecords"] { - assert!(kinds.iter().any(|x| x == k), "no {k} relationship"); + for kind in ["pivotTable", "pivotCacheDefinition", "pivotCacheRecords"] { + assert!(kinds.iter().any(|found| found == kind), "no {kind} relationship"); } - // The workbook lists the caches by id with relationships of the right type. - let wbx = xml::parse(parts.get("xl/workbook.xml").unwrap()).unwrap(); - let wb_rels = xml::parse(parts.get("xl/_rels/workbook.xml.rels").unwrap()).unwrap(); - let caches: Vec<&xml::El> = wbx.child("pivotCaches").unwrap().kids("pivotCache").collect(); +} + +fn assert_workbook_cache_relationships(parts: &HashMap>) -> HashSet { + let workbook = xml::parse(parts.get("xl/workbook.xml").unwrap()).unwrap(); + let relationships = xml::parse(parts.get("xl/_rels/workbook.xml.rels").unwrap()).unwrap(); + let caches: Vec<&xml::El> = workbook.child("pivotCaches").unwrap().kids("pivotCache").collect(); assert_eq!(caches.len(), 3); let mut ids = HashSet::new(); - for c in &caches { - assert!(ids.insert(c.attr("cacheId").unwrap().to_string())); - let rid = c.attr("id").unwrap(); - let rel = wb_rels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); - assert!(rel.attr("Type").unwrap().ends_with("/pivotCacheDefinition")); + for cache in &caches { + assert!(ids.insert(cache.attr("cacheId").unwrap().to_string())); + let rid = cache.attr("id").unwrap(); + let relation = relationships.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); + assert!(relation.attr("Type").unwrap().ends_with("/pivotCacheDefinition")); } - // Each table's cacheId is one of the workbook's caches and its field counts agree. - for t in 1..=3 { - let pt = xml::parse(parts.get(&format!("xl/pivotTables/pivotTable{t}.xml")).unwrap()).unwrap(); - assert!(ids.contains(pt.attr("cacheId").unwrap())); - let trels = xml::parse(parts.get(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels")).unwrap()).unwrap(); - let target = crate::package::resolve("xl/pivotTables", trels.kids("Relationship").next().unwrap().attr("Target").unwrap()); - let cache = xml::parse(parts.get(&target).unwrap()).unwrap(); - let nf = cache.child("cacheFields").unwrap().kids("cacheField").count(); - assert_eq!(pt.child("pivotFields").unwrap().kids("pivotField").count(), nf); - assert_eq!(cache.child("cacheFields").unwrap().attr_u32("count"), Some(nf as u32)); - // Item indices stay inside the shared/group items. - for (i, pf) in pt.child("pivotFields").unwrap().kids("pivotField").enumerate() { - let cf = cache.child("cacheFields").unwrap().kids("cacheField").nth(i).unwrap(); - let n = cf - .path(&["fieldGroup", "groupItems"]) - .map(|g| g.children.len()) - .unwrap_or_else(|| cf.child("sharedItems").map(|s| s.children.len()).unwrap_or(0)); - if let Some(items) = pf.child("items") { - assert_eq!(items.attr_u32("count"), Some(items.children.len() as u32)); - for it in items.kids("item") { - if let Some(x) = it.attr_u32("x") { - assert!((x as usize) < n, "item x={x} out of {n} in table {t} field {i}"); - } + ids +} + +fn assert_pivot_tables_reference_caches(parts: &HashMap>, ids: &HashSet) { + for table_number in 1..=3 { + let table = xml::parse(parts.get(&format!("xl/pivotTables/pivotTable{table_number}.xml")).unwrap()).unwrap(); + assert!(ids.contains(table.attr("cacheId").unwrap())); + let table_rels = xml::parse(parts.get(&format!("xl/pivotTables/_rels/pivotTable{table_number}.xml.rels")).unwrap()).unwrap(); + let cache_path = crate::package::resolve("xl/pivotTables", table_rels.kids("Relationship").next().unwrap().attr("Target").unwrap()); + let cache = xml::parse(parts.get(&cache_path).unwrap()).unwrap(); + assert_pivot_table_fields_match_cache(&table, &cache); + assert_pivot_cache_records_match_fields(parts, &cache, &cache_path); + } +} + +fn assert_pivot_table_fields_match_cache(table: &xml::El, cache: &xml::El) { + let cache_fields = cache.child("cacheFields").unwrap(); + let field_count = cache_fields.kids("cacheField").count(); + assert_eq!(table.child("pivotFields").unwrap().kids("pivotField").count(), field_count); + assert_eq!(cache_fields.attr_u32("count"), Some(field_count as u32)); + for (field_index, pivot_field) in table.child("pivotFields").unwrap().kids("pivotField").enumerate() { + let cache_field = cache_fields.kids("cacheField").nth(field_index).unwrap(); + let item_count = cache_field + .path(&["fieldGroup", "groupItems"]) + .map(|items| items.children.len()) + .unwrap_or_else(|| cache_field.child("sharedItems").map(|items| items.children.len()).unwrap_or(0)); + if let Some(items) = pivot_field.child("items") { + assert_eq!(items.attr_u32("count"), Some(items.children.len() as u32)); + for item in items.kids("item") { + if let Some(item_index) = item.attr_u32("x") { + assert!((item_index as usize) < item_count, "item x={item_index} out of {item_count} in field {field_index}"); } } } - // Records match the database fields. - if let Some(rid) = cache.attr("id") { - let crels_name = target.replace("pivotCache/", "pivotCache/_rels/") + ".rels"; - let crels = xml::parse(parts.get(&crels_name).unwrap()).unwrap(); - let rel = crels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); - let rec = xml::parse(parts.get(&crate::package::resolve("xl/pivotCache", rel.attr("Target").unwrap())).unwrap()).unwrap(); - let db = cache.child("cacheFields").unwrap().kids("cacheField").filter(|f| f.attr("databaseField") != Some("0")).count(); - assert_eq!(rec.attr_u32("count"), cache.attr_u32("recordCount")); - assert_eq!(rec.kids("r").count(), 8); - for r in rec.kids("r") { - assert_eq!(r.children.len(), db); - } - } } - // Sheet 2 (Report) relates to all three tables. - let srels = xml::parse(parts.get("xl/worksheets/_rels/sheet2.xml.rels").unwrap()).unwrap(); - assert_eq!(srels.kids("Relationship").filter(|r| r.attr("Type").unwrap().ends_with("/pivotTable")).count(), 3); +} + +fn assert_pivot_cache_records_match_fields(parts: &HashMap>, cache: &xml::El, cache_path: &str) { + let Some(rid) = cache.attr("id") else { return }; + let rels_path = cache_path.replace("pivotCache/", "pivotCache/_rels/") + ".rels"; + let cache_rels = xml::parse(parts.get(&rels_path).unwrap()).unwrap(); + let relation = cache_rels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); + let records_path = crate::package::resolve("xl/pivotCache", relation.attr("Target").unwrap()); + let records = xml::parse(parts.get(&records_path).unwrap()).unwrap(); + let database_fields = cache.child("cacheFields").unwrap().kids("cacheField").filter(|f| f.attr("databaseField") != Some("0")).count(); + assert_eq!(records.attr_u32("count"), cache.attr_u32("recordCount")); + assert_eq!(records.kids("r").count(), 8); + for record in records.kids("r") { + assert_eq!(record.children.len(), database_fields); + } +} + +fn assert_sheet_pivot_relationships(parts: &HashMap>) { + let sheet_rels = xml::parse(parts.get("xl/worksheets/_rels/sheet2.xml.rels").unwrap()).unwrap(); + assert_eq!(sheet_rels.kids("Relationship").filter(|r| r.attr("Type").unwrap().ends_with("/pivotTable")).count(), 3); } #[test] diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 14fc784f9..2298e513f 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -490,7 +490,16 @@ fn full_roundtrip() { assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic"); let (back, rep) = read_xlsx(&bytes).unwrap(); assert!(rep.warnings.iter().all(|w| !w.contains("skipped") || w.contains("chart")), "{:?}", rep.warnings); + assert_workbook_metadata_roundtrip(&wb, &back); + let (a, b) = (wb.sheet(0).unwrap(), back.sheet(0).unwrap()); + assert_sheet_cells_and_settings(&wb, &back, a, b); + assert_table_roundtrip(a, b); + assert_drawing_and_chart_roundtrip(a, b); + assert_eq!(back.sheet(1).unwrap().value(at("A1")), Value::Number(5.0)); +} + +fn assert_workbook_metadata_roundtrip(wb: &Workbook, back: &Workbook) { assert_eq!(back.date_system, wb.date_system); assert_eq!(back.calc, wb.calc); assert!(back.protected_structure); @@ -505,8 +514,9 @@ fn full_roundtrip() { assert_eq!(back.sheets[2].visibility, Visibility::VeryHidden); assert_eq!(back.sheets[2].name, "2024"); assert_eq!(back.sheets[2].comments, wb.sheets[2].comments); +} - let (a, b) = (wb.sheet(0).unwrap(), back.sheet(0).unwrap()); +fn assert_sheet_cells_and_settings(wb: &Workbook, back: &Workbook, a: &Sheet, b: &Sheet) { assert_eq!(b.name, a.name); // Cells: values, formulas and resolved styles. let skip = [at("H2"), at("H3")]; @@ -542,6 +552,9 @@ fn full_roundtrip() { let mut sp = a.sparklines.clone(); sp[0].source = "'Data & Stuff'!B2:B3".into(); assert_eq!(b.sparklines, sp); +} + +fn assert_table_roundtrip(a: &Sheet, b: &Sheet) { // Tables: name sanitized, ids reassigned. let (ta, tb) = (&a.tables[0], &b.tables[0]); assert_eq!(tb.name, "Sales_Table"); @@ -549,6 +562,9 @@ fn full_roundtrip() { assert_eq!((tb.banded_rows, tb.banded_cols, tb.first_col, tb.last_col, tb.filter_button), (true, true, true, false, true)); assert_eq!(tb.filter, ta.filter, "the table's filter criteria survive (SUBTOTAL tells filtered rows apart)"); assert_eq!(b.value(at("P1")), Value::text("Region"), "header cells carry the column names"); +} + +fn assert_drawing_and_chart_roundtrip(a: &Sheet, b: &Sheet) { // Drawings. assert_eq!(b.images.len(), 1); assert_eq!(b.images[0].data, a.images[0].data); @@ -587,7 +603,6 @@ fn full_roundtrip() { } assert!(close(y.anchor.width, x.anchor.width) && close(y.anchor.height, x.anchor.height)); } - assert_eq!(back.sheet(1).unwrap().value(at("A1")), Value::Number(5.0)); } #[test] diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index da65de014..bd21b4939 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -307,58 +307,74 @@ fn reads_values_formulas_formats_and_layout() { assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings); assert!(report.warnings.iter().any(|w| w.contains("Chart sheets"))); assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1", "Data"]); - assert_eq!(v(&wb, 0, "A1"), Value::from("Hello")); - assert_eq!(v(&wb, 0, "B1"), Value::Number(1.5)); - assert_eq!(v(&wb, 0, "C1"), Value::Number(42.0)); - assert_eq!(v(&wb, 0, "D1"), Value::from("Wörld")); - assert_eq!(v(&wb, 0, "A2"), Value::Bool(true)); - assert_eq!(v(&wb, 0, "B2"), Value::Error(CellError::Div0)); - assert_eq!(v(&wb, 0, "C2"), Value::Number(7.0)); - assert_eq!(v(&wb, 0, "D2"), Value::Number(1.5)); - // Formulas: text plus the cached result. - assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2")); - assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0)); - assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)")); - assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\"")); - assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!")); - assert_eq!(ftext(&wb, "D3").as_deref(), Some("B1>1")); - assert_eq!(v(&wb, 0, "D3"), Value::Bool(true)); - assert_eq!(ftext(&wb, "E3").as_deref(), Some("Rate*2")); - assert_eq!(ftext(&wb, "F3").as_deref(), Some("ROUND(B1,0)")); - assert_eq!(ftext(&wb, "G3").as_deref(), Some("Data!A1")); - assert_eq!(v(&wb, 0, "H3"), Value::Error(CellError::NA)); - // Shared formula: relative offsets resolve per cell. - assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10")); - assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10")); - // Array formula: the anchor holds it; the other cell's cached result is dropped so that - // the formula fills the range on recalculation (as when an .xlsx is read, #156). - let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap(); - assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap()))); - assert_eq!(ftext(&wb, "C6"), None); - assert_eq!(v(&wb, 0, "C6"), Value::Empty); - // Number formats, merges, sizes, names, print area; embedded chart records ignored. - let sh = &wb.sheets[0]; - assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd"); - assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); - assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]); - assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width); - assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0)); - assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom); - assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2")); - assert_eq!(v(&wb, 0, "J10"), Value::Empty); - assert_eq!(wb.names.len(), 1); - assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1")); - assert_eq!(v(&wb, 1, "A1"), Value::Number(7.0)); - assert_eq!(wb.date_system, DateSystem::D1900); - // The import saves as .xlsx and reads back. - let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); - assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2"); - assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!")); + assert_values_roundtrip(&wb); + assert_formula_roundtrip(&wb); + assert_layout_roundtrip(&wb); + assert_xlsx_conversion(&wb); } let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap(); assert_eq!(wb.date_system, DateSystem::D1904); } +fn assert_values_roundtrip(wb: &gridcraft_model::Workbook) { + assert_eq!(v(wb, 0, "A1"), Value::from("Hello")); + assert_eq!(v(wb, 0, "B1"), Value::Number(1.5)); + assert_eq!(v(wb, 0, "C1"), Value::Number(42.0)); + assert_eq!(v(wb, 0, "D1"), Value::from("Wörld")); + assert_eq!(v(wb, 0, "A2"), Value::Bool(true)); + assert_eq!(v(wb, 0, "B2"), Value::Error(CellError::Div0)); + assert_eq!(v(wb, 0, "C2"), Value::Number(7.0)); + assert_eq!(v(wb, 0, "D2"), Value::Number(1.5)); +} + +fn assert_formula_roundtrip(wb: &gridcraft_model::Workbook) { + // Formulas: text plus the cached result. + assert_eq!(ftext(wb, "A3").as_deref(), Some("B1*2")); + assert_eq!(v(wb, 0, "A3"), Value::Number(3.0)); + assert_eq!(ftext(wb, "B3").as_deref(), Some("SUM(B1:C1)")); + assert_eq!(ftext(wb, "C3").as_deref(), Some("A1&\"!\"")); + assert_eq!(v(wb, 0, "C3"), Value::from("Hello!")); + assert_eq!(ftext(wb, "D3").as_deref(), Some("B1>1")); + assert_eq!(v(wb, 0, "D3"), Value::Bool(true)); + assert_eq!(ftext(wb, "E3").as_deref(), Some("Rate*2")); + assert_eq!(ftext(wb, "F3").as_deref(), Some("ROUND(B1,0)")); + assert_eq!(ftext(wb, "G3").as_deref(), Some("Data!A1")); + assert_eq!(v(wb, 0, "H3"), Value::Error(CellError::NA)); + // Shared formula: relative offsets resolve per cell. + assert_eq!(ftext(wb, "B5").as_deref(), Some("A5*10")); + assert_eq!(ftext(wb, "B6").as_deref(), Some("A6*10")); + // Array formula: the anchor holds it; the other cell's cached result is dropped so that + // the formula fills the range on recalculation (as when an .xlsx is read, #156). + let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap(); + assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap()))); + assert_eq!(ftext(wb, "C6"), None); + assert_eq!(v(wb, 0, "C6"), Value::Empty); +} + +fn assert_layout_roundtrip(wb: &gridcraft_model::Workbook) { + // Number formats, merges, sizes, names, print area; embedded chart records ignored. + let sh = &wb.sheets[0]; + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd"); + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); + assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]); + assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width); + assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0)); + assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom); + assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2")); + assert_eq!(v(wb, 0, "J10"), Value::Empty); + assert_eq!(wb.names.len(), 1); + assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1")); + assert_eq!(v(wb, 1, "A1"), Value::Number(7.0)); + assert_eq!(wb.date_system, DateSystem::D1900); +} + +fn assert_xlsx_conversion(wb: &gridcraft_model::Workbook) { + // The import saves as .xlsx and reads back. + let (back, _) = read_xlsx(&write_xlsx(wb).unwrap()).unwrap(); + assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2"); + assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!")); +} + #[test] fn text_result_of_a_formula_kept_as_its_value() { // A what-if data table cell ({=TABLE(B1,)}) can't be imported as a formula, so its saved result diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index b96df35c7..f03dd9b04 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -243,7 +243,6 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) out.default_type("xml", "application/xml"); // Which sheet is active (must be visible), and at least one visible sheet. - let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible); let active = if wb.sheets.get(wb.active_sheet()).is_some_and(|s| s.visibility == Visibility::Visible) { wb.active_sheet() } else { @@ -251,92 +250,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) }; let mut wb_rels = Rels::default(); - // Kept content first: the chart sheets among the worksheets need their relationship ids. - let kept = crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out); - let mut pivots = crate::pivot::PivotWriter::default(); - let mut sheets_xml = String::new(); - let mut ids = crate::passthrough::SheetIds::new(wb, &kept); - for (i, sheet) in wb.sheets.iter().enumerate() { - sheets_xml.push_str(&kept.sheet_entries(i, false, &mut ids)); - let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, &mut out); - pivots.add_sheet(wb, i, &mut out, &mut rels); - let name = format!("xl/worksheets/sheet{}.xml", i + 1); - out.part(&name, Some(CT_WORKSHEET), xml.into_bytes()); - if !rels.is_empty() { - out.part(&format!("xl/worksheets/_rels/sheet{}.xml.rels", i + 1), None, rels.xml().into_bytes()); - } - let rid = wb_rels.add("worksheet", &format!("worksheets/sheet{}.xml", i + 1)); - let state = match sheet.visibility { - Visibility::Visible => "", - _ if !any_visible && i == active => "", - Visibility::Hidden => " state=\"hidden\"", - Visibility::VeryHidden => " state=\"veryHidden\"", - }; - let name: String = sheet.name.chars().take(31).collect(); - let sheet_id = ids.take(sheet.file_id); - let _ = write!(sheets_xml, "", esc_attr(&name)); - } - sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, &mut ids)); + let (sheets_xml, kept, pivots) = write_sheets(wb, &mut out, active, &mut wb_rels); - // Defined names, then print areas and titles. - let mut names = String::new(); - let mut seen = std::collections::HashSet::new(); - for n in &wb.names { - if n.name.is_empty() || !seen.insert((n.name.to_ascii_lowercase(), n.scope)) { - continue; - } - if let Some(sc) = n.scope - && sc >= wb.sheets.len() - { - continue; - } - let _ = write!(names, "", esc(&crate::fmla::text_to_file(&n.formula))); - } - for (i, f) in out.chart_names.iter().enumerate() { - let _ = write!( - names, - "", - crate::chartex::NAME_PREFIX, - esc(&crate::fmla::text_to_file(f)) - ); - } - for (i, sheet) in wb.sheets.iter().enumerate() { - let q = quote_sheet(&sheet.name); - if let Some(pa) = &sheet.print.print_area { - let _ = write!( - names, - "{}!{}", - kept.file_index(i), - esc(&q), - abs_range(pa) - ); - } - let mut titles = vec![]; - if let Some((c0, c1)) = sheet.print.title_cols { - titles.push(format!("{q}!{}", abs_range(&RangeRef::cols(c0.min(c1), c0.max(c1))))); - } - if let Some((r0, r1)) = sheet.print.title_rows { - titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1))))); - } - if !titles.is_empty() { - let _ = write!( - names, - "{}", - kept.file_index(i), - esc(&titles.join(",")) - ); - } - } + let names = write_defined_names(wb, &kept, &out.chart_names); let mut w = format!("{XML_DECL}"); // A workbook read from a file keeps its as it was: Excel trusts the saved values @@ -510,5 +426,106 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) zip.finish() } +fn write_sheets( + wb: &Workbook, + out: &mut Out, + active: usize, + wb_rels: &mut Rels, +) -> (String, crate::passthrough::WorkbookKept, crate::pivot::PivotWriter) { + // Kept content first: the chart sheets among the worksheets need their relationship ids. + let kept = crate::passthrough::write_workbook(wb, wb_rels, out); + let mut pivots = crate::pivot::PivotWriter::default(); + let mut sheets_xml = String::new(); + let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible); + let mut ids = crate::passthrough::SheetIds::new(wb, &kept); + for (i, sheet) in wb.sheets.iter().enumerate() { + sheets_xml.push_str(&kept.sheet_entries(i, false, &mut ids)); + let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, out); + pivots.add_sheet(wb, i, out, &mut rels); + let name = format!("xl/worksheets/sheet{}.xml", i + 1); + out.part(&name, Some(CT_WORKSHEET), xml.into_bytes()); + if !rels.is_empty() { + out.part(&format!("xl/worksheets/_rels/sheet{}.xml.rels", i + 1), None, rels.xml().into_bytes()); + } + let rid = wb_rels.add("worksheet", &format!("worksheets/sheet{}.xml", i + 1)); + let state = match sheet.visibility { + Visibility::Visible => "", + _ if !any_visible && i == active => "", + Visibility::Hidden => " state=\"hidden\"", + Visibility::VeryHidden => " state=\"veryHidden\"", + }; + let name: String = sheet.name.chars().take(31).collect(); + let sheet_id = ids.take(sheet.file_id); + let _ = write!(sheets_xml, "", esc_attr(&name)); + } + sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, &mut ids)); + + (sheets_xml, kept, pivots) +} + +fn write_defined_names(wb: &Workbook, kept: &crate::passthrough::WorkbookKept, chart_names: &[String]) -> String { + // Defined names, then print areas and titles. + let mut names = String::new(); + let mut seen = std::collections::HashSet::new(); + for n in &wb.names { + if n.name.is_empty() || !seen.insert((n.name.to_ascii_lowercase(), n.scope)) { + continue; + } + if let Some(sc) = n.scope + && sc >= wb.sheets.len() + { + continue; + } + let _ = write!(names, "", esc(&crate::fmla::text_to_file(&n.formula))); + } + for (i, f) in chart_names.iter().enumerate() { + let _ = write!( + names, + "", + crate::chartex::NAME_PREFIX, + esc(&crate::fmla::text_to_file(f)) + ); + } + for (i, sheet) in wb.sheets.iter().enumerate() { + let q = quote_sheet(&sheet.name); + if let Some(pa) = &sheet.print.print_area { + let _ = write!( + names, + "{}!{}", + kept.file_index(i), + esc(&q), + abs_range(pa) + ); + } + let mut titles = vec![]; + if let Some((c0, c1)) = sheet.print.title_cols { + titles.push(format!("{q}!{}", abs_range(&RangeRef::cols(c0.min(c1), c0.max(c1))))); + } + if let Some((r0, r1)) = sheet.print.title_rows { + titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1))))); + } + if !titles.is_empty() { + let _ = write!( + names, + "{}", + kept.file_index(i), + esc(&titles.join(",")) + ); + } + } + + names +} + /// Cell metadata declaring dynamic-array formulas (MS-XLSX `XLDAPR`). const METADATA: &str = "\n"; From 287acf7ad6945f55509e65d547b0a9b33751858b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 10/13] Refactor pdf for strict Clippy checks --- crates/pdf/README.md | 33 +++++++++++ crates/pdf/src/lib.rs | 71 +++++++++++++++++------- crates/pdf/src/tests.rs | 120 ++++++++++++++++++++++------------------ 3 files changed, 151 insertions(+), 73 deletions(-) create mode 100644 crates/pdf/README.md diff --git a/crates/pdf/README.md b/crates/pdf/README.md new file mode 100644 index 000000000..cdb6308eb --- /dev/null +++ b/crates/pdf/README.md @@ -0,0 +1,33 @@ +# gridcraft-pdf + +A small, dependency-free PDF 1.7 writer for vector drawing and RGBA images. + +## API migration + +The geometry-taking methods now use `Point` and `Rect` values to name their coordinates, and +`image_rgba` groups its pixel dimensions into an array. Update calls to the four changed methods as +follows: + +```rust +use gridcraft_pdf::{Font, PdfDoc, Point, Rect}; + +let mut doc = PdfDoc::new(); +let page = doc.add_page(612.0, 792.0); + +// Before: page.link_page(20.0, 30.0, 80.0, 16.0, 1, 40.0, 50.0); +page.link_page(Rect::new(20.0, 30.0, 80.0, 16.0), 1, Point::new(40.0, 50.0)); + +// Before: page.stroke_line_dashed(0.0, 0.0, 100.0, 40.0, 1.0, [0, 0, 0], &[4.0, 2.0]); +page.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(100.0, 40.0), 1.0, [0, 0, 0], &[4.0, 2.0]); + +// Before: page.text_rotated(20.0, 40.0, 12.0, Font::Helvetica, [0, 0, 0], "Label", 0.2); +page.text_rotated(Point::new(20.0, 40.0), 12.0, Font::Helvetica, [0, 0, 0], "Label", 0.2); + +let rgba = [255, 0, 0, 255]; +// Before: page.image_rgba(10.0, 10.0, 24.0, 24.0, &rgba, 1, 1); +page.image_rgba(Rect::new(10.0, 10.0, 24.0, 24.0), &rgba, [1, 1]); +``` + +`Point::new(x, y)` uses the page's top-left origin, where y increases downward. `Rect::new(x, y, +width, height)` describes the destination or link rectangle in the same coordinate system. The +`image_rgba` pixel-size array is `[width, height]`. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e00b4fcc3..5270d4311 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -20,6 +20,34 @@ use std::fmt::Write as _; /// RGB colour, 0–255 per channel. pub type Rgb = [u8; 3]; +/// A point in page coordinates, measured from the top-left corner. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Point { + pub x: f32, + pub y: f32, +} + +impl Point { + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} + +/// A page-space rectangle described by its top-left corner and extent. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Rect { + pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { x, y, width, height } + } +} + /// The standard 14 fonts minus Symbol and ZapfDingbats. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Font { @@ -353,11 +381,12 @@ impl Page { } } - fn link_rect(&self, x: f32, y: f32, w: f32, h: f32) -> Option<[f32; 4]> { - if !finite(&[x, y, w, h]) || !extent(w) || !extent(h) || self.links.len() >= MAX_PAGE_LINKS { + fn link_rect(&self, rect: Rect) -> Option<[f32; 4]> { + let Rect { x, y, width, height } = rect; + if !finite(&[x, y, width, height]) || !extent(width) || !extent(height) || self.links.len() >= MAX_PAGE_LINKS { return None; } - let (right, bottom) = (x + w, y + h); + let (right, bottom) = (x + width, y + height); if !finite(&[right, bottom]) { return None; } @@ -374,18 +403,18 @@ impl Page { if !allowed || uri.len() > MAX_LINK_URI_BYTES || uri.chars().any(char::is_control) { return; } - if let Some(rect) = self.link_rect(x, y, w, h) { + if let Some(rect) = self.link_rect(Rect::new(x, y, w, h)) { self.links.push(LinkAnnotation { rect, target: LinkTarget::Uri(uri.to_string()) }); } } /// Adds a link to a location on another page of this document (zero-based page index). - pub fn link_page(&mut self, x: f32, y: f32, w: f32, h: f32, page: usize, target_x: f32, target_y: f32) { - if !finite(&[target_x, target_y]) { + pub fn link_page(&mut self, rect: Rect, page: usize, target: Point) { + if !finite(&[target.x, target.y]) { return; } - if let Some(rect) = self.link_rect(x, y, w, h) { - self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target_x, y: target_y } }); + if let Some(rect) = self.link_rect(rect) { + self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target.x, y: target.y } }); } } @@ -424,12 +453,12 @@ impl Page { } pub fn stroke_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb) { - self.stroke_line_dashed(x0, y0, x1, y1, width, color, &[]); + self.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), width, color, &[]); } /// A line with a dash pattern (`[on, off, …]` in points; empty = solid). - pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) { - if !finite(&[x0, y0, x1, y1]) { + pub fn stroke_line_dashed(&mut self, start: Point, end: Point, width: f32, color: Rgb, dash: &[f32]) { + if !finite(&[start.x, start.y, end.x, end.y]) { return; } let s = format!( @@ -437,10 +466,10 @@ impl Page { rgb_op(color, true), n(stroke_width(width)), dash_values(dash), - n(x0), - n(self.y(y0)), - n(x1), - n(self.y(y1)) + n(start.x), + n(self.y(start.y)), + n(end.x), + n(self.y(end.y)) ); self.op(&s); } @@ -543,20 +572,20 @@ impl Page { /// Text with its baseline starting at `(x, y)`. pub fn text(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str) { - self.text_rotated(x, y, size, font, color, text, 0.0); + self.text_rotated(Point::new(x, y), size, font, color, text, 0.0); } /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate /// system) about its baseline origin. - pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { - if text.is_empty() || !extent(size) || !finite(&[x, y]) { + pub fn text_rotated(&mut self, origin: Point, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { + if text.is_empty() || !extent(size) || !finite(&[origin.x, origin.y]) { return; } let fi = font.index(); let size = n(size.min(10_000.0)); let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; // Clockwise in y-down = counter-clockwise negated in PDF's y-up space. - let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(x), n(self.y(y))); + let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y))); // Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj` // advances the text position, so the runs follow each other on the baseline. let mut rest = text; @@ -577,7 +606,9 @@ impl Page { /// Draws an RGBA image (`pw`×`ph` pixels, row-major, straight alpha) into the box /// `(x, y, w, h)`. Malformed or oversized buffers are ignored. - pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) { + pub fn image_rgba(&mut self, destination: Rect, rgba: &[u8], pixel_size: [u32; 2]) { + let Rect { x, y, width: w, height: h } = destination; + let [pw, ph] = pixel_size; let px = pw as u64 * ph as u64; if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e540685d0..bd1ad73a8 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1,4 +1,6 @@ use super::*; +use std::error::Error; +use std::io; fn find(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) @@ -8,22 +10,23 @@ fn rfind(hay: &[u8], needle: &[u8]) -> Option { } /// Parses the xref table back and checks each in-use entry points at `N 0 obj`. -fn check_xref(pdf: &[u8]) -> usize { - let sx = rfind(pdf, b"startxref\n").unwrap(); - let rest = std::str::from_utf8(&pdf[sx + 10..]).unwrap(); - let off: usize = rest.lines().next().unwrap().trim().parse().unwrap(); +fn check_xref(pdf: &[u8]) -> Result> { + let sx = rfind(pdf, b"startxref\n").ok_or_else(|| io::Error::other("missing startxref"))?; + let rest = std::str::from_utf8(&pdf[sx + 10..])?; + let off: usize = rest.lines().next().ok_or_else(|| io::Error::other("missing xref offset"))?.trim().parse()?; assert!(pdf[off..].starts_with(b"xref\n"), "startxref points at xref"); - let table = std::str::from_utf8(&pdf[off..sx]).unwrap(); + let table = std::str::from_utf8(&pdf[off..sx])?; let mut lines = table.lines(); assert_eq!(lines.next(), Some("xref")); - let hdr: Vec = lines.next().unwrap().split(' ').map(|x| x.parse().unwrap()).collect(); - assert_eq!(hdr[0], 0); - let count = hdr[1]; + let hdr: Vec = + lines.next().ok_or_else(|| io::Error::other("missing xref header"))?.split(' ').map(str::parse).collect::>()?; + assert_eq!(hdr.first(), Some(&0)); + let count = *hdr.get(1).ok_or_else(|| io::Error::other("missing xref count"))?; let mut checked = 0; for i in 0..count { - let e = lines.next().unwrap(); + let e = lines.next().ok_or_else(|| io::Error::other("missing xref entry"))?; assert_eq!(e.len(), 19, "entry `{e}` (20 bytes with newline)"); - let o: usize = e[..10].parse().unwrap(); + let o: usize = e.get(..10).ok_or_else(|| io::Error::other("short xref offset"))?.parse()?; if e.trim_end().ends_with('n') { let want = format!("{i} 0 obj"); assert!(pdf[o..].starts_with(want.as_bytes()), "object {i} at {o}"); @@ -32,38 +35,39 @@ fn check_xref(pdf: &[u8]) -> usize { } assert!(table.contains("trailer")); assert!(table.contains(&format!("/Size {count}"))); - checked + Ok(checked) } #[test] -fn minimal_document() { +fn minimal_document() -> Result<(), Box> { let pdf = PdfDoc::new().finish(); assert!(pdf.starts_with(b"%PDF-1.7")); assert!(pdf.ends_with(b"%%EOF\n")); - assert!(check_xref(&pdf) >= 5); + assert!(check_xref(&pdf)? >= 5); + Ok(()) } #[test] -fn drawing_and_text() { +fn drawing_and_text() -> Result<(), Box> { let mut d = PdfDoc::new(); d.info.title = "Report (Q1)".into(); let p = d.add_page(612.0, 792.0); p.fill_rect(10.0, 10.0, 100.0, 20.0, [255, 255, 0]); p.rect_stroke(10.0, 10.0, 100.0, 20.0, 0.5, [0, 0, 0]); p.stroke_line(0.0, 0.0, 612.0, 792.0, 1.0, [255, 0, 0]); - p.stroke_line_dashed(0.0, 5.0, 100.0, 5.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + p.stroke_line_dashed(Point::new(0.0, 5.0), Point::new(100.0, 5.0), 1.0, [0, 0, 0], &[3.0, 2.0]); p.polygon(&[[0.0, 0.0], [10.0, 0.0], [5.0, 8.0]], Some([0, 0, 255]), Some(([0, 0, 0], 1.0))); p.circle(300.0, 300.0, 20.0, Some([0, 128, 0]), None); p.save(); p.clip_rect(0.0, 0.0, 50.0, 50.0); p.text(72.0, 72.0, 12.0, Font::Helvetica, [0, 0, 0], "Hello (world) \\ café €5"); p.restore(); - p.text_rotated(100.0, 100.0, 9.0, Font::TimesBold, [0, 0, 0], "Up", -std::f32::consts::FRAC_PI_2); + p.text_rotated(Point::new(100.0, 100.0), 9.0, Font::TimesBold, [0, 0, 0], "Up", -std::f32::consts::FRAC_PI_2); let p2 = d.add_page(842.0, 595.0); p2.text(10.0, 20.0, 10.0, Font::CourierBold, [0, 0, 0], "Second"); assert_eq!(d.page_count(), 2); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; assert!(find(&pdf, b"(Hello \\(world\\) \\\\ caf\\351 \\2005) Tj").is_some()); assert!(find(&pdf, b"(Second) Tj").is_some()); assert!(find(&pdf, b"/BaseFont /Helvetica /Encoding /WinAnsiEncoding").is_some()); @@ -74,21 +78,22 @@ fn drawing_and_text() { assert!(find(&pdf, b"/Title (Report \\(Q1\\))").is_some()); // Top-left origin: a line from (0,0) starts at the top of the page. assert!(find(&pdf, b"0 792 m 612 0 l S").is_some()); + Ok(()) } #[test] -fn uri_and_page_links_are_real_pdf_annotations() { +fn uri_and_page_links_are_real_pdf_annotations() -> Result<(), Box> { let mut d = PdfDoc::new(); let first = d.add_page(200.0, 300.0); first.link_uri(10.0, 20.0, 80.0, 30.0, "https://example.org/a(b)?q=£"); - first.link_page(100.0, 40.0, 50.0, 20.0, 1, 35.0, 60.0); + first.link_page(Rect::new(100.0, 40.0, 50.0, 20.0), 1, Point::new(35.0, 60.0)); first.link_uri(-20.0, -20.0, 40.0, 40.0, "mailto:a@example.org"); first.link_uri(0.0, 0.0, f32::NAN, 10.0, "https://bad.example"); first.link_uri(0.0, 0.0, 10.0, 10.0, "javascript:alert(1)"); let second = d.add_page(100.0, 200.0); - second.link_page(0.0, 0.0, 10.0, 10.0, 99, 0.0, 0.0); + second.link_page(Rect::new(0.0, 0.0, 10.0, 10.0), 99, Point::new(0.0, 0.0)); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; let s = String::from_utf8_lossy(&pdf); assert_eq!(s.matches("/Subtype /Link").count(), 3); assert!(s.contains("/Annots [")); @@ -97,25 +102,27 @@ fn uri_and_page_links_are_real_pdf_annotations() { assert!(s.contains("/URI (https://example.org/a\\(b\\)?q=%C2%A3)")); assert!(s.contains("/XYZ 35 140 null")); assert!(!s.contains("https://bad.example")); + Ok(()) } #[test] -fn images_with_alpha() { +fn images_with_alpha() -> Result<(), Box> { let mut d = PdfDoc::new(); let p = d.add_page(200.0, 200.0); let mut px = Vec::new(); for i in 0..16u8 { px.extend_from_slice(&[i * 10, 0, 0, if i < 8 { 255 } else { 128 }]); } - p.image_rgba(10.0, 10.0, 40.0, 40.0, &px, 4, 4); - p.image_rgba(10.0, 10.0, 40.0, 40.0, &[255; 16], 2, 2); // opaque: no mask - p.image_rgba(0.0, 0.0, 1.0, 1.0, &[1, 2, 3], 4, 4); // too short: ignored + p.image_rgba(Rect::new(10.0, 10.0, 40.0, 40.0), &px, [4, 4]); + p.image_rgba(Rect::new(10.0, 10.0, 40.0, 40.0), &[255; 16], [2, 2]); // opaque: no mask + p.image_rgba(Rect::new(0.0, 0.0, 1.0, 1.0), &[1, 2, 3], [4, 4]); // too short: ignored let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; assert!(find(&pdf, b"/SMask").is_some()); assert!(find(&pdf, b"/Im1 Do").is_some()); assert!(find(&pdf, b"/Im2 Do").is_some()); assert!(find(&pdf, b"/Im3").is_none()); + Ok(()) } #[test] @@ -166,7 +173,7 @@ fn widths() { } #[test] -fn hostile_inputs_never_panic() { +fn hostile_inputs_never_panic() -> Result<(), Box> { let mut d = PdfDoc::new(); let p = d.add_page(f32::NAN, -5.0); p.text(f32::INFINITY, f32::NAN, 1e30, Font::Helvetica, [0, 0, 0], "x"); @@ -176,9 +183,10 @@ fn hostile_inputs_never_panic() { p.restore(); p.restore(); p.save(); - p.image_rgba(0.0, 0.0, 10.0, 10.0, &[], u32::MAX, u32::MAX); + p.image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &[], [u32::MAX, u32::MAX]); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; + Ok(()) } fn page_content(d: &PdfDoc) -> &str { @@ -198,9 +206,9 @@ fn text_width_does_not_overflow() { fn all_zero_dash_is_drawn_solid() { let mut d = PdfDoc::new(); let p = d.add_page(100.0, 100.0); - p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]); + p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(50.0, 0.0), 1.0, [0, 0, 0], &[0.0, 0.0]); p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]); - p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(50.0, 0.0), 1.0, [0, 0, 0], &[3.0, 2.0]); let c = page_content(&d); assert_eq!(c.matches("[] 0 d").count(), 2); assert!(!c.contains("[0 0]")); @@ -226,15 +234,15 @@ fn non_finite_image_size_stores_nothing() { let p = d.add_page(200.0, 200.0); let px = [255u8; 16]; for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] { - p.image_rgba(0.0, 0.0, w, h, &px, 2, 2); + p.image_rgba(Rect::new(0.0, 0.0, w, h), &px, [2, 2]); } - p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2); + p.image_rgba(Rect::new(f32::NAN, 0.0, 10.0, 10.0), &px, [2, 2]); assert!(p.images.is_empty()); assert!(p.content.is_empty()); } #[test] -fn every_draw_method_survives_hostile_numbers() { +fn every_draw_method_survives_hostile_numbers() -> Result<(), Box> { let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30]; let px = [200u8; 16]; let mut d = PdfDoc::new(); @@ -245,41 +253,45 @@ fn every_draw_method_survives_hostile_numbers() { p.fill_rect(a, b, b, a, [1, 2, 3]); p.rect_stroke(a, b, a, b, a, [1, 2, 3]); p.stroke_line(a, b, b, a, a, [1, 2, 3]); - p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]); + p.stroke_line_dashed(Point::new(a, b), Point::new(b, a), b, [1, 2, 3], &[a, b, 0.0]); p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]); p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a))); p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a))); p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x"); - p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b); - p.image_rgba(a, b, b, a, &px, 2, 2); + p.text_rotated(Point::new(a, b), 10.0, Font::TimesRoman, [0, 0, 0], "y", b); + p.image_rgba(Rect::new(a, b, b, a), &px, [2, 2]); } } - p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]); + p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(1.0, 1.0), 1.0, [0, 0, 0], &[0.0; 100]); let c = page_content(&d); assert!(!c.contains("NaN") && !c.contains("inf")); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; + Ok(()) } /// Decodes the `` hex string following `key` in the file bytes. -fn utf16_entry(pdf: &[u8], key: &str) -> String { - let at = find(pdf, key.as_bytes()).unwrap() + key.len(); - let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or(""); - let hex = rest.trim_start().strip_prefix("').unwrap()]; - let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect(); - String::from_utf16(&units).unwrap() +fn utf16_entry(pdf: &[u8], key: &str) -> Result> { + let at = find(pdf, key.as_bytes()).ok_or_else(|| io::Error::other("missing PDF info key"))? + key.len(); + let end = at + 9000.min(pdf.len() - at); + let rest = std::str::from_utf8(pdf.get(at..end).ok_or_else(|| io::Error::other("short PDF info value"))?)?; + let hex = rest.trim_start().strip_prefix("').ok_or_else(|| io::Error::other("missing UTF-16 terminator"))?; + let hex = hex.get(..end).ok_or_else(|| io::Error::other("invalid UTF-16 range"))?; + let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(hex.get(i..i + 4).unwrap_or(""), 16)).collect::>()?; + Ok(String::from_utf16(&units)?) } #[test] -fn info_strings_with_non_ascii_are_utf16be() { +fn info_strings_with_non_ascii_are_utf16be() -> Result<(), Box> { let mut doc = PdfDoc::new(); doc.add_page(200.0, 200.0); doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into(); doc.info.author = "Müller & Søn".into(); let pdf = doc.finish(); - assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀"); - assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn"); + assert_eq!(utf16_entry(&pdf, "/Title")?, "Übersicht €5 – “Q1” 日本 😀"); + assert_eq!(utf16_entry(&pdf, "/Author")?, "Müller & Søn"); + Ok(()) } #[test] @@ -293,22 +305,23 @@ fn ascii_info_strings_stay_literal_and_escaped() { } #[test] -fn info_strings_are_capped() { +fn info_strings_are_capped() -> Result<(), Box> { let mut doc = PdfDoc::new(); doc.add_page(200.0, 200.0); doc.info.title = "日".repeat(100_000); let pdf = doc.finish(); - assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS); + assert_eq!(utf16_entry(&pdf, "/Title")?.chars().count(), MAX_INFO_CHARS); + Ok(()) } #[test] -fn cjk_text_is_not_replaced_by_question_marks() { +fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box> { let mut doc = PdfDoc::new(); let p = doc.add_page(612.0, 792.0); p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], "签字清单"); p.text(10.0, 40.0, 10.0, Font::Helvetica, [0, 0, 0], "No. 编号 2"); let pdf = doc.finish(); - check_xref(&pdf); + check_xref(&pdf)?; assert!(find(&pdf, b"(????) Tj").is_none(), "CJK text written as question marks"); assert!(find(&pdf, b"<7B7E5B576E055355> Tj").is_some()); assert!(find(&pdf, b"(No. ) Tj").is_some()); @@ -317,4 +330,5 @@ fn cjk_text_is_not_replaced_by_question_marks() { assert!(find(&pdf, b"/Encoding /UniGB-UTF16-H").is_some()); // Measured as full-width glyphs, matching the CID font's default width. assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0); + Ok(()) } From 9717ccbac13ada7933609062e6e8ba90829ec5bc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 10:52:53 +0000 Subject: [PATCH 11/13] Refactor engine for strict Clippy checks --- crates/engine/src/cf.rs | 172 +++-- crates/engine/src/cmd.rs | 19 +- crates/engine/src/cmd/analysis.rs | 2 +- crates/engine/src/cmd/data.rs | 10 +- crates/engine/src/cmd/data/filter.rs | 5 +- crates/engine/src/cmd/data/grouping.rs | 5 +- crates/engine/src/cmd/data/sort.rs | 5 +- crates/engine/src/cmd/data/tools.rs | 5 +- crates/engine/src/cmd/data/validation.rs | 5 +- crates/engine/src/cmd/draw.rs | 4 +- crates/engine/src/cmd/edit.rs | 5 +- crates/engine/src/cmd/edit/cell.rs | 5 +- crates/engine/src/cmd/edit/clipboard.rs | 4 +- crates/engine/src/cmd/edit/fill.rs | 4 +- crates/engine/src/cmd/edit/find.rs | 2 +- crates/engine/src/cmd/edit/go_to.rs | 2 +- crates/engine/src/cmd/edit/paste_special.rs | 397 +++++----- crates/engine/src/cmd/edit/selection.rs | 5 +- crates/engine/src/cmd/extra.rs | 10 +- crates/engine/src/cmd/extra/cells.rs | 5 +- crates/engine/src/cmd/extra/chart.rs | 5 +- crates/engine/src/cmd/extra/data.rs | 2 +- crates/engine/src/cmd/extra/objects.rs | 4 +- crates/engine/src/cmd/extra/scripts.rs | 2 +- crates/engine/src/cmd/extra/window.rs | 5 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/forecast.rs | 2 +- crates/engine/src/cmd/format.rs | 7 +- crates/engine/src/cmd/format/cell_styles.rs | 4 +- crates/engine/src/cmd/format/sizes.rs | 4 +- crates/engine/src/cmd/formulas.rs | 5 +- crates/engine/src/cmd/insert.rs | 11 +- crates/engine/src/cmd/insert/chart.rs | 5 +- crates/engine/src/cmd/insert/cond_format.rs | 5 +- crates/engine/src/cmd/insert/links.rs | 5 +- crates/engine/src/cmd/insert/objects.rs | 5 +- crates/engine/src/cmd/insert/table.rs | 5 +- crates/engine/src/cmd/inspect.rs | 2 +- crates/engine/src/cmd/pivot.rs | 10 +- crates/engine/src/cmd/pivot/charts.rs | 5 +- crates/engine/src/cmd/pivot/create.rs | 17 +- crates/engine/src/cmd/pivot/fields.rs | 32 +- crates/engine/src/cmd/pivot/layout.rs | 5 +- crates/engine/src/cmd/pivot/params.rs | 2 +- crates/engine/src/cmd/pivot/tests.rs | 218 +++--- crates/engine/src/cmd/print.rs | 20 +- crates/engine/src/cmd/print/draw.rs | 32 +- crates/engine/src/cmd/print/header_footer.rs | 2 +- crates/engine/src/cmd/print/layout.rs | 10 +- crates/engine/src/cmd/print/objects.rs | 15 +- crates/engine/src/cmd/print/text.rs | 111 +-- crates/engine/src/cmd/review.rs | 2 +- crates/engine/src/cmd/sheet/tabs.rs | 2 +- crates/engine/src/cmd/slicer.rs | 6 +- crates/engine/src/cmd/solver.rs | 15 +- crates/engine/src/cmd/solver/evolutionary.rs | 2 +- crates/engine/src/cmd/solver/lp.rs | 354 +++++---- crates/engine/src/cmd/solver/model.rs | 5 +- crates/engine/src/cmd/solver/nlp.rs | 2 +- crates/engine/src/cmd/solver/problem.rs | 2 +- crates/engine/src/cmd/solver/reports.rs | 7 +- crates/engine/src/cmd/spelling.rs | 2 +- crates/engine/src/cmd/view.rs | 10 +- crates/engine/src/cmd/whatif.rs | 2 +- .../engine/src/cmd/whatif/advanced_filter.rs | 2 +- crates/engine/src/cmd/whatif/consolidate.rs | 4 +- crates/engine/src/cmd/whatif/scenarios.rs | 5 +- crates/engine/src/fill.rs | 13 +- crates/engine/src/io.rs | 1 + crates/engine/src/pivot/charts.rs | 2 +- crates/engine/src/pivot/compute.rs | 695 +++++++++++------- crates/engine/src/pivot/items.rs | 2 +- crates/engine/src/pivot/output.rs | 5 +- crates/engine/src/tests.rs | 31 +- 74 files changed, 1404 insertions(+), 997 deletions(-) diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 0c0f9683e..4c9325e36 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -136,6 +136,95 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { pub use gridcraft_model::icon_count; +struct RuleEval<'a> { + wb: &'a Workbook, + sheet: usize, + cell: CellRef, + first: CellRef, + value: &'a Value, + text: &'a str, + stats: &'a RuleStats, +} + +fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> { + let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx; + match rule { + CfRule::CellIs { op, a, b, style } => { + let eval = |f: &str| gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell)); + let av = eval(a); + let matches = match op { + CfOperator::Between | CfOperator::NotBetween => { + let bv = b.as_deref().map(eval).unwrap_or_default(); + let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) }; + let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater; + if *op == CfOperator::Between { within } else { !within } + } + CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal, + CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal, + CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater, + CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less, + CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less, + CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater, + }; + (matches && !value.is_error()).then_some(style.as_ref()) + } + CfRule::Expression { formula, style } => { + gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref()) + } + CfRule::ContainsText { text: needle, style } => { + (!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::NotContainsText { text: needle, style } => { + (value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::BeginsWith { text: needle, style } => { + (!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::EndsWith { text: needle, style } => { + (!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), + CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), + CfRule::Errors { style } => value.is_error().then_some(style.as_ref()), + CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()), + CfRule::Duplicate { style } => { + (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) + } + CfRule::Unique { style } => { + (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) + } + _ => None, + } +} + +fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool { + let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor(); + let day = serial.floor(); + let date = |serial| gridcraft_core::date::datetime_from_serial(wb.date_system, serial); + let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0); + let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1)); + let year_month = date(day).map(|value| (value.year, value.month)); + let month_matches = |offset: i32| { + let shifted_month = month as i32 + offset; + let shifted_year = year + (shifted_month - 1).div_euclid(12); + let shifted_month = (shifted_month - 1).rem_euclid(12) + 1; + year_month == Some((shifted_year, shifted_month as u32)) + }; + match period { + "yesterday" => day == today - 1.0, + "today" => day == today, + "tomorrow" => day == today + 1.0, + "last7Days" => day > today - 7.0 && day <= today, + "lastWeek" => day >= week_start - 7.0 && day < week_start, + "thisWeek" => day >= week_start && day < week_start + 7.0, + "nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0, + "lastMonth" => month_matches(-1), + "thisMonth" => month_matches(0), + "nextMonth" => month_matches(1), + _ => false, + } +} + impl CfCache { pub fn new() -> Self { Self::default() @@ -161,52 +250,8 @@ impl CfCache { None => &no_stats, }; let text = v.display(); - let matched_style: Option<&Style> = match &cf.rule { - CfRule::CellIs { op, a, b, style } => { - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, c, &relative(f, first, c)); - let av = eval(a); - let ok = match op { - CfOperator::Between | CfOperator::NotBetween => { - let bv = b.as_deref().map(eval).unwrap_or_default(); - let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) }; - let inside = compare(&v, &lo) != std::cmp::Ordering::Less && compare(&v, &hi) != std::cmp::Ordering::Greater; - if *op == CfOperator::Between { inside } else { !inside } - } - CfOperator::Equal => compare(&v, &av) == std::cmp::Ordering::Equal, - CfOperator::NotEqual => compare(&v, &av) != std::cmp::Ordering::Equal, - CfOperator::Greater => compare(&v, &av) == std::cmp::Ordering::Greater, - CfOperator::Less => compare(&v, &av) == std::cmp::Ordering::Less, - CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less, - CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater, - }; - // As in Excel, a blank cell compares as 0 (or ""), and an error never matches. - (ok && !v.is_error()).then_some(style.as_ref()) - } - CfRule::Expression { formula, style } => { - gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) - } - // As in Excel, an error value never contains, begins or ends with anything (its rule - // formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162). - CfRule::ContainsText { text: t, style } => { - (!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::NotContainsText { text: t, style } => { - (v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::BeginsWith { text: t, style } => { - (!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()), - CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), - CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), - CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), - CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), - CfRule::Duplicate { style } => { - (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) - } - CfRule::Unique { style } => { - (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) - } + let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st }; + let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule { CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() { Some(n) if !st.sorted.is_empty() => { let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize }; @@ -229,36 +274,8 @@ impl CfCache { None => None, }, CfRule::TimePeriod { period, style } => match v.as_f64() { - Some(n) => { - let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor(); - let d = n.floor(); - let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0); - let week_start = today - wd; - let (ty, tm) = - gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1)); - let ym = gridcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month)); - let month_off = |k: i32| { - let m = tm as i32 + k; - let y = ty + (m - 1).div_euclid(12); - let m = (m - 1).rem_euclid(12) + 1; - ym == Some((y, m as u32)) - }; - let ok = match period.as_str() { - "yesterday" => d == today - 1.0, - "today" => d == today, - "tomorrow" => d == today + 1.0, - "last7Days" => d > today - 7.0 && d <= today, - "lastWeek" => d >= week_start - 7.0 && d < week_start, - "thisWeek" => d >= week_start && d < week_start + 7.0, - "nextWeek" => d >= week_start + 7.0 && d < week_start + 14.0, - "lastMonth" => month_off(-1), - "thisMonth" => month_off(0), - "nextMonth" => month_off(1), - _ => false, - }; - ok.then_some(style.as_ref()) - } - None => None, + Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()), + _ => None, }, CfRule::ColorScale { stops } => { if let Some(n) = v.as_f64() { @@ -329,7 +346,8 @@ impl CfCache { } None } - }; + _ => None, + }); if let Some(st) = matched_style { any = true; // Every matching rule applies; where two set the same part, the rule with the diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index ef6714eea..90c8a5227 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -205,7 +205,6 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) { } fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> { - #[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery match TEST_COMMANDS.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), @@ -349,11 +348,15 @@ pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> }) } +fn wall_clock() -> std::time::Duration { + std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default() +} + pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) -> Result { - // `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there - // `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app. - #[allow(clippy::disallowed_methods)] - let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) }; + // `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch + // reads the wall clock without the (disallowed) `now`. + let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) }; + let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0); let mut sel = d.selection.clone(); let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false }; let r = f(&mut ctx)?; @@ -368,7 +371,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } if let Some(t0) = t0 { - eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len()); + eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len()); } if structural { d.calc.recalc_all(&mut wb); @@ -376,7 +379,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) d.calc.cells_changed(&mut wb, &changed); } if let Some(t0) = t0 { - eprintln!("commit: + recalc {:?}", t0.elapsed()); + eprintln!("commit: + recalc {:?}", since(t0)); } fit_rows.sort_unstable(); fit_rows.dedup(); @@ -384,7 +387,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) auto_row_height(&mut wb, si, row); } if let Some(t0) = t0 { - eprintln!("commit: + rows {:?}", t0.elapsed()); + eprintln!("commit: + rows {:?}", since(t0)); } d.wb = std::sync::Arc::new(wb); d.selection = sel; diff --git a/crates/engine/src/cmd/analysis.rs b/crates/engine/src/cmd/analysis.rs index 6225b4934..9552e95f7 100644 --- a/crates/engine/src/cmd/analysis.rs +++ b/crates/engine/src/cmd/analysis.rs @@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet}; use serde_json::{Value as Json, json}; use super::whatif::parse_range; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param}; const CMD: &str = "data.analysisToolPak"; /// Most input cells a tool reads. diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 5fa076e64..143763a5e 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -4,10 +4,16 @@ use std::cmp::Ordering; use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value, sort_compare}; -use gridcraft_model::*; +use gridcraft_model::{ + Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation, + ValidationKind, Workbook, +}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cell_param_on, cmd, edit, has_doc, ok, parse_lines, split_sheet, + str_param, target_range, target_ranges, target_sheet, +}; use crate::outline; use crate::selection::current_region; diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index 71aa698f4..1b52e4537 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -1,5 +1,8 @@ use super::sort::sort_key; -use super::*; +use super::{ + AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param, + current_region, data_target, edit, json, +}; // ---------------------------------------------------------------- filter diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs index 7b5a2254c..d2e183ae1 100644 --- a/crates/engine/src/cmd/data/grouping.rs +++ b/crates/engine/src/cmd/data/grouping.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines, + str_param, target_sheet, +}; // ---------------------------------------------------------------- outline diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs index 919de1734..a36527650 100644 --- a/crates/engine/src/cmd/data/sort.rs +++ b/crates/engine/src/cmd/data/sort.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet, + Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet, +}; #[derive(Clone)] struct SortKey { diff --git a/crates/engine/src/cmd/data/tools.rs b/crates/engine/src/cmd/data/tools.rs index 1603b2cb4..2cbca17bd 100644 --- a/crates/engine/src/cmd/data/tools.rs +++ b/crates/engine/src/cmd/data/tools.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json, + ok, str_param, target_range, target_sheet, +}; // ---------------------------------------------------------------- tools diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index 6d4ae73cf..ce0d7e3b8 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad, + bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet, +}; // ---------------------------------------------------------------- validation diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index a86be3099..f6184509c 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1,10 +1,10 @@ //! Draw tab: pen and eraser tools, ink strokes, Ink to Shape. use gridcraft_core::CellRef; -use gridcraft_model::*; +use gridcraft_model::{Anchor, Color, Shape, ShapeKind}; use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e48fe8838..f977a95ce 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,10 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Formula, StyleId}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc, + locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param, +}; use crate::selection::{current_region, jump}; use crate::{Clipboard, Session}; diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index 62a6b38bd..c57afae97 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, + locked_cell_error, str_param, target_range, target_ranges, target_sheet, +}; // ---------------------------------------------------------------- entry diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 60c05c687..de921226e 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,6 +1,8 @@ use super::cell::input_to_cell; use super::paste_special::paste_special; -use super::*; +use super::{ + Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet, +}; // ---------------------------------------------------------------- clipboard diff --git a/crates/engine/src/cmd/edit/fill.rs b/crates/engine/src/cmd/edit/fill.rs index 84f4cd6d3..8cd1e3d88 100644 --- a/crates/engine/src/cmd/edit/fill.rs +++ b/crates/engine/src/cmd/edit/fill.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet}; // ---------------------------------------------------------------- fill @@ -78,7 +78,7 @@ pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result { let stop = f64_param(p, "stop"); let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); edit(s, |cx| { - crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?; + crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?; Ok(Json::Null) }) } diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index 2dcbfa886..bce0ba728 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -1,5 +1,5 @@ use super::cell::input_to_cell; -use super::*; +use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param}; // ---------------------------------------------------------------- find & replace diff --git a/crates/engine/src/cmd/edit/go_to.rs b/crates/engine/src/cmd/edit/go_to.rs index b94bcd221..bc4f414db 100644 --- a/crates/engine/src/cmd/edit/go_to.rs +++ b/crates/engine/src/cmd/edit/go_to.rs @@ -1,5 +1,5 @@ use super::selection::{select_region, selection_set}; -use super::*; +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param}; /// How many references the Go To dialog keeps for going back to. pub(super) const GO_TO_RECENT: usize = 10; diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index eba1b429e..0a6d14247 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -1,5 +1,76 @@ use super::clipboard::What; -use super::*; +use super::{ + Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param, + target_sheet, +}; + +fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator + Clone { + (0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c))))) +} + +fn apply_operation(op: &str, old: &Cell, new: &mut Cell) { + let operand = new.value.clone(); + if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number()) + && (old.value.is_number() || old.value.is_empty() || old.value.is_text()) + && (operand.is_number() || operand.is_empty()) + { + new.value = match op { + "add" => Value::number(x + y), + "subtract" => Value::number(x - y), + "multiply" => Value::number(x * y), + "divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0), + "divide" => Value::number(x / y), + _ => operand.clone(), + }; + } else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) { + new.value = old.value.clone(); + } + if let Some(formula) = &old.formula + && let Ok(y) = operand.to_number() + && (operand.is_number() || operand.is_empty()) + && let Some(symbol) = operation_symbol(op) + { + let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y)); + new.formula = Some(std::sync::Arc::new(Formula::new(&text))); + new.value = Value::Empty; + } +} + +fn operation_symbol(op: &str) -> Option { + match op { + "add" => Some('+'), + "subtract" => Some('-'), + "multiply" => Some('*'), + "divide" => Some('/'), + _ => None, + } +} + +fn copy_merges( + cx: &mut crate::cmd::Ctx, + dest_sheet: usize, + src_sheet: &gridcraft_model::Sheet, + src: RangeRef, + at: CellRef, + what: What, + transpose: bool, +) -> Result<()> { + if !matches!(what, What::All | What::Formats) || transpose { + return Ok(()); + } + let merges: Vec = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect(); + for merge in merges { + let row_offset = at.row as i64 - src.start.row as i64; + let col_offset = at.col as i64 - src.start.col as i64; + if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) { + let moved = RangeRef::new(start, end); + let sheet = cx.sheet_mut(dest_sheet)?; + sheet.merges.retain(|existing| !existing.intersects(&moved)); + sheet.merges.push(moved); + } + } + Ok(()) +} pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; @@ -74,209 +145,145 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { } moved_formulas = taken; } - for tr in 0..tiles_r { - for tc in 0..tiles_c { - for r in 0..src.height() { - for c in 0..src.width() { - let sc = CellRef::new(src.start.row + r, src.start.col + c); - let (dr, dc) = if transpose { (c, r) } else { (r, c) }; - let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { - moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) + for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) { + let sc = CellRef::new(src.start.row + r, src.start.col + c); + let (dr, dc) = if transpose { (c, r) } else { (r, c) }; + let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; + let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() }; + let src_val = src_sheet.value(sc); + let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); + if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { + continue; + } + let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); + let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); + let mut new_picture = old_picture.clone(); + let mut new = old.clone(); + let shift = |cell: &Cell| -> Option> { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + if cut_move { + // A moved formula still reads cells on its original sheet. Qualify + // those references before the workbook-wide move adjustment below. + let e = if dest_sheet != clip.sheet { + e.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }) + } else { + e + }; + let mut moved = Formula::from_expr(e); + // The array range moves with its formula. + let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); + moved.dynamic = f.dynamic; + return Some(std::sync::Arc::new(moved)); + } + let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) + }; + if link { + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() + } else { + format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) + }; + new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); + new.value = Value::Empty; + new_picture = None; + } else { + let sc_cell = src_cell.clone().unwrap_or_default(); + match what { + What::All | What::AllExceptBorders => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + let st = map_style(sc_cell.style, &mut cx.wb); + new.style = if what == What::AllExceptBorders { + let borders = cx.wb.styles.get(old.style).borders; + cx.wb.styles.derive(st, |s| s.borders = borders) } else { - src_sheet.cell(sc).cloned() + st }; - let src_val = src_sheet.value(sc); - let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); - if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { - continue; + } + What::Formulas | What::FormulasAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + if what == What::FormulasAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); } - let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); - let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); - let mut new_picture = old_picture.clone(); - let mut new = old.clone(); - let shift = |cell: &Cell| -> Option> { - let f = cell.formula.as_ref()?; - let e = f.expr()?; - if cut_move { - // A moved formula still reads cells on its original sheet. Qualify - // those references before the workbook-wide move adjustment below. - let e = if dest_sheet != clip.sheet { - e.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }) - } else { - e - }; - let mut moved = Formula::from_expr(e); - // The array range moves with its formula. - let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); - moved.dynamic = f.dynamic; - return Some(std::sync::Arc::new(moved)); - } - let moved = - gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) - }; - if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { - String::new() - } else { - format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) - }; - new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); - new.value = Value::Empty; - new_picture = None; - } else { - let sc_cell = src_cell.clone().unwrap_or_default(); - match what { - What::All | What::AllExceptBorders => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - let st = map_style(sc_cell.style, &mut cx.wb); - new.style = if what == What::AllExceptBorders { - let borders = cx.wb.styles.get(old.style).borders; - cx.wb.styles.derive(st, |s| s.borders = borders) - } else { - st - }; - } - What::Formulas | What::FormulasAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - if what == What::FormulasAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Values | What::ValuesAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = None; - new.value = src_val.clone(); - if what == What::ValuesAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} - } - // Operations combine numbers with what is already there. - if op != "none" - && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) - && new.formula.is_none() - { - if old_picture.is_some() || new_picture.is_some() { - new_picture = None; - new.value = Value::Error(gridcraft_core::CellError::Value); - } - let a = old.value.clone(); - let b = new.value.clone(); - // Numbers stored as text take part (multiplying by 1 turns them into - // numbers); other text and errors stay as they are. - if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) - && (a.is_number() || a.is_empty() || a.is_text()) - && (b.is_number() || b.is_empty()) - { - new.value = match op.as_str() { - "add" => Value::number(x + y), - "subtract" => Value::number(x - y), - "multiply" => Value::number(x * y), - "divide" => { - if y == 0.0 { - Value::Error(gridcraft_core::CellError::Div0) - } else { - Value::number(x / y) - } - } - _ => b.clone(), - }; - } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { - new.value = a; - } - // A formula already there is kept and combined with the pasted number. - if let Some(f) = &old.formula - && let Ok(y) = b.to_number() - && (b.is_number() || b.is_empty()) - && let Some(sym) = match op.as_str() { - "add" => Some('+'), - "subtract" => Some('-'), - "multiply" => Some('*'), - "divide" => Some('/'), - _ => None, - } - { - let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); - new.formula = Some(std::sync::Arc::new(Formula::new(&text))); - new.value = Value::Empty; - } - } + } + What::Values | What::ValuesAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = None; + new.value = src_val.clone(); + if what == What::ValuesAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); } - // The copied cell's validation rule replaces whatever rule the destination had. - if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { - let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); - let sh = cx.sheet_mut(dest_sheet)?; - crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); - if let Some(mut dv) = rule { - // Relative references move with the rule, as pasted formulas do. - let first = dv.ranges.first().map_or(sc, |r| r.start); - dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); - dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); - dv.ranges = vec![RangeRef::new(dest, dest)]; - sh.validations.push(dv); - } - } - match what { - What::Comments => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - } - What::ColumnWidths => { - if let Some(info) = src_sheet.cols.get(&sc.col).copied() { - cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); - } - } - What::All => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); - } - cx.sheet_mut(dest_sheet)?.set_cell(dest, new); - } - _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), - } - if let Some(picture) = new_picture { - cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); - } - cx.touch(dest_sheet, dest); + } + What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), + What::Comments | What::Validation | What::ColumnWidths => {} + } + // Operations combine numbers with what is already there. + if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() { + if old_picture.is_some() || new_picture.is_some() { + new_picture = None; + new.value = Value::Error(gridcraft_core::CellError::Value); + } + // Numbers stored as text participate; other text and errors stay as they are. + apply_operation(&op, &old, &mut new); + } + } + // The copied cell's validation rule replaces whatever rule the destination had. + if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { + let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); + let sh = cx.sheet_mut(dest_sheet)?; + crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); + if let Some(mut dv) = rule { + // Relative references move with the rule, as pasted formulas do. + let first = dv.ranges.first().map_or(sc, |r| r.start); + dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); + dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); + dv.ranges = vec![RangeRef::new(dest, dest)]; + sh.validations.push(dv); + } + } + match what { + What::Comments => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } } + What::ColumnWidths => { + if let Some(info) = src_sheet.cols.get(&sc.col).copied() { + cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); + } + } + What::All => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } + cx.sheet_mut(dest_sheet)?.set_cell(dest, new); + } + _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), } + if let Some(picture) = new_picture { + cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); + } + cx.touch(dest_sheet, dest); } // Merged areas inside the copied block travel with it. - if matches!(what, What::All | What::Formats) && !transpose { - let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); - for m in merges { - let dr = at.row as i64 - src.start.row as i64; - let dc = at.col as i64 - src.start.col as i64; - if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { - let nm = RangeRef::new(a, b); - let sh = cx.sheet_mut(dest_sheet)?; - sh.merges.retain(|x| !x.intersects(&nm)); - sh.merges.push(nm); - } - } - } + copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?; if cut_move { // References elsewhere to the moved block now point to its new place. let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index 95793f338..ede66a34d 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges, + target_sheet, +}; // ---------------------------------------------------------------- selection diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index bf21433ab..c9a1582a2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -2,10 +2,16 @@ //! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts. use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::*; +use gridcraft_model::{ + Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind, + Workbook, +}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, anchor_mode_name, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc, + ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param, +}; mod cells; mod chart; diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs index 5cddf0982..ff0d5b65b 100644 --- a/crates/engine/src/cmd/extra/cells.rs +++ b/crates/engine/src/cmd/extra/cells.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok, + str_param, target_ranges, target_sheet, +}; /// Whether `c` has checkbox formatting. pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs index 2e16befa6..b6761f1a9 100644 --- a/crates/engine/src/cmd/extra/chart.rs +++ b/crates/engine/src/cmd/extra/chart.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit, + f64_param, json, split_sheet, str_param, u32_param, +}; fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; diff --git a/crates/engine/src/cmd/extra/data.rs b/crates/engine/src/cmd/extra/data.rs index cef840f21..9a1ae7417 100644 --- a/crates/engine/src/cmd/extra/data.rs +++ b/crates/engine/src/cmd/extra/data.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range}; pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result { let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index 59ab8447a..30725f81e 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -1,5 +1,7 @@ use super::chart::format_series; -use super::*; +use super::{ + Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param, +}; pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result { if str_param(p, "kind") == Some("shape") { diff --git a/crates/engine/src/cmd/extra/scripts.rs b/crates/engine/src/cmd/extra/scripts.rs index 9f86837ab..f1118410f 100644 --- a/crates/engine/src/cmd/extra/scripts.rs +++ b/crates/engine/src/cmd/extra/scripts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param}; pub(super) fn record(s: &mut Session, p: &Json) -> Result { if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { diff --git a/crates/engine/src/cmd/extra/window.rs b/crates/engine/src/cmd/extra/window.rs index 19e92743f..b20a00a61 100644 --- a/crates/engine/src/cmd/extra/window.rs +++ b/crates/engine/src/cmd/extra/window.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet, + stash_get, stash_map, stash_set, str_param, target_sheet, u32_param, +}; /// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it /// neither records an undo step nor marks a clean workbook as changed. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9af85b9dd..8d28c25a5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -2,7 +2,7 @@ use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param}; use crate::DocState; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index b7472fd22..9bb15936b 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -11,7 +11,7 @@ use gridcraft_functions::Timeline; use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn}; use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param}; use crate::selection::current_region; const CMD: &str = "data.forecastSheet"; diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 744b6d136..69123dcfa 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1,10 +1,13 @@ //! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format. use gridcraft_core::{CellRef, RangeRef}; -use gridcraft_model::*; +use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param, + target_ranges, target_sheet, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/format/cell_styles.rs b/crates/engine/src/cmd/format/cell_styles.rs index 53d378202..5ad0ccc15 100644 --- a/crates/engine/src/cmd/format/cell_styles.rs +++ b/crates/engine/src/cmd/format/cell_styles.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param, +}; /// Built-in cell styles (our own palette). pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option