diff --git a/Cargo.lock b/Cargo.lock index ade3a5adb..1815ac7e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,6 +1508,8 @@ dependencies = [ "image", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "pollster 1.0.1", "rfd", "serde_json", diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..90aabd419 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink survives XLSX saves) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,15 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) | +| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) | +| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) | +| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) | +| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) | +| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) | +| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) | +| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | +| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +187,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) | +| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | +| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 64b5ed692..2b8529876 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -8,8 +8,9 @@ //! them are skipped. //! //! **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is -//! validated (content types cover every part, every internal relationship target exists) and read -//! back, and the two workbooks are compared feature by feature. +//! validated (content types cover every part, every internal relationship target exists, no kind of +//! relationship the source had is missing) and read back, and the two workbooks are compared feature +//! by feature. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::{Cursor, Read as _}; @@ -38,6 +39,9 @@ const MAX_FILES: usize = 100_000; const MAX_XML_PART: u64 = 64 * 1024 * 1024; /// Most zip entries looked at while validating a written package. const MAX_ENTRIES: usize = 200_000; +/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is +/// a cache Excel rebuilds, and GridCraft never writes it. +const REBUILT_KINDS: [&str; 1] = ["calcchain"]; #[derive(Clone, Copy, Debug)] pub struct Options { @@ -97,6 +101,9 @@ pub struct CalcReport { /// aren't results, so every formula counts as no oracle. pub stale_cache: bool, pub volatile: usize, + /// Matched cells whose producer saved GridCraft's TRUE or FALSE as the number 1 or 0 (no + /// `t="b"`, as Business Central's Excel exports do); counted in `matched` too. + pub bool_as_number: usize, /// The first `max_report` mismatches, primary ones first. pub mismatches: Vec, pub functions: BTreeMap, @@ -286,7 +293,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport { calc.recalc_all(&mut fresh); let trust_cache = !full_calc_on_load(bytes); report.calc = oracle(&cached, &fresh, &calc, trust_cache, opts); - report.round_trip = round_trip(&fresh, opts); + report.round_trip = round_trip(&fresh, bytes, opts); report } @@ -347,6 +354,13 @@ fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { format!("{}!{}", quote_sheet(name), c.a1()) } +/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a +/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as +/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. +pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { + matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) +} + /// Whether GridCraft's `actual` agrees with the producer's `expected`. pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { match (expected, actual) { @@ -458,7 +472,11 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo } None => vec!["(unparsed)".to_string()], }; - let ok = values_match(&cell.value, &actual, opts.tolerance); + let bool_as_number = bool_cached_as_number(&cell.value, &actual); + let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); + if bool_as_number { + r.bool_as_number += 1; + } for n in &funcs { let st = r.functions.entry(n.clone()).or_default(); st.checked += 1; @@ -514,8 +532,9 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo // ---------------------------------------------------------------- round trip -/// Writes `wb` with GridCraft's XLSX writer, validates the package, reads it back and compares. -pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip { +/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against +/// `source` (the file `wb` was read from), reads it back and compares. +pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip { let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") { Ok(b) => b, Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] }, @@ -525,6 +544,13 @@ pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip { Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)), Err(e) => package.push(|| e), } + if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) { + for (key, kind) in &before { + if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) { + package.push(|| format!("lost relationship {kind}")); + } + } + } let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) { Ok((b, _)) => b, Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] }, @@ -901,6 +927,77 @@ pub fn validate_package(bytes: &[u8]) -> Result, String> { Ok(issues) } +/// Every relationship kind (last segment of the type, the same in the transitional and strict +/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry +/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook +/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. +fn relationship_kinds(bytes: &[u8]) -> Result, String> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; + let names = zip_names(&mut zip); + let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); + let mut kinds = BTreeMap::new(); + for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { + let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; + let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; + for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { + let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; + let target = attr(attrs, "Target").unwrap_or(""); + if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') { + continue; + } + let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + if !external + && let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase()) + && !holds_something(&mut zip, part, kind) + { + continue; + } + kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); + } + } + Ok(kinds) +} + +/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the +/// empty string for the package's `_rels/.rels`. +fn rels_source_dir(key: &str) -> Option { + let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?; + Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string()) +} + +/// Whether a part has content: an element under its root, or for comments (whose authors list +/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts. +fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool { + use quick_xml::events::Event; + let Ok(f) = zip.by_name(name) else { return true }; + let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART))); + let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() { + "comments" => Some(b"comment"), + "vmldrawing" => Some(b"shape"), + _ => None, + }; + let mut buf = Vec::new(); + let mut in_root = false; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let found = match wanted { + Some(w) => local_name(e.name().as_ref()) == w, + None => in_root, + }; + if found { + return true; + } + in_root = true; + } + Ok(Event::Eof) => return false, + Ok(_) => {} + Err(_) => return true, + } + buf.clear(); + } +} + // ---------------------------------------------------------------- output #[derive(Default)] @@ -914,6 +1011,7 @@ struct Totals { mismatched: usize, no_oracle: usize, volatile: usize, + bool_as_number: usize, round_trip_failed: usize, round_trip_differing: usize, categories: BTreeMap<&'static str, usize>, @@ -935,6 +1033,7 @@ fn totals(reports: &[FileReport]) -> Totals { t.mismatched += r.calc.mismatched; t.no_oracle += r.calc.no_oracle; t.volatile += r.calc.volatile; + t.bool_as_number += r.calc.bool_as_number; if r.round_trip.error.is_some() { t.round_trip_failed += 1; } @@ -995,7 +1094,8 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "warnings": r.warnings, "calc": { "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, - "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "mismatches": mismatches, "functions": functions_json(&c.functions), + "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, + "mismatches": mismatches, "functions": functions_json(&c.functions), "signatures": c.signatures, }, "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, @@ -1009,7 +1109,7 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "files": files, "totals": { "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, - "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, + "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, }, "functions": functions_json(&t.functions), @@ -1048,6 +1148,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { if c.stale_cache { outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); } + if c.bool_as_number > 0 { + outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); + } for m in &c.mismatches { let tag = if m.primary { "mismatch" } else { " (downstream)" }; outln!( @@ -1144,6 +1247,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { t.no_oracle, t.volatile ); + if t.bool_as_number > 0 { + outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); + } let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); outln!( "round trip: {} of {} files differ{}{}", @@ -1248,6 +1354,31 @@ mod tests { assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); } + /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: + /// the cache holds the right value under a number type, so it matches and is counted apart. + #[test] + fn booleans_cached_as_numbers_match_and_are_counted_apart() { + let wb = book(&[ + ("A1", "=TRUE()", n(1.0)), + ("B1", "=FALSE()", n(0.0)), + ("C1", "=TRUE()", Value::Bool(true)), + ("D1", "=1=1", n(0.0)), + ("E1", "=TRUE()+0", n(1.0)), + ]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); + // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. + assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); + assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); + assert_eq!(r.calc.bool_as_number, 2); + let v = report_json(std::slice::from_ref(&r), &Options::default()); + assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); + assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); + assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); + } + #[test] fn missing_cached_values_and_volatile_formulas_are_skipped() { let wb = @@ -1290,7 +1421,8 @@ mod tests { fn round_trip_of_a_simple_book_is_clean() { let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let rt = round_trip(&wb, &Options::default()); + let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + let rt = round_trip(&wb, &source, &Options::default()); assert_eq!(rt.error, None); assert!(rt.is_clean(), "{rt:?}"); assert!(rt.categories.iter().any(|c| c.name == "package")); @@ -1376,6 +1508,98 @@ mod tests { assert_eq!(validate_package(&own).unwrap(), Vec::::new()); } + const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; + const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; + const MADE_UP: &str = r#""#; + /// Not empty: an empty part isn't a loss. + const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); + + /// `bytes` with relationships added to rels parts (created when missing) and `parts` added. + fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut pending: Vec<(&str, &str)> = rels.to_vec(); + for i in 0..src.len() { + let mut f = src.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut body = String::new(); + f.read_to_string(&mut body).unwrap(); + if let Some(at) = pending.iter().position(|(r, _)| *r == name) { + body = body.replace("", &format!("{}", pending.remove(at).1)); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + let created = pending.iter().map(|(r, extra)| { + (*r, format!(r#"{extra}"#)) + }); + for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + out.finish().unwrap().into_inner() + } + + /// The `package` examples of checking `source`. + fn package_of(source: &[u8]) -> Vec { + let r = check_bytes("t.xlsx", source, &Options::default()); + r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() + } + + fn plain() -> Vec { + gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() + } + + #[test] + fn package_reports_relationship_kinds_the_output_lost() { + // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written + // (Excel rebuilds it), so its absence isn't a loss. + let calc_chain = r#""#; + let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); + } + + // In the three tests below the made-up kind stays lost, so the comparison is known to run. + + #[test] + fn an_internal_link_written_as_a_relationship_is_not_a_loss() { + // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. + let link = r##""##; + let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + + #[test] + fn comments_without_a_comment_are_not_a_loss() { + let rels = r#""#; + let comments = r#"A"#; + let vml = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], + &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + + #[test] + fn empty_drawings_and_person_lists_are_not_a_loss() { + let drawing = r#""#; + let person = + r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], + &[ + MADE_UP_PART, + ("xl/drawings/drawing1.xml", r#""#), + ("xl/persons/person.xml", r#""#), + ], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + #[test] fn unopenable_files_are_reported() { let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); @@ -1396,7 +1620,19 @@ mod tests { assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); assert_eq!( keys(&f["calc"]), - set(&["formulaCells", "checked", "matched", "mismatched", "noOracle", "staleCache", "volatile", "mismatches", "functions", "signatures"]) + set(&[ + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "staleCache", + "volatile", + "boolAsNumber", + "mismatches", + "functions", + "signatures" + ]) ); assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); @@ -1435,6 +1671,7 @@ mod tests { "mismatched", "noOracle", "volatile", + "boolAsNumber", "roundTripFailed", "roundTripDiffering", "categoriesDiffering" diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index c867d24a2..cd8311b34 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -42,3 +42,13 @@ muda = { version = "0.21", default-features = false } # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" + +# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit); +# pinned exactly to the same versions. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2 = "=0.6.5" +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSMenuItem", "NSResponder"] } + +[[test]] +name = "mac_menu_appkit" +harness = false diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index f4b6e80fc..6985af3c0 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Close", "file.close", Some("CMD+W")), ("Save", "file.save", Some("CMD+S")), ("Save As…", "file.saveAs", Some("CMD+SHIFT+S")), + ("Export as PDF…", "file.exportPdf", None), ("Export as CSV…", "dialog:saveCopy:csv", None), ("Save as Web Page…", "dialog:saveCopy:html", None), ("-", "", None), @@ -215,6 +216,7 @@ impl NativeMenu { ]); let _ = menu.append(&app_menu); let mut theme_items = Vec::new(); + let mut window_menu = None; for (title, entries) in tree() { let sub = Submenu::new(title, true); for (label, id, acc) in entries { @@ -237,8 +239,17 @@ impl NativeMenu { let _ = sub.append(&item); } let _ = menu.append(&sub); + if title == "Window" { + window_menu = Some(sub); + } } menu.init_for_nsapp(); + // NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the + // system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks + // the submenu up in the installed main menu. + if let Some(window) = window_menu { + window.set_as_windows_menu_for_nsapp(); + } let (tx, rx) = channel(); let ctx = ctx.clone(); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { @@ -317,6 +328,15 @@ impl NativeMenu { mod tests { use super::*; + #[test] + fn file_menu_has_export_as_pdf_dispatching_the_command() { + let tree = tree(); + let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu"); + let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu"); + assert_eq!(item.1, "file.exportPdf"); + assert!(gridcraft_engine::find_command("file.exportPdf").is_some()); + } + #[test] fn theme_choices_use_the_shared_preference_command() { let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); diff --git a/apps/gridcraft/tests/mac_menu_appkit.rs b/apps/gridcraft/tests/mac_menu_appkit.rs new file mode 100644 index 000000000..e713f6699 --- /dev/null +++ b/apps/gridcraft/tests/mac_menu_appkit.rs @@ -0,0 +1,34 @@ +//! The macOS menu bar as AppKit sees it, on the main thread (`harness = false`): install it through +//! the real muda backend (`src/native_menu.rs`) and read NSApp back. The Window menu must be NSApp's +//! `windowsMenu`: that is where macOS adds Move & Resize, Fill and Center, the targets of the system +//! window tiling shortcuts (Fn+Ctrl+arrows, Fn+F), and lists the open windows (#716). + +// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its +// unit tests compile here too without a harness to run them). +#[cfg(target_os = "macos")] +#[path = "../src/native_menu.rs"] +#[allow(unused_imports, dead_code)] +mod native_menu; + +#[cfg(target_os = "macos")] +fn main() { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); + let nsapp = NSApplication::sharedApplication(mtm); + nsapp.finishLaunching(); + + let ctx = egui::Context::default(); + let _menu = native_menu::NativeMenu::install(&ctx); + + let bar = nsapp.mainMenu().expect("a main menu"); + let tops: Vec = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect(); + assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]); + assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu"); + + println!("mac_menu_appkit: ok"); +} + +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/contributors/people.toml b/contributors/people.toml index 5d343e9c3..d522b84e0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -61,3 +61,7 @@ sync_github_name = true real_name = "James Symes" sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index e5f3412c6..47a511960 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -88,6 +88,8 @@ pub struct Evaluator<'h> { struct FnCtx<'a, 'h> { ev: &'a mut Evaluator<'h>, + /// The criteria argument of a database function, when it is a reference. + criteria: Option, } impl Ctx for FnCtx<'_, '_> { @@ -100,6 +102,28 @@ impl Ctx for FnCtx<'_, '_> { fn random(&mut self) -> f64 { self.ev.host.random() } + /// Excel's "formula as criteria": the formula is written for the first record, so its relative + /// references move down to `record`'s row, and the database's labels name its fields. + /// Known gap: the shifted references are not dependencies of the calling cell. One that lands + /// outside both the database and the criteria cell's own precedents (say `=G2 Option { + let area = self.criteria?; + let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; + let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?; + let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0); + let env = (0..db.cols) + .filter_map(|c| match db.get(0, c) { + Some(Value::Text(t)) if !t.trim().is_empty() => { + Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty)))) + } + _ => None, + }) + .collect(); + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) + } } impl<'h> Evaluator<'h> { @@ -592,7 +616,7 @@ impl<'h> Evaluator<'h> { let value = self.value(a); evaluated.push(Arg { value, from_ref: false }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); } let i = match index.to_number() { @@ -750,6 +774,20 @@ impl<'h> Evaluator<'h> { Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "GROUPBY" | "PIVOTBY" => { + // The aggregation may be a function given by name (`SUM`): pass it as its name. + let i = if name == "GROUPBY" { 2 } else { 3 }; + match args.get(i).and_then(|a| self.function_by_name(a)) { + Some(f) => { + let mut args = args.to_vec(); + if let Some(a) = args.get_mut(i) { + *a = Expr::Text(f.into()); + } + self.builtin(name, &args) + } + None => self.builtin(name, args), + } + } "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { self.host.note_volatile(); self.builtin(name, args) @@ -775,8 +813,17 @@ impl<'h> Evaluator<'h> { let mut evaluated = Vec::with_capacity(args.len()); // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; for (i, a) in args.iter().enumerate() { let mut ev = self.eval(a); + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } if resizes_sum_range(name, args.len()) { if i == 0 { criteria_shape = ev_shape(&ev); @@ -804,7 +851,7 @@ impl<'h> Evaluator<'h> { }; evaluated.push(Arg { value, from_ref }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria }; gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } @@ -858,13 +905,13 @@ impl<'h> Evaluator<'h> { }; let args = [Arg { value, from_ref }]; if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.sum += n, other => return Err(Ev::V(other)), } } if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), other => return Err(Ev::V(other)), } @@ -921,7 +968,26 @@ impl<'h> Evaluator<'h> { r } - fn lambda_arg(&mut self, e: Option<&Expr>) -> Option> { + /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, + /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has + /// that name. + fn function_by_name(&self, e: &Expr) -> Option { + let Expr::Name(n) = e else { return None }; + if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() { + return None; + } + let u = n.to_ascii_uppercase(); + gridcraft_functions::lookup(&u).map(|_| u) + } + + /// The function argument of a LAMBDA helper; a function given by name takes `arity` + /// arguments. + fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { + if let Some(f) = e.and_then(|x| self.function_by_name(x)) { + let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); + let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); + return Some(Arc::new(Lambda { params, body, env: Vec::new() })); + } match e.map(|x| self.eval(x)) { Some(Ev::L(l)) => Some(l), _ => None, @@ -940,7 +1006,7 @@ impl<'h> Evaluator<'h> { if args.len() < 2 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); @@ -963,7 +1029,7 @@ impl<'h> Evaluator<'h> { } let mut acc = self.value(&args[0]); let arr = as_array(self.value(&args[1])); - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } @@ -987,7 +1053,7 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let arr = as_array(self.value(&args[0])); - let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; let by_row = name == "BYROW"; let n = if by_row { arr.rows } else { arr.cols }; @@ -1016,7 +1082,7 @@ impl<'h> Evaluator<'h> { if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } @@ -1248,7 +1314,7 @@ impl<'h> Evaluator<'h> { } } } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return match gridcraft_functions::call(spec, &xargs, &mut ctx) { Value::Number(p) if p >= 1.0 => { let k = p as u32 - 1; @@ -1302,7 +1368,7 @@ impl<'h> Evaluator<'h> { _ => "v", })) } - "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())), + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), "filename" => Ev::V(Value::text(String::new())), "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5ff3647eb..a0c9fe26d 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -182,6 +182,11 @@ fn errors_and_cycles() { assert!(t.get("B1").is_error()); t.set("C1", "=NOSUCHFN(1)"); assert_eq!(t.get("C1"), Value::Error(CellError::Name)); + // `_xludf.` marks a user-defined function, never the built-in of the same name (#726). + for f in ["=_xludf.CONCAT(\"a\",\"b\")", "=_xludf.SUM(1,2)", "=_xludf.EOMONTH()"] { + t.set("C3", f); + assert_eq!(t.get("C3"), Value::Error(CellError::Name), "{f}"); + } t.set("C2", "=1+"); assert_eq!(t.get("C2"), Value::Error(CellError::Name)); } @@ -547,6 +552,27 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn functions_given_by_name() { + let mut t = T::new(); + for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=BYROW(B1:B3,SUM)"); + assert_eq!(t.num("D2"), -2.0); + t.set("E1", "=MAP(B1:B3,ABS)"); + assert_eq!(t.num("E2"), 2.0); + t.set("F1", "=REDUCE(0,B1:B3,SUM)"); + assert_eq!(t.num("F1"), 2.0); + t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)"); + assert_eq!(t.get("G1"), Value::from("a")); + assert_eq!(t.num("H1"), 4.0); + // A LET variable of that name is still the variable. + t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))"); + assert_eq!(t.num("J1"), 20.0); +} + #[test] fn lambda_argument_count() { let mut t = T::new(); @@ -1098,6 +1124,73 @@ fn trim_references() { assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } +#[test] +fn cell_width_is_the_column_width_dialog_value_rounded() { + // #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them + // (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64). + let mut t = T::new(); + let sh = t.wb.sheet_mut(0).unwrap(); + for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] { + sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() }); + } + for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] { + t.set(at, &format!("=CELL(\"width\",{col}1)")); + } + assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]); +} + +/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding +/// a formula under a label that names no field is evaluated for every record. +#[test] +fn database_functions_with_computed_criteria() { + let mut t = T::new(); + let db = [ + ["name", "age", "size", "headgear", "glasses"], + ["Huey", "6", "45", "cap~", "FALSE"], + ["Dewey", "7", "fourty-six", "cap?", "FALSE"], + ["Louie", "8", "47", "cap*", "FALSE"], + ["Donald", "32", "70", "sailor's hat", "FALSE"], + ["Scrooge", "62", "65", "top hat", "TRUE"], + ]; + for (r, row) in db.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v); + } + } + t.set("G45", "7"); + let cases = [ + // Relative references are to the first record's row and move to each record's row; + // `$G$45` stays put. + ("G", "=G2<$G$45", "Dewey"), + // Column labels stand for the record's fields. + ("H", "=glasses=TRUE", "Scrooge"), + ("I", "=age<7", "Huey"), + ("J", "=size/7=10", "Donald"), + // An absolute reference to a cell that is not the first record's. + ("K", "=H3=$L$1", "Donald"), + ]; + t.set("L1", "70"); + for (i, (col, formula, _)) in cases.iter().enumerate() { + t.set(&format!("{col}46"), "Formula"); + t.set(&format!("{col}47"), formula); + t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)")); + } + for (i, (_, formula, want)) in cases.iter().enumerate() { + assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}"); + } + // Every database function takes them, ANDed with the other criteria of their row. + // Text sorts after numbers, so Dewey's "fourty-six" is > 40. + t.set("L47", "=size>40"); + t.set("M46", "age"); + t.set("M47", ">6"); + t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)"); + t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)"); + t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)"); + assert_eq!(t.num("B51"), 1.0); + assert_eq!(t.num("B52"), 7.0); + assert_eq!(t.num("B53"), 4.0); +} + #[test] fn offset_extreme_arguments_return_ref() { let wb = Workbook::new(); diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs index eb4f733a3..8686bf7d9 100644 --- a/crates/chart/src/axis.rs +++ b/crates/chart/src/axis.rs @@ -122,7 +122,7 @@ impl Fmt { pub fn new(code: &str) -> Fmt { let t = code.trim(); let general = t.is_empty() || t.eq_ignore_ascii_case("general"); - Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) } + Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) } } /// Formats `v`; `step` (axis labels) fixes the decimals for General. pub fn format(&self, v: f64, step: Option) -> String { diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 083ff8b5d..697e2a7a7 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -124,6 +124,9 @@ impl Measure for ApproxMeasure { pub struct ChartData { pub categories: Vec, pub series: Vec, + /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme. + #[serde(default)] + pub palette: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index f2f79dc4b..037b9099e 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { fn point_color(data: &ChartData, j: usize) -> Rgba { match (j, data.series.first()) { (0, Some(s)) => s.color, - _ => series_color(&Theme::default(), j), + _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), } } @@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option]) -> Vec> { fn sanitize(d: &ChartData) -> ChartData { ChartData { + palette: d.palette.iter().take(MAX_POINTS).copied().collect(), categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), series: d .series @@ -254,7 +255,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Doughnut => pie(ctx, chart, data, area, 0.5, false), Sunburst => pie(ctx, chart, data, area, 0.2, true), Treemap => treemap(ctx, chart, data, area), - Funnel => funnel(ctx, data, area), + Funnel => funnel(ctx, chart, data, area), Radar => radar(ctx, chart, data, area), _ => { let area = axis_titles(ctx, chart, area); @@ -266,8 +267,8 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area), Histogram => histogram(ctx, chart, data, area), Waterfall => waterfall(ctx, chart, data, area), - BoxWhisker => box_whisker(ctx, data, area), - Stock => stock(ctx, data, area), + BoxWhisker => box_whisker(ctx, chart, data, area), + Stock => stock(ctx, chart, data, area), _ => cartesian(ctx, chart, data, area, false), } } @@ -1315,7 +1316,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- funnel -fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some(s) = data.series.first() else { return }; let n = data.categories.len().max(s.values.len()).min(MAX_CATS); if n == 0 { @@ -1345,9 +1346,11 @@ fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { } if show { let yc = y + bh / 2.0; - 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); + 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); + } 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); } @@ -1571,12 +1574,12 @@ fn quantile(sorted: &[f64], q: f64) -> f64 { a + (b - a) * frac } -fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1615,7 +1618,7 @@ fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- stock -fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { 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; @@ -1624,7 +1627,7 @@ fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff22..c007e3e90 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16; /// Tints used for series beyond the six theme accents: lighter, darker, lighter still… const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2]; +/// `series_color` repeats after this many colours (six accents × the tints). +pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len(); /// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them. pub fn series_color(theme: &Theme, i: usize) -> Rgba { @@ -145,7 +147,7 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { if let Some(c) = at { let code = &wb.styles.get(s.style_id(c)).num_fmt.0; if !code.eq_ignore_ascii_case("general") { - return format_value(v, &NumberFormat::parse(code), wb.date_system).text; + return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text; } } } @@ -239,5 +241,6 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { } let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); - ChartData { categories, series } + let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE)); + ChartData { categories, series, palette } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 6af1f4604..01f1aac49 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData { let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); b.secondary = kind == ChartKind::Combo; ChartData { + palette: vec![], categories: cats.iter().map(|s| s.to_string()).collect(), series: vec![ series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1), @@ -362,7 +363,7 @@ fn scatter_with_text_x_values_numbers_the_points() { let d = resolve(&wb, 0, &c); assert_eq!(d.series[0].x, None, "{kind:?}"); let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); - let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); + let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points"); // Numeric X values are kept. c.series[0].categories = Some("Sheet1!$C$1:$C$3".into()); @@ -455,7 +456,8 @@ fn pie_starts_at_twelve_clockwise() { #[test] fn negative_values_put_axis_below_zero() { let c = chart(ChartKind::ColumnClustered); - let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let d = + ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); let t = texts(&prims); assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); @@ -466,8 +468,13 @@ fn negative_values_put_axis_below_zero() { fn edge_cases_never_panic() { let m = ApproxMeasure; let empty = ChartData::default(); - let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] }; + let zeros = ChartData { + palette: vec![], + categories: vec!["a".into(), "b".into()], + series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)], + }; let weird = ChartData { + palette: vec![], categories: vec![String::new(); 3], series: vec![SeriesData { name: "w".repeat(500), @@ -498,7 +505,7 @@ fn thousands_of_points_are_decimated() { let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); let mut c = chart(ChartKind::Line); c.data_labels = true; - let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); let max_line = prims .iter() @@ -523,7 +530,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = smooth; - let d = ChartData { categories: cats.clone(), series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] }; render(&chart(ChartKind::Line), &d, 600.0, 400.0, m) .iter() .filter_map(|p| match p { @@ -540,7 +547,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { // Rendering either way must stay finite and inside the canvas. let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; - let d = ChartData { categories: cats, series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats, series: vec![s] }; assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0); } @@ -550,7 +557,7 @@ fn smooth_line_splits_at_gaps() { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; s.values[1] = None; // gap between two data points - let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m); // Two separate runs (before/after the gap) → at least two line primitives. let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count(); @@ -627,7 +634,7 @@ fn fuzz_random_data_and_sizes() { }) .collect(); let ncat = (r.next() % 50) as usize; - let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; let mut c = chart(kind); c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; c.data_labels = r.next().is_multiple_of(2); @@ -802,7 +809,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line); s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }]; - let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; + let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; let mut c = chart(ChartKind::Line); c.legend = LegendPos::None; // keeps legend swatches out of the line counts let prims = render(&c, &d, 600.0, 400.0, &m); @@ -826,7 +833,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { assert!(lines_of(&pie, tc).is_empty()); // One point: nothing to fit, nothing drawn. s.values.truncate(1); - let one = ChartData { categories: vec!["a".into()], series: vec![s] }; + let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] }; assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty()); } @@ -846,7 +853,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] }, TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] }, ]; - let d = ChartData { categories: vec![], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![s] }; let mut c = chart(ChartKind::Scatter); c.legend = LegendPos::None; let prims = render(&c, &d, 600.0, 400.0, &m); @@ -861,6 +868,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { #[test] fn treemap_and_histogram_shapes() { let d = ChartData { + palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], }; @@ -881,7 +889,7 @@ fn treemap_and_histogram_shapes() { assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); let vals: Vec = (0..100).map(|i| i as f64).collect(); - let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); assert_eq!(bins, 10); @@ -890,7 +898,7 @@ fn treemap_and_histogram_shapes() { #[test] fn histogram_boundary_values_match_interval_labels() { let heights = |vals: &[f64]| -> Vec { - let d = ChartData { categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect() }; @@ -935,6 +943,95 @@ fn union_series_references() { let _ = resolve(&wb, 0, &c); } +#[test] +fn box_and_stock_charts_honour_the_gridlines_flag() { + // Horizontal full-width lines in the gridline colour are the value-axis gridlines. + let grid: Rgba = [217, 217, 217, 255]; + let is_grid = |p: &Prim| match p { + Prim::Line { color, pts, .. } => *color == grid && pts.len() == 2 && pts[0][1] == pts[1][1] && pts[1][0] - pts[0][0] > 200.0, + _ => false, + }; + let grids = |prims: &[Prim]| prims.iter().filter(|p| is_grid(p)).count(); + for kind in [ChartKind::BoxWhisker, ChartKind::Stock, ChartKind::ColumnClustered] { + let mut d = sample(kind); + d.series.push(series("Close", &[10000.0, 12000.0, 9000.0, 14000.0, 20000.0, 16000.0], [90, 90, 90, 255], kind)); + let mut c = chart(kind); + let on = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + c.gridlines = false; + let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines"); + assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them"); + } +} + +#[test] +fn funnel_value_labels_follow_the_data_labels_flag() { + let data = ChartData { + palette: vec![], + categories: vec!["a".into(), "b".into(), "c".into()], + series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)], + }; + let mut c = chart(ChartKind::Funnel); + c.data_labels = false; + let off = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + c.data_labels = true; + let on = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + let values = ["$11,111", "$22,222", "$33,333"]; + for v in values { + assert!(texts(&on).contains(&v), "{v} missing with labels on"); + assert!(!texts(&off).contains(&v), "{v} drawn with labels off"); + } + for cat in ["a", "b", "c"] { + assert!(texts(&off).contains(&cat)); + assert!(texts(&on).contains(&cat)); + } +} + +#[test] +fn pie_points_follow_workbook_theme() { + let mut wb = Workbook::new(); + wb.theme.colors[4] = 0x4F6D8A; + wb.theme.colors[5] = 0x8AA1B8; + let mut c = chart(ChartKind::Pie); + c.series = vec![Series { + name: None, + categories: None, + values: "{5,3}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)]; + assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected); + let fills: Vec = render(&c, &d, 480.0, 288.0, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { fill, .. } => Some(*fill), + _ => None, + }) + .collect(); + assert_eq!(fills, expected); + for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] { + c.kind = kind; + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected, "{kind:?}"); + } + // Past the 36-colour cycle the points keep following the workbook theme. + c.kind = ChartKind::Pie; + c.series[0].values = format!("{{{}}}", ["1"; 40].join(",")); + let d = resolve(&wb, 0, &c); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend.len(), 40); + assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37))); + assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37)); +} + #[test] fn area_charts_draw_source_value_labels_only_when_enabled() { for kind in [ChartKind::Area, ChartKind::AreaStacked] { diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index df52eb9cc..311121ea5 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -456,9 +456,13 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { if has { return edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; + // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. + let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); sh.autofilter = None; - for info in sh.rows.values_mut() { - info.hidden = false; + for r in filtered { + if let Some(info) = sh.rows.get_mut(&r) { + info.hidden = false; + } } cx.structural = true; Ok(json!({"filter": false})) @@ -578,19 +582,28 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { let Some(af) = sh.autofilter.clone() else { return }; let r = af.range; let mut hide = Vec::new(); - // Precompute per-column numbers for top10/average. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); - for (off, c) in &af.criteria { + // Precompute per-criterion top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + for (i, (off, c)) in af.criteria.iter().enumerate() { if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { let col = r.start.col + off; - let nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); + let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; - col_stats.insert(*off, (nums, avg)); + let thr = match c { + FilterCriterion::Top10 { bottom, percent, count } => { + nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); + let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; + let k = k.clamp(1, nums.len().max(1)); + nums.get(k - 1).copied() + } + _ => None, + }; + col_stats.insert(i, (thr, avg)); } } for row in r.start.row + 1..=r.end.row { let mut visible = true; - for (off, crit) in &af.criteria { + for (i, (off, crit)) in af.criteria.iter().enumerate() { let c = CellRef::new(row, r.start.col + off); let v = sh.value(c); let text = crate::display::cell_text(wb, sh, c); @@ -612,18 +625,14 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { None => x, } } - FilterCriterion::Top10 { bottom, percent, count } => match (v.as_f64(), col_stats.get(off)) { - (Some(n), Some((nums, _))) => { - let mut sorted = nums.clone(); - sorted.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); - let k = if *percent { ((sorted.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; - let k = k.clamp(1, sorted.len().max(1)); - let thr = sorted.get(k - 1).copied().unwrap_or(n); + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { + (Some(n), Some((thr, _))) => { + let thr = thr.unwrap_or(n); if *bottom { n <= thr } else { n >= thr } } _ => false, }, - FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) { + FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { (Some(n), Some((_, avg))) => { if *above { n > *avg @@ -1149,6 +1158,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { { e.collapsed = false; } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } @@ -1338,6 +1348,45 @@ mod tests { assert!(!ok(&mut s, "B1", "150")); } + #[test] + fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("data.group", json!({"rows": "2:4"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + let hidden = |s: &Session| -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() + }; + let before = hidden(&s); + assert_eq!(before, [1, 2, 3, 9]); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); + assert_eq!(hidden(&s), before); + assert_eq!(row_state(&s, 4..=4), [(false, true)]); + } + + #[test] + fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + s.execute("selection.set", json!({"range": "E1"})).unwrap(); + // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); + } + /// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). fn nested_rows() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 74fb5040a..0b467e086 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { Some(c) => c, None => s.doc()?.selection.active, }; - let input = str_param(p, "input") - .or_else(|| str_param(p, "value")) - .map(str::to_string) - .or_else(|| p.get("value").map(json_to_input)) - .ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + // Both spellings take a string, number or boolean, as typed (#697). + let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; let array = bool_param(p, "array").unwrap_or(false); let d = s.doc()?; if let Some(sh) = d.wb.sheet(sheet) @@ -420,14 +417,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let sel = &mut d.selection; // Extension moves the far corner, not the active cell. - let from = if extend { - let r = sel.current(); - let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row }; - let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col }; - CellRef::new(far_row, far_col) - } else { - sel.active - }; + let from = if extend { sel.moving_end() } else { sel.active }; // Skip hidden rows/columns and step over merged areas. let mut to = if jmp { jump(sheet, from, dr.signum(), dc.signum()) @@ -1597,6 +1587,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { mod tests { use super::*; + #[test] + fn cell_set_takes_numbers_for_input_and_value() { + // #697: a JSON number for `input` used to clear the cell instead of entering it. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); + assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); + assert_eq!(get(&mut s, "A3")["value"], json!(true)); + assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); + assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); + } + #[test] fn legacy_array_entry() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a98aebd6b..62bc51584 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -701,6 +701,12 @@ fn stash_get(wb: &Workbook, key: &str) -> Json { .unwrap_or(json!({})) } +/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty. +fn stash_map(wb: &Workbook, key: &str) -> Json { + let v = stash_get(wb, key); + if v.is_object() { v } else { json!({}) } +} + fn stash_set(wb: &mut Workbook, key: &str, v: &Json) { let text = v.to_string(); let formula = format!("\"{}\"", text.replace('"', "\"\"")); @@ -723,7 +729,7 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc fn custom_views(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let mut views = stash_get(&d.wb, VIEWS_NAME); + let mut views = stash_map(&d.wb, VIEWS_NAME); if let Some(name) = str_param(p, "save") { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); @@ -949,7 +955,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result { return Err(bad("automate.newScript", "`commands` must be an array")); } edit(s, |cx| { - let mut all = stash_get(&cx.wb, SCRIPTS_NAME); + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); all[name.as_str()] = cmds.clone(); stash_set(&mut cx.wb, SCRIPTS_NAME, &all); Ok(json!({"name": name})) @@ -988,7 +994,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { - let mut all = stash_get(&cx.wb, SCRIPTS_NAME); + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); if let Some(o) = all.as_object_mut() { o.remove(&name); } @@ -1063,8 +1069,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { }) } +/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null). +fn object_params(p: &Json) -> Json { + Json::Object(p.as_object().cloned().unwrap_or_default()) +} + fn theme_colors(s: &mut Session, p: &Json) -> Result { - let mut params = p.clone(); + let mut params = object_params(p); if params.get("name").is_none() { params["name"] = json!(s.doc()?.wb.theme.name.clone()); } @@ -1085,7 +1096,7 @@ fn background(s: &mut Session, p: &Json) -> Result { Ok(Json::Null) }); } - let mut params = p.clone(); + let mut params = object_params(p); params["alt"] = json!("Sheet background"); params["at"] = json!("A1"); s.execute("insert.picture", params) @@ -1101,7 +1112,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result { "outline" => "outside", _ => "all", }; - let mut params = p.clone(); + let mut params = object_params(p); params["preset"] = json!(preset); s.execute("home.borders", params) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3c4a1012b..2a8b43537 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -75,7 +75,11 @@ fn open(s: &mut Session, p: &Json) -> Result { { s.close_document(0); } - let i = s.add_document(DocState::new(wb, path, title)); + let mut doc = DocState::new(wb, path, title); + if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { + doc.content_kind = Some(crate::io::FileKind::Xlsx); + } + let i = s.add_document(doc); // The decrypted-workbook notice is for the info bar, not a message box. let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); if !shown.is_empty() { @@ -105,11 +109,16 @@ fn save(s: &mut Session, p: &Json) -> Result { return super::print::export_pdf(s, &json!({"path": path})); } let d = s.doc_mut()?; - let bytes = crate::io::save_bytes(&d.wb, &path)?; + // The document's own file keeps the format its content had when opened; any other path + // goes by its extension. + let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str()))); + let bytes = match kind { + Some(kind) => crate::io::save_bytes_as(&d.wb, &path, kind)?, + None => crate::io::save_bytes(&d.wb, &path)?, + }; crate::io::write_file(&path, &bytes)?; - let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx); // Only a full-fidelity format clears the dirty flag and becomes the document's file. - if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) { + if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) { d.saved = d.wb.clone(); d.path = Some(path.clone()); } diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 13c120c85..9795ea67a 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Forecast Sheet", ["Data", "Forecast"], None, - "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", has_doc, forecast_sheet )] @@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) { return Err(EngineError::Other("There are no values to forecast.".into())); } - let end = match p.get("end") { - Some(Json::Number(n)) => n.as_f64(), - Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { - Value::Number(end) => Some(end), - _ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))), - }, - _ => None, + let time_param = |key: &str| -> Result> { + match p.get(key) { + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Value::Number(x) => Ok(Some(x)), + _ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))), + }, + None | Some(Json::Null) => Ok(None), + Some(Json::String(_)) => Ok(None), + Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))), + } + }; + let end = time_param("end")?; + // Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the + // usual forecast after the history. + let fit = match time_param("forecastStart")? { + None => rows.len(), + Some(start) => { + let last_t = times.last().copied().unwrap_or(f64::NAN); + let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0); + if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) { + rows.len() + } else { + let i = times + .iter() + .position(|t| tol(*t, start)) + .ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?; + if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) { + return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into())); + } + i + } + } }; let cap = max_periods(rows.len()); let periods = match end { @@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { // Columns: A timeline, B values, C forecast, D/E bounds. let n = rows.len() as u32; let last = n + 1; // 1-based row of the last history point + let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to let end_row = last + periods; // 1-based row of the last forecast - let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col)); + let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col)); let mut tail = format!("{},{}", opts.seasonality, opts.fill); if let Some(a) = opts.aggregate { tail.push_str(&format!(",{a}")); @@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { put(&mut sheet, r, 1, v.clone(), vstyle); } } - // The last history point starts the forecast lines, so they join the values. - formula(&mut sheet, n, 2, format!("=B{last}"), vstyle); + // The last point the model is fit to starts the forecast lines, so they join the values. + let join = fit_last - 1; // 0-based row + formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle); if opts.confidence.is_some() { - formula(&mut sheet, n, 3, format!("=C{last}"), vstyle); - formula(&mut sheet, n, 4, format!("=C{last}"), vstyle); + formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle); + formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle); } let (tl, vl) = (hist(0), hist(1)); + let forecast_row = |sheet: &mut Sheet, r: u32| { + formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); + if let Some(c) = opts.confidence { + let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); + formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); + formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); + } + }; + // Hindcast: forecasts over the actuals from the start to the last history point. + for r in (fit_last + 1)..=last { + forecast_row(&mut sheet, r); + } for (k, t) in future.iter().enumerate() { let r = last + k as u32 + 1; // 1-based put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle); - formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); - if let Some(c) = opts.confidence { - let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); - formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); - formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); - } + forecast_row(&mut sheet, r); } if opts.statistics { put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); @@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if let Ok(d) = s.doc_mut() { d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default()); } - Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods})) + Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit})) } /// Forecast rows needed to reach `end` (rounded up to whole steps). @@ -545,4 +580,39 @@ mod tests { s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); } + + #[test] + fn forecast_start_hindcasts_over_the_actuals() { + let mut s = session_with_history(); // t = 1..16 in A2:A17 + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap(); + assert_eq!(r["fitted"], 12); + // Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19. + assert_eq!(formula(&s, 0, "C13"), "B13"); + assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13")); + assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)"); + assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)"); + // The exact seasonal series is recovered, and the actual is still shown next to it. + let c14 = value(&s, 0, "C14").as_f64().unwrap(); + assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3 + assert!(value(&s, 0, "B14").as_f64().is_some()); + // Start at the last point or one step later: as without forecastStart. + for start in [json!(16), json!(17)] { + let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap(); + assert_eq!(r["fitted"], 16); + } + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + } + + #[test] + fn forecast_start_invalid_values_are_errors() { + let mut s = session_with_history(); + let sheets = s.doc().unwrap().wb.sheets.len(); + for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] { + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // Blank or null behaves like no start; empty params unchanged. + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap(); + assert_eq!(r["fitted"], 16); + } } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 61c9acb54..3ed8ac1c4 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -963,6 +963,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result { e.custom = true; } } + cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } @@ -1002,9 +1003,7 @@ pub fn chars_to_points(c: f64) -> f64 { } } -pub fn points_to_chars(px: f64) -> f64 { - if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } -} +pub use gridcraft_model::points_to_chars; /// Approximate text width in points for autofit (no font metrics in the engine: average glyph /// widths for the default font; the UI refines with real measurement when it autofits). @@ -1079,6 +1078,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result { } cx.fit_rows.extend((a..=b).map(|r| (si, r))); } + cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 4f388a7d9..e589e60a2 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Link", ["Insert", "Links"], Some("Cmd+K"), - "{cell?, target: \"https://…\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}", + "{cell?, target: \"https://…\" | \"Book.xlsx\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}", has_doc, insert_link ), @@ -1166,6 +1166,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }; let text = str_param(p, "text").map(str::to_string); let tooltip = str_param(p, "tooltip").map(str::to_string); + let external = links_outside(&s.doc()?.wb, sheet, &target); edit(s, |cx| { let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| { st.font.color = Color::Theme(10, 0); @@ -1177,12 +1178,30 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { } else { sh.set_style(at, link_style); } - sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone() }); + sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external }); cx.touch(sheet, at); Ok(Json::Null) }) } +/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This +/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web +/// Page") is outside the workbook and saved as a relationship. +fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool { + let t = target.trim(); + if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() { + return false; + } + let (on, body) = split_sheet(t); + // Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference, + // and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name. + let on_sheet = on.is_none_or(|name| { + wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name)) + || (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#'])) + }); + !(on_sheet && RangeRef::parse(body).is_some()) +} + fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 0d73b0a4a..8a441fa1b 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Move Field", [], None, - "{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", + "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", has_doc, move_field ), @@ -333,6 +333,71 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool { pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field)) } +/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more). +const VALUES_FIELD: &str = "Σ Values"; + +/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name. +fn is_values_field(src: &pv::Source, field: &str) -> bool { + let f = field.trim(); + f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none()) +} + +/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of +/// Rows (`rows`) or Columns. +fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) { + if pt.values_on_rows != rows { + return; + } + let Some(p) = pt.values_position else { return }; + // Σ Values stays just ahead of the field it preceded. + let p = match inserted { + true if i <= p => p + 1, + false if i < p => p - 1, + _ => p, + }; + let n = if rows { pt.rows.len() } else { pt.columns.len() }; + pt.values_position = (p < n).then_some(p); +} + +/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry. +fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option { + let mut first = None; + for rows in [true, false] { + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break }; + let f = list.remove(i); + shift_values(pt, rows, i, false); + first.get_or_insert(f); + } + } + first +} + +/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end). +fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option) { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let at = position.unwrap_or(list.len()).min(list.len()); + list.insert(at, f); + shift_values(pt, rows, at, true); +} + +/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end). +/// In Columns it always comes after the last column field: the column layout doesn't place it +/// anywhere else yet. +fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option) -> Result { + let current = if pt.values_on_rows { "rows" } else { "columns" }; + if from != current { + return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area"))); + } + if !matches!(to, "rows" | "columns") { + return Err(other("The field you are moving cannot be placed in that PivotTable area.")); + } + pt.values_on_rows = to == "rows"; + pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len()); + Ok(json!({"field": VALUES_FIELD, "to": to})) +} + /// The canonical source field name for `field`. fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result { src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) @@ -384,21 +449,11 @@ fn add_to_area( if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) { return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS))); } - let existing = pt - .rows - .iter() - .chain(pt.columns.iter()) - .find(|f| f.source_col.eq_ignore_ascii_case(&field)) - .cloned() - .unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); - pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); - pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); + let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); // A report filter that selects nothing in particular goes away; a selection stays // as the field's hidden items. pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none())); - let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns }; - let at = position.unwrap_or(list.len()).min(list.len()); - list.insert(at, existing); + insert_axis_field(pt, area == "rows", existing, position); } "values" => { if pt.values.len() >= pv::MAX_VALUE_FIELDS { @@ -421,8 +476,7 @@ fn add_to_area( pt.values.insert(at, v); } _ => { - pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); - pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); + take_from_axes(pt, &field); let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); let entry = match existing { Some(i) => pt.filters.remove(i), @@ -439,12 +493,14 @@ fn add_to_area( fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result { match area { "rows" | "columns" => { - let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns }; + let rows = area == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; let i = list .iter() .position(|f| f.source_col.eq_ignore_ascii_case(field)) .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?; let f = list.remove(i); + shift_values(pt, rows, i, false); pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col)); Ok(f.source_col) } @@ -699,6 +755,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "style": pt.style, "showHeaders": pt.show_headers, "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + "valuesOnRows": pt.values_on_rows, + "valuesPosition": pt.values_position, }) } @@ -890,18 +948,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result { let to = area_param(C, str_param(p, "to"))?; let pos = position_param(p); update(s, p, |pt, src, _| { + if is_values_field(src, &field) { + return move_values_field(C, pt, from, to, pos); + } if from == to { // Reorder within the area. match from { "rows" | "columns" => { - let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns }; + let rows = from == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; let i = list .iter() .position(|f| f.source_col.eq_ignore_ascii_case(&field)) .ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?; let f = list.remove(i); - let at = pos.unwrap_or(list.len()).min(list.len()); - list.insert(at, f); + shift_values(pt, rows, i, false); + insert_axis_field(pt, rows, f, pos); } "values" => { let i = pt @@ -1270,8 +1332,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result { } ok }; - pt.rows.retain(|f| keep(&f.source_col, &mut dropped)); - pt.columns.retain(|f| keep(&f.source_col, &mut dropped)); + // Axis fields leave one by one so Σ Values keeps its place among the rest. + for rows in [true, false] { + let mut i = 0; + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(f) = list.get(i) else { break }; + if keep(&f.source_col, &mut dropped) { + i += 1; + } else { + list.remove(i); + shift_values(pt, rows, i, false); + } + } + } pt.values.retain(|f| keep(&f.source_col, &mut dropped)); pt.filters.retain(|f| keep(&f.source_col, &mut dropped)); Ok(json!({"source": source, "dropped": dropped})) @@ -1722,6 +1796,167 @@ mod tests { assert_eq!(text(&s, "A5"), "Apples"); } + #[test] + fn values_on_rows_layouts() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + assert_eq!(text(&s, "C3"), "Sum of Units"); + // Σ Values after Region: each region holds one line per value field. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + let p = pt(&s); + assert!(p.values_on_rows); + assert_eq!(p.values_position, None); + let r = p.last_range.unwrap(); + assert_eq!(r.width(), 2); + // header + 3 × (region + 2 values) + 2 grand totals + assert_eq!(r.height(), 1 + 9 + 2); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A6"), "Sum of Units"); + assert_eq!(val(&s, "B6"), Value::Number(33.0)); + assert_eq!(text(&s, "A13"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A14"), "Total Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(72.0)); + // Tabular, no column field: "Total" heads the data column, as Excel saves it. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Total"); + s.execute("pivot.layout", json!({"layout": "compact"})).unwrap(); + // Σ Values before Region: the value fields are the outer items. + s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "East"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A8"), "Sum of Units"); + assert_eq!(text(&s, "A9"), "East"); + assert_eq!(val(&s, "B9"), Value::Number(33.0)); + assert_eq!(text(&s, "A12"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(72.0)); + // Adding a row field in front keeps Σ Values ahead of Region. + s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(1)); + s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + // No row field: one line per value field and no grand total. + s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.height(), 1 + 2); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A5"), "Sum of Units"); + assert_eq!(val(&s, "B5"), Value::Number(72.0)); + // Back to Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap(); + assert!(!pt(&s).values_on_rows); + assert_eq!(text(&s, "A3"), "Sum of Sales"); + assert_eq!(text(&s, "B3"), "Sum of Units"); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err()); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); + } + + #[test] + fn collapsed_item_puts_values_in_the_values_column() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); + for layout in ["tabular", "outline"] { + s.execute("pivot.layout", json!({"layout": layout})).unwrap(); + assert_eq!(text(&s, "C3"), "Values", "{layout}"); + let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap(); + let first = if layout == "tabular" { east } else { east + 1 }; + assert_eq!(text(&s, &format!("B{first}")), "", "{layout}"); + assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}"); + assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}"); + assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}"); + } + } + + #[test] + fn values_stay_last_in_columns() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap(); + let p = pt(&s); + assert!(!p.values_on_rows); + assert_eq!(p.values_position, None); + // Σ Values to Rows and back with a position: still last in Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, None); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C5"), "Sum of Units"); + } + + #[test] + fn change_source_keeps_values_ahead_of_its_field() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // The new source has no Region: Σ Values stays ahead of Product. + s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap(); + let p = pt(&s); + assert_eq!(p.rows.len(), 1); + assert_eq!(p.values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(text(&s, "A5"), "Apples"); + } + + #[test] + fn values_on_rows_between_row_fields() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // Compact: East, then per value field its products, then per-value subtotals of East. + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(text(&s, "A6"), "Apples"); + assert_eq!(val(&s, "B6"), Value::Number(160.0)); + assert_eq!(text(&s, "A9"), "Sum of Units"); + assert_eq!(text(&s, "A10"), "Apples"); + assert_eq!(val(&s, "B10"), Value::Number(16.0)); + assert_eq!(text(&s, "A13"), "East Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(415.0)); + assert_eq!(text(&s, "A14"), "East Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(33.0)); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2); + // Outline: one column per level, "Values" heads the Σ Values column. + s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().width(), 4); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Product"); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C6"), "Apples"); + assert_eq!(val(&s, "D6"), Value::Number(160.0)); + // Tabular: labels start on their parent's row; totals per value field below each group. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B4"), "Sum of Sales"); + assert_eq!(text(&s, "C4"), "Apples"); + assert_eq!(val(&s, "D4"), Value::Number(160.0)); + assert_eq!(text(&s, "B7"), "Sum of Units"); + assert_eq!(text(&s, "C7"), "Apples"); + assert_eq!(val(&s, "D7"), Value::Number(16.0)); + assert_eq!(text(&s, "A10"), "East Sum of Sales"); + assert_eq!(val(&s, "D10"), Value::Number(415.0)); + assert_eq!(text(&s, "A11"), "East Sum of Units"); + let r = pt(&s).last_range.unwrap(); + let last = r.end.row + 1; + assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales"); + assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units"); + assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0)); + } + #[test] fn refresh_after_source_edits() { let mut s = sales(); @@ -2039,4 +2274,38 @@ mod tests { assert!(e.contains("field name is not valid"), "{e}"); assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err()); } + + #[test] + fn too_many_value_columns_are_refused() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Cat", "Sub", "V"])]; + for r in 0..64 { + for c in 0..64 { + records.push(json!([format!("c{r}"), format!("s{c}"), 1.0])); + } + } + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let req = |n: usize| { + let values: Vec = (0..n).map(|_| json!({"field": "V"})).collect(); + json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values}) + }; + let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string(); + assert!(e.contains("will not fit"), "{e}"); + assert!(s.execute("insert.pivotTable", req(128)).is_ok()); + } + + #[test] + fn collapsed_column_items_count_as_one_column_group() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Grp", "Sub", "V"])]; + records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0]))); + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let values: Vec = (0..256).map(|_| json!({"field": "V"})).collect(); + s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap(); + // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. + s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); + } } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 8ede4e25d..cc1dc860b 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -2054,6 +2054,23 @@ mod tests { assert!(s.execute("file.exportPdf", json!({})).is_ok()); } + #[test] + fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { + let mut s = s(); + for params in [json!({}), json!({"path": ""}), json!({"path": null})] { + let r = s.execute("file.exportPdf", params).unwrap(); + assert!(r.get("path").is_none(), "no file is written without a path"); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + } + assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); + // Headless callers (CLI, MCP) get the same bytes, not an error. + assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); + // With no workbook open it is a clear error, not a panic. + let mut none = Session::new(); + assert!(none.execute("file.exportPdf", json!({})).is_err()); + } + #[test] fn bands_unit() { let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index bcad8d48d..e8a17b7f6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -156,8 +156,19 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { /// Encodes a workbook in the format chosen by `path`'s extension. pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { + // An unknown or missing extension is refused rather than written as an XLSX under that name: + // a typo like `.cvs` would otherwise produce a file no other app recognises. + let Some(kind) = FileKind::from_path(path) else { + let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path); + return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html."))); + }; + save_bytes_as(wb, path, kind) +} + +/// Encodes a workbook as `kind`, whatever `path`'s extension says. +pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result> { let sheet = wb.active_sheet; - match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { + match kind { // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry // the VBA project, so any other name saves without it, as Excel does. FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())), @@ -167,17 +178,17 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). - FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()), - FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()), + FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), + FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), - FileKind::Html => Ok(to_html(wb, sheet).into_bytes()), + FileKind::Html => to_html(wb, sheet).map(String::into_bytes), } } /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. -fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { +fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { gridcraft_xlsx::write_csv(sh, delimiter, |c| { // General's grid-width formatting rounds large identifiers to scientific notation. // Delimited files have no column width, so preserve the whole numeric value instead. @@ -190,6 +201,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { crate::display::value_text(wb, sh, c) } }) + .map_err(|e| EngineError::Other(e.to_string())) } fn esc(s: &str) -> String { @@ -299,9 +311,12 @@ impl std::fmt::Write for ClipboardHtml { } } +/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells. +const HTML_EXPORT_MAX_ROWS: u32 = 100_001; + /// A sheet as a standalone HTML table (Save as Web Page). -pub fn to_html(wb: &Workbook, sheet: usize) -> String { - let Some(sh) = wb.sheet(sheet) else { return String::new() }; +pub fn to_html(wb: &Workbook, sheet: usize) -> Result { + let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) }; let mut out = String::new(); let _ = write!( out, @@ -309,7 +324,13 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { esc(&sh.name) ); if let Some(r) = sh.used_range() { - for row in r.start.row..=r.end.row.min(r.start.row + 100_000) { + if r.height() > HTML_EXPORT_MAX_ROWS { + return Err(EngineError::Other(format!( + "the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.", + r.height() + ))); + } + for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; } @@ -332,7 +353,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { } } out.push_str(""); - out + Ok(out) } #[cfg(not(target_arch = "wasm32"))] @@ -436,6 +457,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { mod tests { use super::*; + #[test] + fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); + sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); + assert!(save_bytes(&wb, "book.html").is_err()); + let sh = wb.sheet_mut(0).unwrap(); + sh.remove_cell(CellRef::new(149_999, 0)); + sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); + let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); + assert!(html.contains(">near") && html.contains(">far")); + } + #[test] fn macros_are_saved_only_into_xlsm() { let mut wb = Workbook::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3aceab093..5a2e3df98 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,6 +108,9 @@ pub struct DocState { pub sheet_selections: Vec, /// Allow Edit Ranges unlocked with their password in this session: (sheet name, title). pub unlocked_ranges: Vec<(String, String)>, + /// The format `path`'s content was recognised as when its extension doesn't name one (an + /// XLSX called `budget.dat`): Save writes it back in that format. + pub content_kind: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -132,6 +135,7 @@ impl DocState { title, sheet_selections: vec![], unlocked_ranges: vec![], + content_kind: None, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index c76bf4b9e..6146bd4f9 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -560,6 +560,8 @@ struct Line { values: bool, /// (column, value, indent, number format) labels: Vec<(usize, Value, u8, Option)>, + /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). + vi: Option, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -579,6 +581,10 @@ struct Ctx<'a> { rows: Vec<&'a PivotField>, row_items: Vec, row_fmts: Vec, + /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). + vpos: Option, + /// Value field captions. + val_names: Vec, } impl Ctx<'_> { @@ -588,26 +594,105 @@ impl Ctx<'_> { (it.value.clone(), fmt, it.label.clone()) } - fn flatten(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + /// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form. + fn level_cell(&self, level: usize) -> (usize, u8) { + if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) } + } + + /// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above). + fn values_here(&self, depth: usize, vi: Option) -> bool { + vi.is_none() && self.vpos == Some(depth) + } + + /// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales"). + fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec) { + for (vi, name) in self.val_names.iter().enumerate() { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.to_vec(), + values: true, + labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)], + vi: Some(vi), + }); + } + } + + /// The Σ Values level at row field depth `depth`: one item per value field, each followed by + /// the row fields below it (`nodes`) or, innermost, holding that value. + fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + let (col, indent) = self.level_cell(depth); + for (vi, name) in self.val_names.iter().enumerate() { + let labels = vec![(col, Value::text(name.as_str()), indent, None)]; + let leaf = nodes.is_empty(); + out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) }); + if !leaf { + self.flatten(nodes, depth, Some(vi), prefix, out); + } + } + } + + fn flatten(&self, nodes: &[Node], depth: usize, vi: Option, prefix: &mut Vec, out: &mut Vec) { + if self.values_here(depth, vi) { + self.flatten_values(nodes, depth, prefix, out); + return; + } let n = self.rows.len(); - let outline = self.pt.layout == PivotLayout::Outline; + // Σ Values still to come below this level: totals need one line per value field. + let values_below = vi.is_none() && self.vpos.is_some(); + let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some())); for node in nodes { prefix.push(node.item); let (v, fmt, label) = self.label_cell(depth, node.item); - let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) }; let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); - if depth + 1 >= n || collapsed || node.children.is_empty() { + let leaf = depth + 1 >= n || collapsed || node.children.is_empty(); + if leaf && !values_below { out.push(Line { kind: LineKind::Item(depth + 1 < n), leaf: true, path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)], + vi, }); + } else if leaf { + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + // Outline gives each level its own column, so the captions go in the Values + // column even when the levels between are collapsed away. + let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 }; + self.flatten_values(&[], level, prefix, out); + } else if values_below { + // Subtotals per value field, below the group (one line can't hold them all). + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, None, prefix, out); + if self.pt.subtotals { + self.value_subtotals(col, indent, &label, prefix, out); + } } else { let top = self.pt.subtotals && self.pt.subtotals_top; - out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] }); - self.flatten(&node.children, depth + 1, prefix, out); + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: top, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, vi, prefix, out); if self.pt.subtotals && !self.pt.subtotals_top { out.push(Line { kind: LineKind::Subtotal, @@ -615,6 +700,7 @@ impl Ctx<'_> { path: prefix.clone(), values: true, labels: vec![(col, Value::text(format!("{label} Total")), indent, None)], + vi, }); } } @@ -622,7 +708,8 @@ impl Ctx<'_> { } } - fn flatten_tabular( + /// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels. + fn flatten_tabular_values( &self, nodes: &[Node], depth: usize, @@ -630,24 +717,57 @@ impl Ctx<'_> { pending: &[(usize, Value, u8, Option)], out: &mut Vec, ) { + for (vi, name) in self.val_names.iter().enumerate() { + let mut labels: Vec<(usize, Value, u8, Option)> = if vi == 0 { pending.to_vec() } else { vec![] }; + labels.push((depth, Value::text(name.as_str()), 0, None)); + if nodes.is_empty() { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) }); + } else { + self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out); + } + } + } + + fn flatten_tabular( + &self, + nodes: &[Node], + depth: usize, + vi: Option, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + if self.values_here(depth, vi) { + self.flatten_tabular_values(nodes, depth, prefix, pending, out); + return; + } let n = self.rows.len(); + let values_below = vi.is_none() && self.vpos.is_some(); + let col = depth + usize::from(vi.is_some() && self.vpos.is_some()); for (i, node) in nodes.iter().enumerate() { prefix.push(node.item); let (v, fmt, label) = self.label_cell(depth, node.item); let mut labels: Vec<(usize, Value, u8, Option)> = if i == 0 { pending.to_vec() } else { vec![] }; - labels.push((depth, v, 0, fmt)); + labels.push((col, v, 0, fmt)); let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); if depth + 1 >= n || collapsed || node.children.is_empty() { - out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels }); + if values_below { + self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out); + } else { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); + } } else { - self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out); - if self.pt.subtotals { + self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out); + if self.pt.subtotals && values_below { + self.value_subtotals(col, 0, &label, prefix, out); + } else if self.pt.subtotals { out.push(Line { kind: LineKind::Subtotal, leaf: false, path: prefix.clone(), values: true, - labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)], + labels: vec![(col, Value::text(format!("{label} Total")), 0, None)], + vi, }); } } @@ -693,6 +813,12 @@ enum ValSrc { Calc(usize), } +/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields +/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count. +fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool { + (col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize +} + /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; @@ -745,6 +871,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = (pt.values_on_rows && k > 1).then(|| { + let p = pt.values_position.unwrap_or(pt.rows.len()); + pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count() + }); + let on_rows = vpos.is_some(); + let row_levels = rows.len() + usize::from(on_rows); // ---- aggregate let mut row_items: Vec = rows.iter().map(|_| FieldItems::default()).collect(); @@ -752,6 +886,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result> = HashSet::new(); let mut col_set: HashSet> = HashSet::new(); + let mut col_leaves: HashSet> = HashSet::new(); let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); @@ -788,8 +923,22 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 5_000_000 { return Err("The PivotTable report is too large.".into()); } @@ -806,24 +955,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = row_tree.iter().map(|n| n.item).collect(); - let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() }; + let val_names: Vec = vals.iter().map(|(_, v)| v.name.clone()).collect(); + let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() }; let mut lines: Vec = Vec::new(); - if rows.is_empty() { - lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] }); + if row_levels == 0 { + lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None }); } else { if pt.layout == PivotLayout::Tabular { - ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines); + ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines); } else { - ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines); + ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines); } - if pt.grand_totals_cols { - lines.push(Line { + // With only Σ Values in Rows its items are the totals already. + if pt.grand_totals_cols && !rows.is_empty() { + let grand = |text: String, vi: Option| Line { kind: LineKind::Grand, leaf: false, path: vec![], values: true, - labels: vec![(0, Value::text("Grand Total"), 0, None)], - }); + labels: vec![(0, Value::text(text), 0, None)], + vi, + }; + if on_rows { + lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi)))); + } else { + lines.push(grand("Grand Total".into(), None)); + } } } let mut specs: Vec = Vec::new(); @@ -837,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result)> = Vec::new(); for (si, _) in specs.iter().enumerate() { - if k == 0 { + if on_rows { + // Each line names its value field; one data column per column item. + data_cols.push((si, None)); + } else if k == 0 { if !cols.is_empty() { data_cols.push((si, None)); } @@ -852,12 +1012,12 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result = row_fields.iter().map(|f| f.source_col.as_str()).collect(); + if let Some(p) = vpos { + names.insert(p.min(names.len()), "Values"); + } + for (i, name) in names.into_iter().enumerate() { + g.set(r, i, Value::text(name), CellKind::Header, 0, None); } } }; @@ -920,8 +1085,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None), + // Values on rows, no column field: the one data column is the grand total. + None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None), + None => {} } } } else { @@ -933,7 +1101,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 1); + let levels = cols.len() + usize::from(k > 1 && !on_rows); let first_level = g.rows.len(); for _ in 0..levels { g.push_row(RowKind::Header)?; @@ -1048,17 +1216,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 1 || parts.is_empty()) { parts.push(val_name(*vi)); + } else if parts.is_empty() { + parts.push("Total".into()); } Some(((l + j) as u32, parts.join(" - "))) }) .filter(|_| k > 0) .collect(); - let label_col = if rows.is_empty() { + let label_col = if row_levels == 0 { None } else if compact { Some(0) } else { - Some(rows.len().saturating_sub(1) as u32) + Some(row_levels.saturating_sub(1) as u32) }; for line in &lines { let rk = match line.kind { @@ -1082,8 +1252,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result bool { self.ranges.len() == 1 && self.current().is_single() } + /// The corner of the current area opposite the anchor: the end that Shift+arrows move. + pub fn moving_end(&self) -> CellRef { + let r = self.current(); + let row = if self.anchor.row == r.start.row { r.end.row } else { r.start.row }; + let col = if self.anchor.col == r.start.col { r.end.col } else { r.start.col }; + CellRef::new(row, col) + } /// Extends the current area from the anchor to `to` (Shift+click / Shift+arrows). pub fn extend_to(&mut self, to: CellRef) { let r = RangeRef::new(self.anchor, to); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9acf508da..0225b838c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -805,6 +805,46 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +/// Top 10 keeps the top or bottom N items or percent, ties at the threshold included. +#[test] +fn filter_top10_items_and_percent() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Score"], [5], [1], [9], [3], [9], [7], ["x"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + // Top 2 items: 9, 9 (the "x" text row is hidden too). + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2}})).unwrap(); + assert_eq!(r["hiddenRows"], 5); + // Bottom 2 items: 1, 3. + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2, "bottom": true}})).unwrap(); + assert_eq!(r["hiddenRows"], 5); + // Top 50 percent of 6 numbers = 3 items; the threshold 7 keeps 9, 9, 7. + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 50, "percent": true}})).unwrap(); + assert_eq!(r["hiddenRows"], 4); +} + +/// Two Top 10 criteria on one column (possible in a native JSON file) each keep their own +/// cutoff: Top 3 and Bottom 4 of 1..5 keep 3 and 4. +#[test] +fn filter_top10_two_criteria_on_one_column() { + use gridcraft_model::FilterCriterion; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["N"], [1], [2], [3], [4], [5]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + if let Some(af) = sh.autofilter.as_mut() { + af.criteria = vec![ + (0, FilterCriterion::Top10 { bottom: false, percent: false, count: 3 }), + (0, FilterCriterion::Top10 { bottom: true, percent: false, count: 4 }), + ]; + } + s.execute("data.reapply", json!({})).unwrap(); + let hidden: Vec = s.active().unwrap().wb.sheet(0).unwrap().rows.iter().filter(|(_, r)| r.hidden).map(|(k, _)| *k).collect(); + // Rows are 0-based: values 1, 2 and 5 sit on rows 1, 2 and 5. + assert_eq!(hidden, vec![1, 2, 5]); +} + /// Turning the filter on with only the header row selected filters the data below it (#201). #[test] fn filter_on_a_selected_header_row_covers_the_data_below() { @@ -1018,6 +1058,36 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn structured_refs_to_columns_with_edge_spaces() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap(); + s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap(); + assert_eq!(v(&s, "B4"), Value::Number(5.0)); + for (cell, input) in [ + ("F1", "=SUM(Table1[Qty ])"), + ("F2", "=Table1[[#Totals],[Qty ]]"), + ("F3", "=SUBTOTAL(109,Table1[Qty ])"), + ("F4", "=SUM(Table1[[Qty ]])"), + ("F5", "=SUM(Table1[Qty])"), + ("C2", "=Table1[@[Qty ]]*10"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let n = Value::Number; + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]); + assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref)); + assert_eq!(v(&s, "C2"), n(20.0)); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]); +} + #[test] fn table_rename_keeps_a_defined_name_with_the_same_name() { let mut s = s(); @@ -1550,6 +1620,65 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { std::fs::remove_dir_all(dir).unwrap(); } +/// A typo like `report.cvs` must not quietly write an XLSX under that name and adopt it as the +/// document's file. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn saving_to_an_unknown_extension_is_refused() { + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + for name in ["report.cvs", "report"] { + let path = dir.join(name); + let e = s.execute("file.save", json!({"path": path})).unwrap_err().to_string(); + assert!(e.contains(".xlsx"), "{name}: {e}"); + assert!(!path.exists(), "{name} was written"); + } + let d = s.doc().unwrap(); + assert!(d.path.is_none()); + assert!(d.is_dirty()); + assert!(s.execute("file.saveBytes", json!({"format": "foo"})).is_err()); + s.execute("file.save", json!({"path": dir.join("report.csv")})).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// An XLSX opened by its content under an unknown extension (`budget.dat`) saves back to its own +/// file as XLSX; a Save As to another unknown extension is still refused. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn an_xlsx_opened_under_an_unknown_extension_saves_back_as_xlsx() { + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-dat-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("budget.dat"); + let mut src = s(); + src.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + std::fs::write(&path, crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap()).unwrap(); + let mut s = s(); + s.execute("file.open", json!({"path": path})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + s.execute("file.save", json!({})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsx); + let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + assert_eq!(wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(2.0)); + assert!(s.execute("file.saveAs", json!({"path": dir.join("copy.foo")})).is_err()); + assert!(!dir.join("copy.foo").exists()); + // A text file under an unknown extension opens as CSV: Save doesn't turn it into an XLSX. + let notes = dir.join("notes.dat"); + std::fs::write(¬es, "a,b\r\n").unwrap(); + s.execute("file.open", json!({"path": notes})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "c"})).unwrap(); + assert!(s.execute("file.save", json!({})).is_err()); + assert_eq!(std::fs::read_to_string(¬es).unwrap(), "a,b\r\n"); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn validation_formulas_are_relative_and_see_the_new_value() { let mut s = s(); @@ -1738,6 +1867,23 @@ fn every_command_survives_empty_params() { } } +#[test] +fn non_object_params_do_not_panic_in_wrapper_commands() { + // Issue #516. + for cmd in ["pageLayout.themeColors", "pageLayout.background", "home.drawBorder"] { + for params in [json!([]), json!(5), json!("x"), json!(true)] { + let mut s = s(); + let r = s.execute(cmd, params.clone()); + assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{cmd} {params}: {r:?}"); + } + } + // A saved script step forwards its params unchanged into the same call. + let mut s = s(); + s.execute("automate.newScript", json!({"name": "bad", "commands": [{"command": "home.drawBorder", "params": []}]})).unwrap(); + let r = s.execute("automate.runScript", json!({"name": "bad"})); + assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "script: {r:?}"); +} + fn fmt_of(s: &Session, a: &str) -> String { let wb = &s.doc().unwrap().wb; let sh = wb.active().unwrap(); @@ -2600,6 +2746,56 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.cells, before.cells); } +/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is +/// a place in this document, anything else an existing file or web page. A file saved as a +/// `location` opens nothing in Excel (#800). +#[test] +fn links_to_files_are_saved_as_external_relationships() { + use std::io::Read; + let mut s = s(); + s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap(); + s.execute("insert.table", json!({"range": "F1:G3", "name": "Sales"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Q#1"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let links = [ + ("A1", "Source.xlsx", true), + ("A2", r"C:\Temp\Book.xlsx", true), + ("A3", r"..\Reports\Q1.xlsx", true), + ("A4", "Other.xlsx#Sheet1!A2", true), + ("A5", "https://example.com/a.xlsx", true), + ("A6", "Sheet1!B2", false), + ("A7", "#Sheet1!B3", false), + ("A8", "Price", false), + ("A9", "'Sheet1'!C4:D5", false), + ("A10", "Sales", false), + ("A11", "'Q#1'!B2", false), + ]; + for (cell, target, _) in links { + s.execute("insert.link", json!({"cell": cell, "target": target})).unwrap(); + } + let bytes = gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let mut part = |name: &str| { + let mut text = String::new(); + zip.by_name(name).unwrap().read_to_string(&mut text).unwrap(); + text + }; + let (sheet, rels) = (part("xl/worksheets/sheet1.xml"), part("xl/worksheets/_rels/sheet1.xml.rels")); + for (cell, target, external) in links { + let file = target.split_once('#').map_or(target, |(f, _)| f); + let as_rel = format!("Target=\"{file}\" TargetMode=\"External\""); + let as_location = format!("location=\"{}\"", target.trim_start_matches('#')); + assert_eq!(rels.contains(&as_rel), external, "{cell} {target}: {rels}"); + assert_eq!(sheet.contains(&as_location), !external, "{cell} {target}: {sheet}"); + } + assert!(sheet.contains("location=\"Sheet1!A2\""), "{sheet}"); + let (back, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap(); + for (cell, target, external) in links { + let h = &back.sheet(0).unwrap().hyperlinks[&CellRef::parse(cell).unwrap()]; + assert_eq!((h.target.as_str(), h.external), (target.trim_start_matches('#'), external), "{cell}"); + } +} + #[test] fn vertical_alignment_commands_and_roundtrip() { let mut s = s(); @@ -2847,6 +3043,22 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + // A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL). + s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); +} + #[test] fn paste_special_validation_copies_the_rule() { let mut s = s(); diff --git a/crates/engine/tests/csv_export_budget.rs b/crates/engine/tests/csv_export_budget.rs new file mode 100644 index 000000000..22068d1c9 --- /dev/null +++ b/crates/engine/tests/csv_export_budget.rs @@ -0,0 +1,45 @@ +//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as +//! big as the whole rectangle between them. A refused export is an error and writes nothing. + +use gridcraft_engine::Session; +use serde_json::json; + +fn two_far_cells() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap(); + s +} + +#[test] +fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() { + let mut s = two_far_cells(); + for format in ["csv", "tsv"] { + let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string(); + assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}"); + } + // Save As and Export write no file, and the document stays as it was. + let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.csv"); + let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string(); + assert!(err.contains("over the limit"), "{err}"); + assert!(!path.exists(), "nothing is written for a refused export"); + std::fs::remove_dir_all(&dir).unwrap(); + // Other formats are unaffected, and so is a small export from the same session. + assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3); +} + +#[test] +fn exports_below_the_cap_keep_their_positions() { + for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}"); + } +} diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/ink_roundtrip.rs new file mode 100644 index 000000000..bff1b61ed --- /dev/null +++ b/crates/engine/tests/ink_roundtrip.rs @@ -0,0 +1,135 @@ +//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit. + +use gridcraft_engine::Session; +use gridcraft_engine::cmd::draw::{ink_points, ink_width}; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Shape, ShapeKind}; +use serde_json::json; + +fn workbook() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +fn reopen(session: &mut Session) -> Session { + let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap(); + reopened +} + +fn shapes(session: &Session) -> &[Shape] { + &session.doc().unwrap().wb.active().unwrap().shapes +} + +fn assert_anchor(actual: &Shape, expected: &Shape) { + assert_eq!(actual.anchor.cell, expected.anchor.cell); + assert_eq!(actual.anchor.mode, expected.anchor.mode); + for (name, actual, expected) in [ + ("dx", actual.anchor.dx, expected.anchor.dx), + ("dy", actual.anchor.dy, expected.anchor.dy), + ("width", actual.anchor.width, expected.anchor.width), + ("height", actual.anchor.height, expected.anchor.height), + ] { + assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}"); + } +} + +fn assert_ink(actual: &Shape, expected: &Shape) { + assert_eq!(actual.kind, ShapeKind::Ink); + let actual_points = ink_points(actual); + let expected_points = ink_points(expected); + assert_eq!(actual_points.len(), expected_points.len()); + for (actual, expected) in actual_points.iter().zip(&expected_points) { + for axis in 0..2 { + // DrawingML quantizes coordinates to integer EMUs (9,525 per model unit). + assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}"); + } + } + assert_eq!(actual.line, expected.line); + assert_eq!(ink_width(actual), ink_width(expected)); + assert_anchor(actual, expected); +} + +#[test] +fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() { + let mut session = workbook(); + session + .execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3})) + .unwrap(); + session + .execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7})) + .unwrap(); + let expected = shapes(&session).to_vec(); + assert_eq!(expected.len(), 2); + + for pass in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), expected.len()); + for (actual, expected) in shapes(&session).iter().zip(&expected) { + assert_ink(actual, expected); + } + if pass == 0 { + session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap(); + } + } + assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0)); +} + +#[test] +fn reopened_ink_can_still_be_converted_to_a_shape() { + for (points, expected_kind, expected_name) in [ + (vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"), + (vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"), + ] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap(); + let original = shapes(&session)[0].clone(); + session = reopen(&mut session); + let id = shapes(&session)[0].id; + let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap(); + assert_eq!(converted["kind"], expected_name); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, expected_kind); + assert_eq!(shape.line, original.line); + assert!(shape.text.is_empty()); + assert_anchor(shape, &original); + } +} + +#[test] +fn ordinary_shape_text_is_not_mistaken_for_ink_points() { + let mut session = workbook(); + session + .execute( + "insert.shape", + json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}), + ) + .unwrap(); + let original = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), 1); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, ShapeKind::Rectangle); + assert_eq!(shape.text, original.text); + assert_eq!(shape.fill, original.fill); + assert_eq!(shape.line, original.line); + assert_anchor(shape, &original); + assert!(session.execute("draw.inkToShape", json!({})).is_err()); + } +} + +#[test] +fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() { + for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap(); + let expected = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_ink(&shapes(&session)[0], &expected); + } + } +} diff --git a/crates/engine/tests/stored_list_shape.rs b/crates/engine/tests/stored_list_shape.rs new file mode 100644 index 000000000..0d38cd5f9 --- /dev/null +++ b/crates/engine/tests/stored_list_shape.rs @@ -0,0 +1,48 @@ +use gridcraft_engine::Session; +use serde_json::json; + +const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\""]; + +fn with_name(name: &str, refers_to: &str) -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap(); + s +} + +#[test] +fn saving_a_view_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_custom_views", text); + let out = s.execute("view.customViews", json!({"save": "v"})).unwrap(); + assert!(out.get("v").is_some(), "{text}: {out}"); + } +} + +#[test] +fn saving_a_view_keeps_the_keys_of_a_stored_object() { + let mut s = with_name("_sc_custom_views", "\"{\"\"a\"\":1}\""); + let out = s.execute("view.customViews", json!({"save": "v"})).unwrap(); + assert!(out.get("a").is_some() && out.get("v").is_some(), "{out}"); +} + +#[test] +fn creating_a_script_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_scripts", text); + s.execute("automate.newScript", json!({"name": "s1", "commands": []})).unwrap(); + let list = s.execute("automate.allScripts", json!({})).unwrap(); + assert!(list.to_string().contains("s1"), "{text}: {list}"); + } +} + +#[test] +fn stopping_a_recording_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_scripts", text); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "s1"})).unwrap(); + let list = s.execute("automate.allScripts", json!({})).unwrap(); + assert!(list.to_string().contains("s1"), "{text}: {list}"); + } +} diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 6f01e0507..d4881aa9e 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -264,11 +264,10 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr { } Expr::Ref(r) } - // Sheet-qualified defined name `Old!MyName` is stored as one string. + // Sheet-qualified defined name `Old!MyName` is stored as one string, with the bare sheet name as the parser + // stores it (the printer adds the quotes). Expr::Name(n) => match n.split_at_checked(old.len()) { - Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => { - Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new))) - } + Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => Expr::Name(format!("{new}{rest}")), _ => Expr::Name(n), }, other => other, diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 355a96428..3ea97085e 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -89,15 +89,19 @@ fn word_is_cell(w: &str) -> Option { Some(Anchor { row, col, row_abs, col_abs }) } -/// `_xlfn.XLOOKUP` → `XLOOKUP`. +/// `_xlfn.XLOOKUP` → `XLOOKUP`. `_xludf.` is kept (`_xludf.SUM`): it marks a user-defined +/// function, never the built-in of the same name, and files need it back. pub fn normalize_function_name(name: &str) -> String { let u = name.to_ascii_uppercase(); - for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS.", "_XLUDF."] { + for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS."] { if let Some(r) = u.strip_prefix(p) { return r.to_string(); } } - u + match u.strip_prefix("_XLUDF.") { + Some(r) => format!("_xludf.{r}"), + None => u, + } } impl Parser { @@ -358,6 +362,10 @@ impl Parser { if w.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Err(self.err(format!("unexpected `{w}`"))); } + // A function passed by name is saved as `_xleta.SUM`. + if let Some(f) = w.get(..7).filter(|p| p.eq_ignore_ascii_case("_xleta.")).and_then(|_| w.get(7..)) { + return Ok(Expr::Name(f.to_ascii_uppercase())); + } Ok(Expr::Name(w.to_string())) } @@ -418,6 +426,9 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr { pub fn parse_struct(table: &str, body: &str) -> Result { let mut specifiers = Vec::new(); let mut cols: Vec = Vec::new(); + // Spaces at the edges of a column name are part of it (`Table1[Qty ]` names `Qty `), so the + // untrimmed body is what a lone column is read from. + let raw = body; let body = body.trim(); let unescape = |s: &str| { let mut out = String::new(); @@ -469,7 +480,7 @@ pub fn parse_struct(table: &str, body: &str) -> Result { } else if let Some(i) = item(body) { specifiers.push(i); } else if !body.is_empty() { - cols.push(column(body)?); + cols.push(column(raw)?); } } else { // [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1] diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index eb981efac..3975a7c61 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -91,6 +91,16 @@ fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String { } pub fn struct_ref(s: &StructRef) -> String { + write_struct_ref(s, true) +} + +/// Structured reference as files spell it: `[@Col]` is only Excel's display shorthand, files +/// hold `[[#This Row],[Col]]` (and `[#This Row]` for `[@]`). +pub fn struct_ref_file(s: &StructRef) -> String { + write_struct_ref(s, false) +} + +fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); let item = |i: &StructItem| match i { StructItem::All => "#All", @@ -99,7 +109,7 @@ pub fn struct_ref(s: &StructRef) -> String { StructItem::Totals => "#Totals", StructItem::ThisRow => "#This Row", }; - let this_row = s.specifiers == [StructItem::ThisRow]; + let this_row = at_shorthand && s.specifiers == [StructItem::ThisRow]; let mut out = s.table.clone(); let simple_col = |c: &str| !c.chars().any(|ch| matches!(ch, ' ' | '[' | ']' | '#' | '\'' | ',' | ':')); match (&s.col_start, &s.col_end) { @@ -109,7 +119,12 @@ pub fn struct_ref(s: &StructRef) -> String { let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); } (Some(c), None) if s.specifiers.is_empty() => { - let _ = write!(out, "[{}]", esc(c)); + // Excel writes a name with spaces at its edges in the double-bracket form. + if c.trim() == c { + let _ = write!(out, "[{}]", esc(c)); + } else { + let _ = write!(out, "[[{}]]", esc(c)); + } } (Some(c), None) if this_row => { if simple_col(c) { @@ -178,7 +193,15 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => s.push_str(n), + Expr::Name(n) => match n.rsplit_once('!') { + // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. + Some((sheet, name)) => { + s.push_str("e_sheet(sheet)); + s.push('!'); + s.push_str(name); + } + _ => s.push_str(n), + }, Expr::Struct(st) => s.push_str(&struct_ref(st)), Expr::Unary(op, x) => match op { UnOp::Neg => { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index a63882c1b..103a26bd1 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -5,6 +5,13 @@ fn rt(src: &str) -> String { print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) } +#[test] +fn quoted_sheet_qualified_name_keeps_quotes() { + assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total"); + assert_eq!(rt("'Data'!Total"), "Data!Total"); + assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1"); +} + #[test] fn intersection_with_deleted_operand_reparses() { // Deleting the columns of an intersection operand prints `A1:B2 #REF!`; that text must parse back. @@ -64,6 +71,8 @@ fn normalises() { assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); assert_eq!(rt("= 1 + 2"), "1+2"); assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("_xlfn.GROUPBY(A1:A3,B1:B3,_xleta.SUM)"), "GROUPBY(A1:A3,B1:B3,SUM)"); + assert_eq!(rt("_XLUDF.concat(\"a\",\"b\")"), "_xludf.CONCAT(\"a\",\"b\")"); assert_eq!(rt("true"), "TRUE"); assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } @@ -199,6 +208,10 @@ fn rename_sheet_updates_qualified_names() { assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName"); let e = parse("Data!MyName").unwrap(); 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"); + assert_eq!(e, Expr::Name("It's!MyName".into())); + assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName"); } #[test] @@ -264,6 +277,20 @@ fn unbracketed_this_row_column() { } } +#[test] +fn struct_column_keeps_edge_spaces() { + // The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`. + let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() }; + assert_eq!(s.col_start.as_deref(), Some("Qty ")); + let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() }; + 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); + } +} + #[test] fn trim_references() { for f in [ diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8a9..2b4c48886 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,6 +5,10 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; +/// Longest text criterion COUNTIF and its relatives accept, in UTF-16 code units as Excel +/// counts them (😀 is two units); a longer one is `#VALUE!`. +const MAX_CRITERION_UTF16_UNITS: usize = 255; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { Eq, @@ -22,6 +26,15 @@ enum Operand { Error(CellError), /// Text (lower-cased chars kept for wildcard matching). Text(String), + /// A criterion cut to its first 255 UTF-16 units in the middle of a surrogate pair: the + /// complete characters before the cut, followed (in Excel) by a lone high surrogate. + /// + /// No cell text holds a lone surrogate, so `=` matches nothing and `<>` everything, as + /// Excel gives 0 for SUMIFS with a criterion of 128 emoji. For `<`, `<=`, `>` and `>=` the + /// criterion sorts just above the kept text: a cell matches `>`/`>=` when it sorts above + /// that text, and `<`/`<=` otherwise. This is an approximation (Excel wasn't checked) and + /// only matters for comparison criteria over 255 units. + CutText(String), /// Nothing after the operator (`"="`, `"<>"`) or an empty criterion. Blank, } @@ -48,25 +61,52 @@ impl Criterion { } } - fn parse_text(t: &str) -> Criterion { - let (op, rest) = if let Some(r) = t.strip_prefix(">=") { - (Op::Ge, r) - } else if let Some(r) = t.strip_prefix("<=") { - (Op::Le, r) - } else if let Some(r) = t.strip_prefix("<>") { - (Op::Ne, r) - } else if let Some(r) = t.strip_prefix('=') { - (Op::Eq, r) - } else if let Some(r) = t.strip_prefix('>') { - (Op::Gt, r) - } else if let Some(r) = t.strip_prefix('<') { - (Op::Lt, r) - } else { - if t.is_empty() { - return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; + /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS + /// functions: like `parse`, but text longer than 255 UTF-16 code units is `#VALUE!`, as in + /// Excel (checked on Excel 2016 and Microsoft 365: `"a"×254 & "😀"` is 255 characters but + /// 256 units, and is `#VALUE!`). + pub(crate) fn parse_arg(v: &Value) -> Result { + match v { + Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), + // A UTF-8 string has at least as many bytes as UTF-16 units, so short text skips the count. + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS && t.encode_utf16().nth(MAX_CRITERION_UTF16_UNITS).is_some() => { + Err(CellError::Value) } - (Op::Eq, t) - }; + other => Ok(Criterion::parse(other)), + } + } + + /// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel + /// doesn't reject a longer text criterion there: it matches its first 255 UTF-16 code units + /// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and + /// MINIFS). A cut through a surrogate pair matches nothing; see `Operand::CutText`. + pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion { + match v { + Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS => { + let mut units = 0; + for (i, c) in t.char_indices() { + if units + c.len_utf16() > MAX_CRITERION_UTF16_UNITS { + if units == MAX_CRITERION_UTF16_UNITS { + return Criterion::parse_text(&t[..i]); + } + // The cut falls between the two halves of `c`. + let (op, rest) = split_op(&t[..i]); + return Criterion { op, operand: Operand::CutText(rest.to_string()), bare_empty: false }; + } + units += c.len_utf16(); + } + Criterion::parse_text(t) + } + other => Criterion::parse(other), + } + } + + fn parse_text(t: &str) -> Criterion { + if t.is_empty() { + return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; + } + let (op, rest) = split_op(t); let operand = if rest.is_empty() { Operand::Blank } else if let Some(n) = parse_number_text(rest) { @@ -140,10 +180,35 @@ impl Criterion { _ => false, }, }, + Operand::CutText(kept) => match self.op { + Op::Eq => false, + Op::Ne => true, + Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater), + Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater), + }, } } } +/// Splits a leading comparison operator (`=` when there is none) off a text criterion. +fn split_op(t: &str) -> (Op, &str) { + if let Some(r) = t.strip_prefix(">=") { + (Op::Ge, r) + } else if let Some(r) = t.strip_prefix("<=") { + (Op::Le, r) + } else if let Some(r) = t.strip_prefix("<>") { + (Op::Ne, r) + } else if let Some(r) = t.strip_prefix('=') { + (Op::Eq, r) + } else if let Some(r) = t.strip_prefix('>') { + (Op::Gt, r) + } else if let Some(r) = t.strip_prefix('<') { + (Op::Lt, r) + } else { + (Op::Eq, t) + } +} + fn cmp_op(op: Op, o: Ordering) -> bool { match op { Op::Eq => o == Ordering::Equal, diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 38e372975..67132e98f 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,9 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A +//! formula under a label that names no field is a computed criterion, which the host evaluates +//! for each record ([`Ctx::computed_criterion`]). use std::borrow::Cow; @@ -49,7 +51,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { fn cell_criterion(v: &Value) -> Option { match v { Value::Empty => None, - Value::Text(t) if t.is_empty() => None, Value::Text(t) => { let s: &str = t; let has_op = s.starts_with(['=', '<', '>']); @@ -65,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option { } /// Rows (1-based within the database, excluding the header) of matching records. -fn matching_records(db: &Array, crit: &Array) -> Vec { - // Per criteria row: list of (database column or None when the label is unknown, criterion). - let mut rows: Vec, Criterion)>> = Vec::new(); +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(); for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { @@ -78,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec { Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)), _ => None, }; - conds.push((col, cr)); + conds.push((c, col, cr)); } - rows.push(conds); + rows.push((r, conds)); } let all_match = rows.is_empty(); (1..db.rows) - .filter(|&r| { + .filter(|&rec| { all_match - || rows.iter().any(|conds| { - conds.iter().all(|(col, cr)| match col { - Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)), - None => false, + || rows.iter().any(|(r, conds)| { + conds.iter().all(|(c, col, cr)| match col { + Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)), + // Under a label that names no field, only a formula (a computed + // criterion) can hold. + None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false), }) }) }) @@ -102,7 +106,7 @@ struct Selection<'a> { records: Vec, } -fn select(a: &[Arg]) -> R> { +fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R> { let dbv = &a.first().ok_or(CellError::Value)?.value; let critv = &a.get(2).ok_or(CellError::Value)?.value; if let Value::Error(e) = dbv { @@ -114,31 +118,31 @@ fn select(a: &[Arg]) -> R> { let db = as_array(dbv); let crit = as_array(critv); let field = field_index(&db, a)?; - let records = matching_records(&db, &crit); + let records = matching_records(&db, &crit, ctx); Ok(Selection { db, field, records }) } /// Numbers of the field among matching records (field required). -fn field_numbers(a: &[Arg]) -> R> { - let s = select(a)?; +fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R> { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect()) } -fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(field_numbers(a)?.iter().sum()) +fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(field_numbers(a, c)?.iter().sum()) } -fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return Err(CellError::Div0); } num_val(v.iter().sum::() / v.len() as f64) } -fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(), None => s.records.len(), @@ -146,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(), None => s.records.len(), @@ -155,28 +159,31 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dget(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; - match s.records.as_slice() { - [] => Err(CellError::Value), - [r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)), + // Records whose field is blank (or "") don't count, as in Excel. + let mut found = + s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty())); + match (found.next(), found.next()) { + (None, _) => Err(CellError::Value), + (Some(v), None) => Ok(v.clone()), _ => Err(CellError::Num), } } -fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) } -fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) } -fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return num_val(0.0); } @@ -194,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R { Ok(ss / if sample { n - 1.0 } else { n }) } -fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?) +fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?) } -fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?) +fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?) } -fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?.sqrt()) +fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?.sqrt()) } -fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?.sqrt()) +fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?.sqrt()) } pub(crate) fn specs() -> Vec { @@ -371,4 +378,69 @@ mod tests { is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref); close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0); } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86). + #[test] + fn empty_string_criterion() { + let db = arr(vec![ + vec![tv("Index"), tv("Value")], + vec![tv("a"), tv("=")], + vec![tv("b"), Value::Empty], + vec![tv("c"), tv("")], + vec![tv("d"), tv(" ")], + vec![tv("f"), Value::Error(CellError::Value)], + vec![tv("h"), tv("abc")], + vec![tv("i"), nv(0.0)], + ]); + let get = |index: &str| { + let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]); + ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)]) + }; + // Like any plain text, "" is a "begins with" pattern: it matches every text value. + is_text(get("c"), "c"); + is_text(get("h"), "h"); + is_err(get("b"), CellError::Value); + is_err(get("i"), CellError::Value); + is_err(get("f"), CellError::Value); + let all = arr(vec![vec![tv("Value")], vec![tv("")]]); + close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0); + + // Under a label that names no field, "" is a condition that never holds. + let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]); + let get = |label: Value, value: Value| { + let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]); + ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)]) + }; + is_text(get(Value::Empty, Value::Empty), "a"); + is_text(get(tv(""), Value::Empty), "a"); + is_err(get(Value::Empty, tv("")), CellError::Value); + is_err(get(tv(""), tv("")), CellError::Value); + } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119). + #[test] + fn dget_skips_empty_fields() { + let db = arr(vec![ + vec![tv("One"), tv("Two")], + vec![tv("a"), Value::Empty], + vec![tv("b"), tv(" ")], + vec![tv("c"), tv("")], + vec![tv("d"), Value::Error(CellError::NA)], + vec![tv("j"), Value::Empty], + vec![tv("j"), tv("Text")], + vec![tv("j"), Value::Empty], + vec![tv("k"), Value::Empty], + vec![tv("k"), tv("")], + ]); + let get = |one: &str| { + let crit = arr(vec![vec![tv("One")], vec![tv(one)]]); + ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)]) + }; + is_err(get("a"), CellError::Value); + is_text(get("b"), " "); + is_err(get("c"), CellError::Value); + is_err(get("d"), CellError::NA); + is_text(get("j"), "Text"); + is_err(get("k"), CellError::Value); + } } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230ce..148d68221 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::OnceLock; -use gridcraft_core::{CellError, DateSystem, Value}; +use gridcraft_core::{Array, CellError, DateSystem, Value}; /// Builds a [`FnSpec`] whose implementation returns `Result`. /// @@ -105,6 +105,12 @@ pub trait Ctx { fn now_serial(&self) -> f64; /// Uniform in [0, 1). fn random(&mut self) -> f64; + /// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds + /// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the + /// criteria didn't come from a reference. + fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option { + None + } } pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value; @@ -197,7 +203,8 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { }) } -/// Looks a function up by name (case-insensitive; `_xlfn.`, `_xlws.` and `_xludf.`-free names). +/// Looks a function up by name (case-insensitive; `_xlfn.`- and `_xlws.`-free names). A +/// `_xludf.` name is user-defined and never found. pub fn lookup(name: &str) -> Option<&'static FnSpec> { let mut n = name.trim().to_ascii_uppercase(); loop { diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 6410918fe..7611dc410 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -642,16 +642,18 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R { const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; -/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; higher forms -/// allow subtracting progressively smaller numerals (max ratio 10, 20, 50, 100, 1000). +/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher +/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating: +/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2 +/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID. fn roman_tokens(form: u32) -> Vec<(String, u32)> { - let max_ratio = [10, 20, 50, 100, 1000].get(form as usize).copied().unwrap_or(10); + let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0)); let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); for (i, (sc, sv)) in NUMERALS.iter().enumerate() { for (lc, lv) in NUMERALS.iter().skip(i + 1) { let ratio = lv / sv; let pow10 = matches!(sc, 'I' | 'X' | 'C'); - let ok = if form == 0 { pow10 && ratio <= 10 } else { ratio <= max_ratio }; + let ok = ratio <= if pow10 { pow10_max } else { five_max }; let value = lv - sv; // Skip pairs that equal a single numeral (VX = V) – they are never shorter. if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { @@ -958,6 +960,9 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R R { fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; num_val(range.iter().filter(|v| c.matches(v)).count() as f64) } @@ -1034,7 +1040,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { /// the evaluator does that resizing (it has the references), so cells line up one to one here. fn if_numbers(a: &[Arg]) -> R> { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; let mut out = Vec::new(); for r in 0..range.rows { @@ -1568,6 +1574,43 @@ mod tests { is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); } + /// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): + /// form 2 subtracts I from C and X from M, form 3 also V from M. + #[test] + fn roman_forms_two_and_three_match_excel() { + const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; + const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; + let mut cases = Vec::new(); + for thousands in 0..4usize { + let m = "M".repeat(thousands); + for (h, hundreds) in HUNDREDS.iter().enumerate() { + if h != 4 && h != 9 { + cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); + } + } + for (o, ones) in ONES.iter().enumerate() { + cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); + } + for (o, ones) in ONES.iter().enumerate().take(5) { + cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); + } + } + assert_eq!(cases.len(), 92); + for (number, form, want) in &cases { + is_text(ev2("ROMAN", *number as f64, *form), want); + } + // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. + is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); + is_text(ev2("ROMAN", 495.0, 3.0), "VD"); + is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); + is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); + is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); + is_text(ev2("ROMAN", 999.0, 4.0), "IM"); + is_text(ev2("ROMAN", 995.0, 4.0), "VM"); + is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); + is_text(ev2("ROMAN", 99.0, 4.0), "IC"); + } + #[test] fn matrices() { let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); @@ -1658,4 +1701,77 @@ mod tests { is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); } + + #[test] + fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value); + // With two or more pairs Excel matches the first 255 characters: the 255-character cell + // (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365. + let over = format!("{ok}y"); + close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); + } + + #[test] + fn criteria_over_255_utf16_units_follow_excel() { + // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these + // strings, B is the row number, and each row uses its own string as the criterion. + let e = "😀"; + let strings = + [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; + let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); + let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. + let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; + for (s, want) in strings.iter().zip(expected) { + let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); + let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); + let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); + let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); + match want { + Some([a, b, c, d]) => { + close(countif, a); + close(sumif, b); + close(countifs, c); + close(sumifs, d); + } + // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which + // matches no cell (for emoji it splits a surrogate pair). + None => { + is_err(countif, CellError::Value); + is_err(sumif, CellError::Value); + is_err(countifs, CellError::Value); + close(sumifs, 0.0); + } + } + } + } + + #[test] + fn criteria_cut_inside_a_surrogate_pair() { + let e = "😀"; + let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); + let rows = col(&[1.0, 2.0, 3.0, 4.0]); + let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); + // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. + close(sumifs(format!("={}", e.repeat(128))), 1.0); + // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion + // equals no cell text: "=" matches nothing and "<>" everything, blanks included. + close(sumifs(format!("=a{}", e.repeat(128))), 0.0); + close(sumifs(format!("<>{}", e.repeat(128))), 10.0); + // Comparisons treat it as sorting just above the 126 kept emoji. + close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); + close(sumifs(format!("<={}", e.repeat(128))), 3.0); + } } diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index c166cc39a..7c50abeaa 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -79,10 +79,12 @@ fn mode_sngl(v: &[f64]) -> R { } /// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers -/// count, like [`numbers`]. Scalar arguments are stricter than in SUM: Excel answers `#VALUE!` -/// for direct text (even numeric text such as `"1"`) and for a single-cell reference to text or -/// a blank cell (observed: `MODE(B8,"1",2,3)` and `MODE(B9,2,D7)` with D7 blank are `#VALUE!`). -/// Arguments are processed left to right, so the first error wins. +/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical +/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so +/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference +/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to +/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an +/// older Excel that answered `#VALUE!` for typed text; they are not the reference here. fn mode_numbers(args: &[Arg]) -> R> { let mut out = Vec::new(); for a in args { @@ -98,9 +100,8 @@ fn mode_numbers(args: &[Arg]) -> R> { } Value::Number(n) => out.push(*n), Value::Error(e) => return Err(*e), - Value::Text(_) | Value::Empty => return Err(CellError::Value), - Value::Bool(_) if a.from_ref => {} - v => out.push(v.to_number()?), + Value::Text(_) | Value::Bool(_) => {} + Value::Empty => return Err(CellError::Value), } } Ok(out) @@ -2279,9 +2280,11 @@ mod tests { assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); } - /// MODE argument semantics observed in Excel (FormulaEvalTestData, row "Mode"): multi-cell - /// ranges skip text and blanks, but direct text and single-cell references to text or blank - /// cells are `#VALUE!`; the first error from the left wins. + /// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): + /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is + /// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an + /// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and + /// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. #[test] fn mode_scalar_arguments() { let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); @@ -2290,9 +2293,9 @@ mod tests { (vec![rf(data)], Ok(1.0)), (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), // MODE(C9,E16,"1",B8): E16 holds the text "1.1". - (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::Value)), - (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::Value)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Value)), + (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), + (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), // MODE(B9,2,D7) with D7 blank. (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), @@ -2305,7 +2308,31 @@ mod tests { } } } - is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), n(1.0)]), CellError::Value); + is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); + } + + /// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). + #[test] + fn mode_skips_typed_text_and_logicals() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), + (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), + (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), + (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); + is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); } #[test] diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index ccb9c0f0d..de6bbc6c5 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -202,6 +202,9 @@ fn basic_tools() { assert!(f.as_array().unwrap().iter().any(|x| x["name"] == "VLOOKUP")); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "SUM(1,2,3)"}))["result"], json!(6.0)); ok(&mut s, "set_cell", json!({"cell": "A1", "input": "5"})); + // #697: a number for `input` is entered, not rejected or cleared. + ok(&mut s, "set_cell", json!({"cell": "B1", "input": 42.5})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B1"}))["value"], json!(42.5)); ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "A2", "input": "=A1*2"}})); assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0)); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=A1+A2", "cell": "C1"}))["result"], json!(15.0)); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 71fbdf4aa..e6322f016 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -180,7 +180,7 @@ pub fn tool_definitions() -> Vec { "Enter one cell exactly as if typed: a number, text, or a formula starting with = (\"=AVERAGE(B2:B9)\"). \ Dependent cells recalculate. Returns the command result; use get_cell to read the computed value.", obj( - json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": string("Typed input: number, text or =formula")}), + json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": {"type": ["string", "number", "boolean"], "description": "Typed input: number, text or =formula"}}), &["cell", "input"], ), false, diff --git a/crates/model/src/cellmap.rs b/crates/model/src/cellmap.rs index 0f2c685a7..dbbe202f5 100644 --- a/crates/model/src/cellmap.rs +++ b/crates/model/src/cellmap.rs @@ -43,7 +43,7 @@ mod tests { dates: vec![], }, ); - s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) }); + 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"); assert_eq!(back.comments, s.comments); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index e04db25cd..7e8e08bf7 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -304,9 +304,14 @@ pub struct Comment { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Hyperlink { - /// URL, `mailto:`, or an in-workbook location like `Sheet2!A1`. + /// URL, `mailto:`, a file path (`Book.xlsx`, `Book.xlsx#Sheet1!A2`), or an in-workbook location + /// like `Sheet2!A1`. pub target: String, pub tooltip: Option, + /// The target is outside this workbook (Excel's "Existing File or Web Page"): a relationship in + /// the file, not a `location`. A bare file name can't be told from a defined name by its text. + #[serde(default)] + pub external: bool, } // ---------------------------------------------------------------- drawing objects @@ -975,6 +980,12 @@ pub struct PivotTable { pub last_range: Option, /// Calculated fields, usable in the Values area by name. pub calculated_fields: Vec, + /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather + /// than Columns (Excel's "values on rows"). + pub values_on_rows: bool, + /// Position of Σ Values among the row fields (`None` = after the last). Only used with + /// `values_on_rows`: in Columns Σ Values always comes after the last column field. + pub values_position: Option, } /// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in @@ -1007,6 +1018,8 @@ impl Default for PivotTable { show_headers: true, last_range: None, calculated_fields: vec![], + values_on_rows: false, + values_position: None, } } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 1bea84218..fa64aad37 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -19,7 +19,7 @@ pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility}; +pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, diff --git a/crates/model/src/preserved.rs b/crates/model/src/preserved.rs index 6d0b35d5c..eb8e2bb91 100644 --- a/crates/model/src/preserved.rs +++ b/crates/model/src/preserved.rs @@ -33,10 +33,11 @@ pub struct PreservedPart { } /// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its -/// relationship ids rewritten. +/// relationship ids rewritten, or that Excel needs back unchanged (the workbook's `fileVersion`). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PreservedXml { - /// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`). + /// Which slot of the owner it belongs in (`externalReferences`, `fileVersion`, `ext`, + /// `oleObjects`, `controls`). pub slot: String, /// The element, namespace declarations it needs included. pub xml: String, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index e05d3bfb5..b404b891c 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -117,6 +117,12 @@ pub struct Sheet { pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; +/// Column width (points) → characters of the default font's digit width (7 px), without the +/// cell padding: the value the Column Width dialog shows. +pub fn points_to_chars(px: f64) -> f64 { + if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } +} + impl Sheet { pub fn new(name: impl Into) -> Sheet { Sheet { diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 126727eda..d3976876e 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -377,6 +377,12 @@ impl Default for StyleTable { } impl StyleTable { + /// A table whose default (Normal) style is `normal`, such as a file's own Normal font. + pub fn with_default(normal: Style) -> Self { + let mut index = HashMap::new(); + index.insert(normal.clone(), StyleId::DEFAULT); + StyleTable { styles: vec![normal], index } + } pub fn get(&self, id: StyleId) -> &Style { self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE) } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e84446..e5c65bdb6 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -101,6 +101,8 @@ impl Default for NumberFormat { impl NumberFormat { /// Parses a format code. Never fails: unknown or malformed parts degrade to literal text. pub fn parse(code: &str) -> NumberFormat { + #[cfg(test)] + parse_cache::count_parse(); let trimmed = code.trim(); // `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other // value displays as General. @@ -261,7 +263,8 @@ impl NumberFormat { let Some(mut out) = out else { return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true); }; - if minus && sec.kind != SecKind::Text { + // Excel drops the minus sign when the first section shows a negative number as all zeros. + if minus && sec.kind != SecKind::Text && !out.zero { out.prepend("-"); } (Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false) @@ -294,6 +297,78 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted } } +/// A small per-thread cache of parsed format codes for callers that format many values with the +/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only +/// (the date system and values are applied when formatting), so the code is the whole key. +mod parse_cache { + use std::cell::RefCell; + use std::collections::HashMap; + + use super::NumberFormat; + + /// Most codes kept per thread. A full cache is emptied, so memory stays bounded. + pub(super) const CAPACITY: usize = 64; + /// Longer codes are parsed every time and never stored. + pub(super) const MAX_CODE_LEN: usize = 256; + + thread_local! { + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + + pub(super) fn get(code: &str) -> NumberFormat { + if code.len() > MAX_CODE_LEN { + return NumberFormat::parse(code); + } + // Not usable (thread shutting down, re-entered): parse without the cache. + let cached = CACHE + .try_with(|c| { + let mut c = c.try_borrow_mut().ok()?; + if let Some(f) = c.get(code) { + return Some(f.clone()); + } + let f = NumberFormat::parse(code); + if c.len() >= CAPACITY { + c.clear(); + } + c.insert(code.to_string(), f.clone()); + Some(f) + }) + .ok() + .flatten(); + cached.unwrap_or_else(|| NumberFormat::parse(code)) + } + + #[cfg(test)] + thread_local! { + static PARSES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[cfg(test)] + pub(super) fn count_parse() { + let _ = PARSES.try_with(|p| p.set(p.get() + 1)); + } + + /// Parses done on this thread so far (tests only). + #[cfg(test)] + pub(super) fn parses() -> usize { + PARSES.try_with(std::cell::Cell::get).unwrap_or(0) + } + + /// Empties this thread's cache (tests only). + #[cfg(test)] + pub(super) fn clear() { + let _ = CACHE.try_with(|c| c.borrow_mut().clear()); + } +} + +impl NumberFormat { + /// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so + /// formatting many values with one code parses it once. Same result as `parse`. + pub fn parse_cached(code: &str) -> NumberFormat { + parse_cache::get(code) + } +} + /// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is /// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text /// section, or are returned as is. @@ -301,7 +376,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result return Err(e), Value::Empty => 0.0, diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index e54f04c9f..6df22ad79 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -513,6 +513,7 @@ fn analyze(raw: Raw) -> Section { let mut seen_exp = false; let mut seen_digit = false; let mut fraction_slash = false; + let scientific = toks.iter().skip_while(|t| !matches!(t, Tok::Digit(_))).any(|t| matches!(t, Tok::Exp { .. })); for i in 0..toks.len() { let Some(t) = toks.get(i).cloned() else { break }; let replace = match t { @@ -547,6 +548,10 @@ fn analyze(raw: Raw) -> Section { if seen_digit && next_digit && !seen_point && !seen_exp { sec.thousands = true; Some(Tok::Lit(String::new())) + } else if seen_digit && scientific { + // Observed: a scientific section never scales; any other comma after the + // first digit shows nothing (`#,e+#`, `#e+#,`, `#e+000,00`). + Some(Tok::Lit(String::new())) } else if seen_digit && !next_digit { sec.scale += 1; Some(Tok::Lit(String::new())) diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index f02d84bc5..64a4fca93 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -11,6 +11,8 @@ use crate::parse::{DatePart, Section, Tok}; pub(crate) struct Out { pub text: String, pub fill: Option<(char, usize)>, + /// True when every digit shown is zero (a number section that rounded to zero). + pub zero: bool, } impl Out { @@ -231,7 +233,9 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range) -> Vec { /// Renders a non-negative number through a number section. pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut x = v.abs(); - if sec.percent > 0 { + // Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as + // `1%e+5`). + if sec.percent > 0 && !sec.exp { x *= 100f64.powi(sec.percent.min(20)); } if sec.scale > 0 { @@ -247,6 +251,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut pieces: Vec = vec![String::new(); n]; let mut before: Vec = vec![String::new(); n + 1]; let mut lits: Vec> = Vec::new(); + let mut zero = false; if sec.fraction { lits = vec![None; n]; render_fraction(sec, x, &mut pieces, &mut lits); @@ -264,7 +269,9 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let exp_idx = digit_indices(toks, e_at + 1..n); let step = int_idx.len().max(1) as i32; let (mut e, int_s, frac_s) = if x == 0.0 { - (0i32, String::new(), "0".repeat(frac_idx.len())) + // Observed: zero shows `0` in every integer placeholder, `#` and `?` too + // (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`). + (0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len())) } else { let (digits, xe) = decimal_digits(x); let mut e = xe - xe.rem_euclid(step); @@ -283,26 +290,30 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { if int_s.is_empty() && frac_s.chars().all(|c| c == '0') { e = 0; } - let mut head = String::from(if upper { "E" } else { "e" }); - if e < 0 { - head.push('-'); + let head = if upper { "E" } else { "e" }; + let sign = if e < 0 { + "-" } else if plus { - head.push('+'); - } - let width = exp_idx.iter().filter(|&&i| matches!(toks.get(i), Some(Tok::Digit('0')))).count().min(20); - let digits = format!("{:0w$}", e.unsigned_abs(), w = width); + "+" + } else { + "" + }; + let digits = e.unsigned_abs().to_string(); + // The sign sits right before the exponent digits, after any literal that follows the + // `E` (observed: `|0|e+|0|` shows `|1|e|+5|`); `?` pads the digits with spaces. match exp_idx.first() { Some(&first) => { if let Some(p) = pieces.get_mut(e_at) { - *p = head; + *p = head.to_string(); } - if let Some(p) = pieces.get_mut(first) { - *p = digits; + if let Some(b) = before.get_mut(first) { + *b = sign.to_string(); } + fill_int(toks, &exp_idx, &digits, false, false, &mut pieces); } None => { if let Some(p) = pieces.get_mut(e_at) { - *p = head + &digits; + *p = format!("{head}{sign}{digits}"); } } } @@ -310,6 +321,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { } else { fixed(x, frac_idx.len()) }; + zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0'); fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces); if int_idx.is_empty() && !int_s.is_empty() @@ -338,6 +350,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { _ => out.push(pieces.get(i).map(String::as_str).unwrap_or("")), } } + out.zero = zero; out } diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index f777c6a05..c335ed849 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -24,7 +24,7 @@ fn numbers() { assert_eq!(f("0", -2.5), "-3"); assert_eq!(f("0.00", 2.675), "2.68"); assert_eq!(f("0.00", 1.005), "1.01"); - assert_eq!(f("0.0", -0.04), "-0.0"); + assert_eq!(f("0.0", -0.04), "0.0"); assert_eq!(f("00000", 123.0), "00123"); assert_eq!(f("000-00-0000", 123456789.0), "123-45-6789"); assert_eq!(f("#.##", 0.5), ".5"); @@ -105,6 +105,29 @@ fn scientific() { assert_eq!(f("0.00E+00", -12345.0), "-1.23E+04"); } +/// A negative number that rounds to zero in the first section shows no minus sign; a negative +/// section of its own keeps its text (observed Excel results). +#[test] +fn negative_rounding_to_zero_drops_the_sign() { + let cases: &[(&str, f64, &str)] = &[ + ("0.00", -0.0033, "0.00"), + ("0.00", -2.89e-9, "0.00"), + ("0.00", -0.005, "-0.01"), + ("0", -0.4, "0"), + ("0", -0.5, "-1"), + ("0.0", -0.004, "0.0"), + ("#,##0.00", -0.004, "0.00"), + ("0.00%", -2.89e-9, "0.00%"), + ("0.00%", -0.0033, "-0.33%"), + ("0.00;(0.00)", -0.0033, "(0.00)"), + ("0.00;-0.00;\"zero\"", -0.0033, "-0.00"), + ("0.00E+00", -2.89e-9, "-2.89E-09"), + ]; + for &(code, n, want) in cases { + assert_eq!(f(code, n), want, "{code} with {n}"); + } +} + #[test] fn fractions() { assert_eq!(f("# ?/?", 1.5), "1 1/2"); @@ -347,6 +370,8 @@ fn text_fn() { assert_eq!(text_function(&Value::Number(2957003.0), "m/d/yyyy", DateSystem::D1904), Ok("12/31/9999".into())); assert_eq!(text_function(&Value::Number(2957004.0), "m/d/yyyy", DateSystem::D1904), Err(CellError::Value)); assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into())); + assert_eq!(text_function(&Value::Number(-0.0033), "0", S), Ok("0".into())); + assert_eq!(text_function(&Value::Number(-0.4), "0", S), Ok("0".into())); // Booleans go through the text section as TRUE/FALSE (observed Excel results). let cases: &[(bool, &str, &str)] = &[ (true, ";;;@", "TRUE"), @@ -589,3 +614,174 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { let (d, e) = decimal_digits(999999999999999.6); assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15)); } + +// ---- parse cache ---- + +/// `TEXT()` as it was before the cache: parse the code, then format. +fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result { + if code.is_empty() { + return Ok(String::new()); + } + let fmt = NumberFormat::parse(code); + let n = match v.scalar() { + Value::Error(e) => return Err(e), + Value::Empty => 0.0, + Value::Number(n) => n, + Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text), + Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) { + Some(n) => n, + None => return Ok(fmt.format_text(&t).text), + }, + Value::Array(_) => return Err(CellError::Value), + }; + if !n.is_finite() { + return Err(CellError::Num); + } + let (f, overflow) = fmt.format_number(n, sys); + if overflow { Err(CellError::Value) } else { Ok(f.text) } +} + +const CACHE_CODES: &[&str] = &[ + "0", + "0.00", + "#,##0.00", + "#,##0.00;[Red](#,##0.00)", + "$#,##0.00", + "0%", + "0.0%", + "0.00E+00", + "##0.0E+0", + "General", + "general", + "checkbox", + "@", + "0;@", + "0;-0;;@", + "00000", + "# ?/?", + "# ??/16", + "?/?", + "m/d/yyyy", + "yyyy-mm-dd hh:mm:ss", + "dddd, mmmm d, yyyy", + "h:mm AM/PM", + "[h]:mm:ss", + "mm:ss.000", + "[Red]0;[Blue]-0", + "[>100]0.0;[<5]0.000;0", + "[<=9999999]###-####;(###) ###-####", + "[$-409]d mmm yy", + "\"abc\"0", + "\\x0", + "*-0", + "_(* #,##0_)", + "0.0.0", + "[", + "\"abc", + "0;0;0;0;0", + "#,##0.00;[Red", + "[$-]0", + " 0.00 ", + "[Color99]0", + "0.00_);(0.00)", + "#,,\"M\"", + "yyyy", + "", +]; + +fn cache_values() -> Vec { + vec![ + Value::Number(0.0), + Value::Number(1234.5678), + Value::Number(-42.5), + Value::Number(0.5), + Value::Number(45000.75), + Value::Number(1e15), + Value::Number(1e-7), + Value::Number(-0.0001), + Value::Number(f64::INFINITY), + Value::from("abc"), + Value::from("12.5"), + Value::Bool(true), + Value::Empty, + Value::Error(CellError::NA), + ] +} + +#[test] +fn parse_cached_gives_the_same_results_cold_and_warm() { + assert!(CACHE_CODES.len() >= 40); + parse_cache::clear(); + let values = cache_values(); + for sys in [DateSystem::D1900, DateSystem::D1904] { + // Cold on the first system, warm on the second. + for code in CACHE_CODES { + let direct = NumberFormat::parse(code); + let cached = NumberFormat::parse_cached(code); + assert_eq!(cached.code(), direct.code(), "{code:?}"); + assert_eq!( + (cached.kind(), cached.is_date(), cached.is_text(), cached.is_general()), + (direct.kind(), direct.is_date(), direct.is_text(), direct.is_general()) + ); + for v in &values { + let want = text_uncached(v, code, sys); + assert_eq!(text_function(v, code, sys), want, "TEXT({v:?}, {code:?})"); + // Again, now certainly warm. + assert_eq!(text_function(v, code, sys), want, "warm TEXT({v:?}, {code:?})"); + assert_eq!(format_value(v, &cached, sys), format_value(v, &direct, sys), "{v:?} {code:?}"); + } + } + } +} + +#[test] +fn parse_cached_parses_each_code_once() { + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + assert!(text_function(&Value::Number(f64::from(i) + 0.5), "0.00", S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 1); + + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + // "0.00" is still cached from above: only the second code is new. + assert_eq!(parse_cache::parses() - before, 1); + + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 2); +} + +#[test] +fn parse_cache_is_bounded() { + parse_cache::clear(); + // More distinct codes than the capacity: all still format correctly... + let n = parse_cache::CAPACITY + 10; + for i in 0..n { + let c = format!("0.00\"{i}\""); + assert_eq!(text_function(&Value::Number(1.0), &c, S), Ok(format!("1.00{i}"))); + } + // ...the first ones were evicted (parsed again), the newest is still cached. + let before = parse_cache::parses(); + let newest = format!("0.00\"{}\"", n - 1); + assert!(text_function(&Value::Number(1.0), &newest, S).is_ok()); + assert_eq!(parse_cache::parses() - before, 0); + assert!(text_function(&Value::Number(1.0), "0.00\"0\"", S).is_ok()); + assert_eq!(parse_cache::parses() - before, 1); + + // A very long code is parsed on every call and not stored. + let long = format!("0\"{}\"", "x".repeat(parse_cache::MAX_CODE_LEN)); + let before = parse_cache::parses(); + for _ in 0..3 { + assert!(text_function(&Value::Number(1.0), &long, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 3); +} diff --git a/crates/numfmt/tests/poi_number_format_approx.rs b/crates/numfmt/tests/poi_number_format_approx.rs new file mode 100644 index 000000000..0f9932101 --- /dev/null +++ b/crates/numfmt/tests/poi_number_format_approx.rs @@ -0,0 +1,272 @@ +//! `TEXT(value, format)` against the results Excel cached in Apache POI's +//! `test-data/spreadsheet/NumberFormatApproxTests.xlsx` (sheet `Tests`: B = format, C = value, +//! A = `=TEXT(C, B)`). The workbook is not committed; its rows are copied here as data. + +use gridcraft_core::{DateSystem, Value}; +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|"), + (r"|#%e-#|", 123456.789, r"|1%e5|"), + (r"|#e-#%|", 123456.789, r"|1e5%|"), + (r"|#|e-|#|", 123456.789, r"|1|e|5|"), + (r"|#.#|e-|#|", 123456.789, r"|1.2|e|5|"), + (r"|####.####|e-|#|", 123456.789, r"|12.3457|e|4|"), + (r"|#######.####|e-|#|", 123456.789, r"|123456.789|e|0|"), + (r"|#,######.####|e-|#|", 123456.789, r"|123,456.789|e|0|"), + (r"|0|e-|0|", 123456.789, r"|1|e|5|"), + (r"|0.0|e-|0|", 123456.789, r"|1.2|e|5|"), + (r"|0000.0000|e-|0|", 123456.789, r"|0012.3457|e|4|"), + (r"|0000000.0000|e-|0|", 123456.789, r"|0123456.7890|e|0|"), + (r"|0,000000.0000|e-|0|", 123456.789, r"|0,123,456.7890|e|0|"), + (r"|?|e-|?|", 123456.789, r"|1|e|5|"), + (r"|?.?|e-|?|", 123456.789, r"|1.2|e|5|"), + (r"|????.????|e-|?|", 123456.789, r"| 12.3457|e|4|"), + (r"|???????.????|e-|?|", 123456.789, r"| 123456.789 |e|0|"), + (r"|?,??????.????|e-|?|", 123456.789, r"| 123,456.789 |e|0|"), + (r"|#,e+#|", 123456.789, r"|1e+5|"), + (r"|#e+#,|", 123456.789, r"|1e+5|"), + (r"|#%e+#|", 123456.789, r"|1%e+5|"), + (r"|#e+#%|", 123456.789, r"|1e+5%|"), + (r"|#|e+|#|", 123456.789, r"|1|e|+5|"), + (r"|#.#|e+|#|", 123456.789, r"|1.2|e|+5|"), + (r"|####.####|e+|#|", 123456.789, r"|12.3457|e|+4|"), + (r"|#######.####|e+|#|", 123456.789, r"|123456.789|e|+0|"), + (r"|#,######.####|e+|#|", 123456.789, r"|123,456.789|e|+0|"), + (r"|0|e+|0|", 123456.789, r"|1|e|+5|"), + (r"|0.0|e+|0|", 123456.789, r"|1.2|e|+5|"), + (r"|0000.0000|e+|0|", 123456.789, r"|0012.3457|e|+4|"), + (r"|0000000.0000|e+|0|", 123456.789, r"|0123456.7890|e|+0|"), + (r"|0,000000.0000|e+|0|", 123456.789, r"|0,123,456.7890|e|+0|"), + (r"|?|e+|?|", 123456.789, r"|1|e|+5|"), + (r"|?.?|e+|?|", 123456.789, r"|1.2|e|+5|"), + (r"|????.????|e+|?|", 123456.789, r"| 12.3457|e|+4|"), + (r"|???????.????|e+|?|", 123456.789, r"| 123456.789 |e|+0|"), + (r"|?,??????.????|e+|?|", 123456.789, r"| 123,456.789 |e|+0|"), + (r"|#.#|E-|#|", 123456.789, r"|1.2|E|5|"), + (r"|#.#|E+|#|", 123456.789, r"|1.2|E|+5|"), + (r"|#,e-#|", 1.23456789e-05, r"|1e-5|"), + (r"|#e-#,|", 1.23456789e-05, r"|1e-5|"), + (r"|#%e-#|", 1.23456789e-05, r"|1%e-5|"), + (r"|#e-#%|", 1.23456789e-05, r"|1e-5%|"), + (r"|#|e-|#|", 1.23456789e-05, r"|1|e|-5|"), + (r"|#.#|e-|#|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|####.####|e-|#|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|#######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|#,######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|0|e-|0|", 1.23456789e-05, r"|1|e|-5|"), + (r"|0.0|e-|0|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|0000.0000|e-|0|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|0000000.0000|e-|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"), + (r"|0,000000.0000|e-|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"), + (r"|?|e-|?|", 1.23456789e-05, r"|1|e|-5|"), + (r"|?.?|e-|?|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|????.????|e-|?|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|???????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|?,??????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|#,e+#|", 1.23456789e-05, r"|1e-5|"), + (r"|#e+#,|", 1.23456789e-05, r"|1e-5|"), + (r"|#%e+#|", 1.23456789e-05, r"|1%e-5|"), + (r"|#e+#%|", 1.23456789e-05, r"|1e-5%|"), + (r"|#|e+|#|", 1.23456789e-05, r"|1|e|-5|"), + (r"|#.#|e+|#|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|#.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|##.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|###.####|e+|#|", 0.000123456789, r"|123.4568|e|-6|"), + (r"|####.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|#####.####|e+|#|", 0.000123456789, r"|12.3457|e|-5|"), + (r"|#######.####|e+|#|", 0.000123456789, r"|1234.5679|e|-7|"), + (r"|#.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"), + (r"|##.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"), + (r"|###.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"), + (r"|####.####|e+|#|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|#####.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"), + (r"|#######.####|e+|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|#.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|##.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|###.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|####.####|e+|#|", 1.23456789e-06, r"|123.4568|e|-8|"), + (r"|#####.####|e+|#|", 1.23456789e-06, r"|12345.6789|e|-10|"), + (r"|#######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"), + (r"|#,######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"), + (r"|0|e+|0|", 1.23456789e-05, r"|1|e|-5|"), + (r"|0.0|e+|0|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|0000.0000|e+|0|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|0000000.0000|e+|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"), + (r"|0,000000.0000|e+|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"), + (r"|?|e+|?|", 1.23456789e-05, r"|1|e|-5|"), + (r"|?.?|e+|?|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|????.????|e+|?|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|???????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|?,??????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|#.#|E-|#|", 1.23456789e-05, r"|1.2|E|-5|"), + (r"|#.#|E+|#|", 1.23456789e-05, r"|1.2|E|-5|"), + (r"|#,e-#|", 0.0, r"|0e0|"), + (r"|#e-#,|", 0.0, r"|0e0|"), + (r"|#%e-#|", 0.0, r"|0%e0|"), + (r"|#e-#%|", 0.0, r"|0e0%|"), + (r"|#|e-|#|", 0.0, r"|0|e|0|"), + (r"|#.#|e-|#|", 0.0, r"|0.|e|0|"), + (r"|####.####|e-|#|", 0.0, r"|0000.|e|0|"), + (r"|#######.####|e-|#|", 0.0, r"|0000000.|e|0|"), + (r"|#,######.####|e-|#|", 0.0, r"|0,000,000.|e|0|"), + (r"|0|e-|0|", 0.0, r"|0|e|0|"), + (r"|0.0|e-|0|", 0.0, r"|0.0|e|0|"), + (r"|0000.0000|e-|0|", 0.0, r"|0000.0000|e|0|"), + (r"|0000000.0000|e-|0|", 0.0, r"|0000000.0000|e|0|"), + (r"|0,000000.0000|e-|0|", 0.0, r"|0,000,000.0000|e|0|"), + (r"|?|e-|?|", 0.0, r"|0|e|0|"), + (r"|?.?|e-|?|", 0.0, r"|0. |e|0|"), + (r"|????.????|e-|?|", 0.0, r"|0000. |e|0|"), + (r"|???????.????|e-|?|", 0.0, r"|0000000. |e|0|"), + (r"|?,??????.????|e-|?|", 0.0, r"|0,000,000. |e|0|"), + (r"|#,e+#|", 0.0, r"|0e+0|"), + (r"|#e+#,|", 0.0, r"|0e+0|"), + (r"|#%e+#|", 0.0, r"|0%e+0|"), + (r"|#e+#%|", 0.0, r"|0e+0%|"), + (r"|#|e+|#|", 0.0, r"|0|e|+0|"), + (r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"), + (r"|####.####|e+|#|", 0.0, r"|0000.|e|+0|"), + (r"|#######.####|e+|#|", 0.0, r"|0000000.|e|+0|"), + (r"|#,######.####|e+|#|", 0.0, r"|0,000,000.|e|+0|"), + (r"|0|e+|0|", 0.0, r"|0|e|+0|"), + (r"|0.0|e+|0|", 0.0, r"|0.0|e|+0|"), + (r"|0000.0000|e+|0|", 0.0, r"|0000.0000|e|+0|"), + (r"|0000000.0000|e+|0|", 0.0, r"|0000000.0000|e|+0|"), + (r"|0,000000.0000|e+|0|", 0.0, r"|0,000,000.0000|e|+0|"), + (r"|?|e+|?|", 0.0, r"|0|e|+0|"), + (r"|?.?|e+|?|", 0.0, r"|0. |e|+0|"), + (r"|????.????|e+|?|", 0.0, r"|0000. |e|+0|"), + (r"|???????.????|e+|?|", 0.0, r"|0000000. |e|+0|"), + (r"|?,??????.????|e+|?|", 0.0, r"|0,000,000. |e|+0|"), + (r"|#.#|E-|#|", 0.0, r"|0.|E|0|"), + (r"|#.#|E+|#|", 0.0, r"|0.|E|+0|"), + (r"|#,e-#|", -123456.789, r"-|1e5|"), + (r"|#e-#,|", -123456.789, r"-|1e5|"), + (r"|#%e-#|", -123456.789, r"-|1%e5|"), + (r"|#e-#%|", -123456.789, r"-|1e5%|"), + (r"|#|e-|#|", -123456.789, r"-|1|e|5|"), + (r"|#.#|e-|#|", -123456.789, r"-|1.2|e|5|"), + (r"|####.####|e-|#|", -123456.789, r"-|12.3457|e|4|"), + (r"|#######.####|e-|#|", -123456.789, r"-|123456.789|e|0|"), + (r"|#,######.####|e-|#|", -123456.789, r"-|123,456.789|e|0|"), + (r"|0|e-|0|", -123456.789, r"-|1|e|5|"), + (r"|0.0|e-|0|", -123456.789, r"-|1.2|e|5|"), + (r"|0000.0000|e-|0|", -123456.789, r"-|0012.3457|e|4|"), + (r"|0000000.0000|e-|0|", -123456.789, r"-|0123456.7890|e|0|"), + (r"|0,000000.0000|e-|0|", -123456.789, r"-|0,123,456.7890|e|0|"), + (r"|?|e-|?|", -123456.789, r"-|1|e|5|"), + (r"|?.?|e-|?|", -123456.789, r"-|1.2|e|5|"), + (r"|????.????|e-|?|", -123456.789, r"-| 12.3457|e|4|"), + (r"|???????.????|e-|?|", -123456.789, r"-| 123456.789 |e|0|"), + (r"|?,??????.????|e-|?|", -123456.789, r"-| 123,456.789 |e|0|"), + (r"|#,e+#|", -123456.789, r"-|1e+5|"), + (r"|#e+#,|", -123456.789, r"-|1e+5|"), + (r"|#%e+#|", -123456.789, r"-|1%e+5|"), + (r"|#e+#%|", -123456.789, r"-|1e+5%|"), + (r"|#|e+|#|", -123456.789, r"-|1|e|+5|"), + (r"|#.#|e+|#|", -123456.789, r"-|1.2|e|+5|"), + (r"|####.####|e+|#|", -123456.789, r"-|12.3457|e|+4|"), + (r"|#######.####|e+|#|", -123456.789, r"-|123456.789|e|+0|"), + (r"|#,######.####|e+|#|", -123456.789, r"-|123,456.789|e|+0|"), + (r"|0|e+|0|", -123456.789, r"-|1|e|+5|"), + (r"|0.0|e+|0|", -123456.789, r"-|1.2|e|+5|"), + (r"|0000.0000|e+|0|", -123456.789, r"-|0012.3457|e|+4|"), + (r"|0000000.0000|e+|0|", -123456.789, r"-|0123456.7890|e|+0|"), + (r"|0,000000.0000|e+|0|", -123456.789, r"-|0,123,456.7890|e|+0|"), + (r"|?|e+|?|", -123456.789, r"-|1|e|+5|"), + (r"|?.?|e+|?|", -123456.789, r"-|1.2|e|+5|"), + (r"|????.????|e+|?|", -123456.789, r"-| 12.3457|e|+4|"), + (r"|???????.????|e+|?|", -123456.789, r"-| 123456.789 |e|+0|"), + (r"|?,??????.????|e+|?|", -123456.789, r"-| 123,456.789 |e|+0|"), + (r"|#.#|E-|#|", -123456.789, r"-|1.2|E|5|"), + (r"|#.#|E+|#|", -123456.789, r"-|1.2|E|+5|"), + (r"|#,e-#|", -1.23456789e-05, r"-|1e-5|"), + (r"|#e-#,|", -1.23456789e-05, r"-|1e-5|"), + (r"|#%e-#|", -1.23456789e-05, r"-|1%e-5|"), + (r"|#e-#%|", -1.23456789e-05, r"-|1e-5%|"), + (r"|#|e-|#|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|#.#|e-|#|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|####.####|e-|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|#######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|#,######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|0|e-|0|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|0.0|e-|0|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|0000.0000|e-|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|0000000.0000|e-|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"), + (r"|0,000000.0000|e-|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"), + (r"|?|e-|?|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|?.?|e-|?|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|????.????|e-|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|???????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|?,??????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|#,e+#|", -1.23456789e-05, r"-|1e-5|"), + (r"|#e+#,|", -1.23456789e-05, r"-|1e-5|"), + (r"|#%e+#|", -1.23456789e-05, r"-|1%e-5|"), + (r"|#e+#%|", -1.23456789e-05, r"-|1e-5%|"), + (r"|#|e+|#|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|#.#|e+|#|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|####.####|e+|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|#######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|#,######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|0|e+|0|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|0.0|e+|0|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|0000.0000|e+|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|0000000.0000|e+|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"), + (r"|0,000000.0000|e+|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"), + (r"|?|e+|?|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|?.?|e+|?|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|????.????|e+|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|???????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|?,??????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|#.#|E-|#|", -1.23456789e-05, r"-|1.2|E|-5|"), + (r"|#.#|E+|#|", -1.23456789e-05, r"-|1.2|E|-5|"), + (r"|#.#|e+|#|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|#####|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|0|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|000|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|0000|", 1.2345678e+142, r"|1.2|e|+0142|"), + (r"|#.#|e+|00000|", 1.2345678e+142, r"|1.2|e|+00142|"), + (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"|#.#|e+|?????|", 1.2345678e+142, r"|1.2|e|+ 142|"), + (r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|#####|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|0|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|000|", 0.0, r"|0.|e|+000|"), + (r"|#.#|e+|0000|", 0.0, r"|0.|e|+0000|"), + (r"|#.#|e+|00000|", 0.0, r"|0.|e|+00000|"), + (r"|#.#|e+|?|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|???|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|????|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|?????|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|#|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|#####|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|0|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|000|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|0000|", 1.2345678e-142, r"|1.2|e|-0142|"), + (r"|#.#|e+|00000|", 1.2345678e-142, r"|1.2|e|-00142|"), + (r"|#.#|e+|000,00|", 1.2345678e-142, r"|1.2|e|-00142|"), + (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"|#.#|e+|?????|", 1.2345678e-142, r"|1.2|e|- 142|"), + (r"#,##0.00e+00", 3.14159, r"3.14e+00"), +]; + +#[test] +fn poi_number_format_approx_tests() { + let wrong: Vec = CASES + .iter() + .filter_map(|&(code, v, want)| { + let got = text_function(&Value::Number(v), code, DateSystem::D1900); + (got.as_deref() != Ok(want)).then(|| format!("TEXT({v}, {code:?}) = {got:?}, Excel {want:?}")) + }) + .collect(); + assert!(wrong.is_empty(), "{} of {} cases differ:\n{}", wrong.len(), CASES.len(), wrong.join("\n")); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df115..b55f247cf 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -145,7 +145,8 @@ impl Font { /// Width of `text` in points when set in `font` at `size` points. pub fn text_width(font: Font, size: f32, text: &str) -> f32 { - let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum(); + // u64: a u32 sum overflows for ~8 million characters. + let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum(); units as f32 * size / 1000.0 } @@ -221,6 +222,32 @@ fn n(v: f32) -> String { } } +/// True when every value is finite (NaN and infinities are rejected). +fn finite(v: &[f32]) -> bool { + v.iter().all(|x| x.is_finite()) +} + +/// A positive, finite extent (a width, height or radius). +fn extent(v: f32) -> bool { + v > 0.0 && v.is_finite() +} + +/// A stroke width: negative, NaN and infinite become 0 (the thinnest line). +fn stroke_width(w: f32) -> f32 { + if w.is_finite() { w.max(0.0) } else { 0.0 } +} + +/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification +/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is +/// replaced by an empty array, i.e. a solid line. +fn dash_values(dash: &[f32]) -> String { + let d: Vec = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect(); + if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) { + return String::new(); + } + d.iter().map(|v| n(*v)).collect::>().join(" ") +} + fn rgb_op(c: Rgb, stroke: bool) -> String { let f = |v: u8| n(v as f32 / 255.0); format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" }) @@ -288,7 +315,7 @@ impl Page { } pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) { - if w <= 0.0 || h <= 0.0 { + if !(extent(w) && extent(h) && finite(&[x, y])) { return; } let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h)); @@ -296,7 +323,10 @@ impl Page { } pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) { - let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h)); + if !finite(&[x, y, w, h]) { + return; + } + let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h)); self.op(&s); } @@ -306,19 +336,28 @@ impl Page { /// 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]) { - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let s = - format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1))); + if !finite(&[x0, y0, x1, y1]) { + return; + } + let s = format!( + "{}\n{} w [{}] 0 d\n{} {} m {} {} l S", + rgb_op(color, true), + n(stroke_width(width)), + dash_values(dash), + n(x0), + n(self.y(y0)), + n(x1), + n(self.y(y1)) + ); self.op(&s); } /// An open polyline. pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) { - if pts.len() < 2 { + if pts.len() < 2 || !pts.iter().all(|p| finite(p)) { return; } - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" ")); + let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash)); self.path(&mut s, pts, false); s.push_str(" S"); self.op(&s); @@ -335,7 +374,7 @@ impl Page { /// A closed polygon, filled and/or stroked. pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option, stroke: Option<(Rgb, f32)>) { - if pts.len() < 2 || (fill.is_none() && stroke.is_none()) { + if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) { return; } let mut s = String::new(); @@ -344,7 +383,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w))); } self.path(&mut s, pts, true); s.push_str(match (fill.is_some(), stroke.is_some()) { @@ -357,7 +396,7 @@ impl Page { /// A circle approximated by four cubic Béziers. pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option, stroke: Option<(Rgb, f32)>) { - if r <= 0.0 || (fill.is_none() && stroke.is_none()) { + if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) { return; } const K: f32 = 0.552_284_8; @@ -369,7 +408,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w))); } let _ = write!( s, @@ -417,7 +456,7 @@ impl Page { /// 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() || !(size.is_finite() && size > 0.0) { + if text.is_empty() || !extent(size) || !finite(&[x, y]) { return; } let fi = font.index(); @@ -446,7 +485,7 @@ impl Page { /// `(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) { let px = pw as u64 * ph as u64; - if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 { + if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; } let px = px as usize; @@ -516,8 +555,23 @@ fn run_length(data: &[u8]) -> Vec { out } -fn pdf_text_string(s: &str) -> String { - pdf_string(s) +/// Longest Info string written, in characters; longer values are cut on a character boundary. +const MAX_INFO_CHARS: usize = 1024; + +/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII, +/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2). +fn pdf_text_string(text: &str) -> String { + let text: String = text.chars().take(MAX_INFO_CHARS).collect(); + if text.chars().all(|c| matches!(c, ' '..='~')) { + return pdf_string(&text); + } + let mut s = String::with_capacity(text.len() * 4 + 6); + s.push_str("'); + s } impl PdfDoc { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a20..7d6a61a45 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,123 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +fn page_content(d: &PdfDoc) -> &str { + &d.pages[0].content +} + +#[test] +fn text_width_does_not_overflow() { + // 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds. + let long = "a".repeat(8_000_000); + let w = text_width(Font::Helvetica, 10.0, &long); + assert!(w.is_finite() && w > 0.0); + assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000))); +} + +#[test] +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.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]); + let c = page_content(&d); + assert_eq!(c.matches("[] 0 d").count(), 2); + assert!(!c.contains("[0 0]")); + assert!(c.contains("[3 2] 0 d")); +} + +#[test] +fn negative_and_nan_stroke_widths_become_zero() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]); + p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]); + p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0))); + p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY))); + let c = page_content(&d); + assert_eq!(c.matches("\n0 w ").count(), 4); + assert!(!c.contains("-1 w") && !c.contains("-3 w")); +} + +#[test] +fn non_finite_image_size_stores_nothing() { + let mut d = PdfDoc::new(); + 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(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() { + 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(); + let p = d.add_page(300.0, 300.0); + for &a in &bad { + for &b in &bad { + p.clip_rect(a, b, a, b); + 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.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.stroke_line_dashed(0.0, 0.0, 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); +} + +/// 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() +} + +#[test] +fn info_strings_with_non_ascii_are_utf16be() { + 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"); +} + +#[test] +fn ascii_info_strings_stay_literal_and_escaped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = r"Q1 (draft) a\b".into(); + let pdf = doc.finish(); + assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some()); + assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some()); +} + +#[test] +fn info_strings_are_capped() { + 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); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 45397d394..b98a4d5d1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -432,6 +432,7 @@ impl Dialog { Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" }, Text { key: "values", label: "Values range (blank: second column of the selection):" }, Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" }, + Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" }, Check { key: "confidenceInterval", label: "Confidence interval" }, Text { key: "confidence", label: "Confidence level (%):" }, Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" }, @@ -938,7 +939,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "solver" => 460.0, _ => 380.0, }; - let window = egui::Window::new(d.title.clone()) + let window = egui::Window::new(app.ui.language.tr(&d.title)) .id(egui::Id::new("dialog").with(&d.name)) .collapsible(false) .resizable(false) @@ -1270,11 +1271,15 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + let lang = crate::i18n::current(ui.ctx()); + let ok = ui.add( + egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)) + .fill(crate::theme::Tokens::get(ui.ctx()).accent), + ); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } - if ui.button(" Cancel ").clicked() { + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { *open = false; } }); @@ -1409,9 +1414,20 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) { } fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; ui.horizontal(|ui| { - for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { d.tab = tab.to_string(); } } @@ -1423,20 +1439,20 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: "Number" => { let mut code = st.num_fmt.as_str().to_string(); ui.columns(2, |cols| { - cols[0].label(egui::RichText::new("Category:").strong()); + cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); for name in ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] { let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); - if cols[0].selectable_label(code == c, name).clicked() { + if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { code = c; } } - cols[1].label(egui::RichText::new("Sample").strong()); + cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); cols[1].add_space(8.0); - cols[1].label("Type (custom format code):"); + cols[1].label(lang.tr("Type (custom format code):")); cols[1].text_edit_singleline(&mut code); for c in [ "0", @@ -1465,7 +1481,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Alignment" => { use gridcraft_engine::model::{HAlign, VAlign}; - ui.label(egui::RichText::new("Horizontal").strong()); + ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); ui.horizontal_wrapped(|ui| { for (h, n) in [ (HAlign::General, "General"), @@ -1477,10 +1493,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (HAlign::CenterAcross, "Center Across Selection"), (HAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.h, h, n); + ui.radio_value(&mut st.align.h, h, lang.tr(n)); } }); - ui.label(egui::RichText::new("Vertical").strong()); + ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); ui.horizontal_wrapped(|ui| { for (v, n) in [ (VAlign::Top, "Top"), @@ -1489,29 +1505,29 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (VAlign::Justify, "Justify"), (VAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.v, v, n); + ui.radio_value(&mut st.align.v, v, lang.tr(n)); } }); let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text("Indent")); + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); st.align.indent = indent as u8; let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; - ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text("Orientation (degrees)")); + ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, "Wrap text"); - ui.checkbox(&mut st.align.shrink, "Shrink to fit"); + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); } "Font" => { - egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| { + egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { for f in crate::fonts::families() { ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); } }); - ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size")); + ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, "Bold"); - ui.checkbox(&mut st.font.italic, "Italic"); - ui.checkbox(&mut st.font.strike, "Strikethrough"); + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); }); use gridcraft_engine::model::{Underline, VertAlign}; ui.horizontal(|ui| { @@ -1522,17 +1538,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (Underline::SingleAccounting, "Single Accounting"), (Underline::DoubleAccounting, "Double Accounting"), ] { - ui.radio_value(&mut st.font.underline, u, n); + ui.radio_value(&mut st.font.underline, u, lang.tr(n)); } }); ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, "Normal"); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, "Superscript"); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, "Subscript"); + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); }); - ui.label("Color:"); + ui.label(lang.tr("Color:")); let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "Automatic") { + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { st.font.color = if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; } @@ -1541,10 +1557,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: use gridcraft_engine::model::{BorderLine, BorderStyle}; let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; ui.horizontal(|ui| { - if ui.button("None").clicked() { + if ui.button(lang.tr("None")).clicked() { st.borders = Default::default(); } - if ui.button("Outline").clicked() { + if ui.button(lang.tr("Outline")).clicked() { st.borders.top = line; st.borders.bottom = line; st.borders.left = line; @@ -1559,6 +1575,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: ("Diagonal ↘", &mut st.borders.diag_down), ("Diagonal ↗", &mut st.borders.diag_up), ] { + let n = lang.tr(n); ui.horizontal(|ui| { ui.label(format!("{n}:")); for (s, label) in [ @@ -1570,6 +1587,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (BorderStyle::Dotted, "dotted"), (BorderStyle::Double, "double"), ] { + let label = lang.tr(label); let response = ui.selectable_label(slot.style == s, label); response .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}"))); @@ -1584,8 +1602,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Fill" => { let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - ui.label("Background Color:"); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + ui.label(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { st.fill = if c == "none" { Default::default() } else { @@ -1594,11 +1612,13 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } "Protection" => { - ui.checkbox(&mut st.protection.locked, "Locked"); - ui.checkbox(&mut st.protection.hidden, "Hidden"); + ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); ui.label( - egui::RichText::new("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet).") - .small(), + egui::RichText::new( + lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), + ) + .small(), ); } _ => {} diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index ad0725e54..6e833b95d 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -9,6 +9,14 @@ pub(crate) fn canonical( sheet: usize, cell: gridcraft_engine::core::CellRef, ) -> Result { + // The editor hid the `_xludf.` prefixes of this cell's formula; confirming it unchanged keeps + // the user-defined calls instead of binding them to the built-ins of the same name. + if let Some(stored) = wb.sheet(sheet).and_then(|sh| sh.cell(cell)).filter(|c| c.formula.is_some()).map(|c| c.input_text()) + && has_udf_prefix(&stored) + && text == display(&stored, locale, wb, sheet) + { + return Ok(stored); + } if locale == FormulaLocale::En || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") { return Ok(text.to_string()); } @@ -40,16 +48,42 @@ pub(crate) fn canonical( } pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String { - // English (and every language without a formula table) shows the stored text untouched, - // and skips a parse per frame. + // English (and every language without a formula table) shows the stored text, `_xludf.` + // prefixes aside, and skips a parse per frame. if locale == FormulaLocale::En { - return text.to_string(); + return hide_udf_prefix(text, locale); } let Some(body) = text.strip_prefix('=') else { return text.to_string() }; - formula::parse(body) + let shown = formula::parse(body) .and_then(|expr| formula::print_input(&expr, locale, |name| wb.name(name, sheet).is_some() || wb.table(name).is_some())) .map(|body| format!("={body}")) - .unwrap_or_else(|_| text.to_string()) + .unwrap_or_else(|_| text.to_string()); + hide_udf_prefix(&shown, locale) +} + +const UDF_PREFIX: &str = "_xludf."; + +fn has_udf_prefix(text: &str) -> bool { + text.to_ascii_lowercase().contains(UDF_PREFIX) +} + +/// Excel shows a user-defined call such as `_xludf.EOMONTH()` as `=EOMONTH()`. Only function +/// names lose the prefix: text literals and names keep it. +fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String { + let Some(body) = text.strip_prefix('=').filter(|b| has_udf_prefix(b)) else { return text.to_string() }; + let Ok(tokens) = formula::lexer::tokenize_locale(body, locale) else { return text.to_string() }; + let mut out = String::from("="); + let mut from = 0; + for t in tokens { + if matches!(t.tok, formula::lexer::Tok::Func(_)) + && body.get(t.start..t.start + UDF_PREFIX.len()).is_some_and(|p| p.eq_ignore_ascii_case(UDF_PREFIX)) + { + out.push_str(body.get(from..t.start).unwrap_or("")); + from = t.start + UDF_PREFIX.len(); + } + } + out.push_str(body.get(from..).unwrap_or("")); + out } // Descriptions/signatures for the supported Spanish function table; untranslated functions diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 78676e2a3..a9eb039d2 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -68,11 +68,15 @@ pub enum Drag { pub struct GridState { pub drag: Drag, pub ensure_visible: bool, + /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell + /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). + pub reveal_moving_end: bool, pub last_title: Option, pub drag_select: bool, pub rect: Option, pub cells_rect: Option, - pub filter_menu: Option<(u32, Pos2)>, + /// Column and row of the clicked header cell, and where to open the menu. + pub filter_menu: Option<(u32, u32, Pos2)>, pub context_menu: Option, pub marching_phase: f32, /// The selection outline on its way to a new selection. @@ -99,6 +103,8 @@ pub struct GridState { pub paper: Option, /// Header or footer being edited in place (Page Layout view). pub hf_edit: Option, + /// A page margin being dragged on a ruler (Page Layout view); committed on release. + pub ruler_drag: Option, /// Insert › Header & Footer: start editing the first page's header once Page Layout view is up. pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). @@ -487,11 +493,17 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Page Layout view: pages on a desk, rulers above and left of the headings. let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max); + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } @@ -532,10 +544,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { view.scroll.x = view.scroll.x.min(max_x); view.scroll.y = view.scroll.y.min(max_y); let mut geo = make_geo(app, view.scroll); - // Keep the active cell visible after keyboard moves. + // Keep the active cell (or the moving end of an extension) visible after keyboard moves. if app.grid.ensure_visible { app.grid.ensure_visible = false; - let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(sel.active); + let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active }; + let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); if paper { crate::paper_view::ensure_covers(app, &wb, si, target); geo = make_geo(app, view.scroll); @@ -1372,6 +1385,26 @@ fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 } +/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). +/// +/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in +/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay +/// near the viewport do not. Sides wholly outside `clip` are left out. +pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { + if !rect.is_finite() || !clip.is_finite() { + return Vec::new(); + } + let h = width / 2.0; + let clip = clip.expand(width * 2.0); + let sides = [ + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), + ]; + sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() +} + /// `outline` is where the current area's border is drawn this frame (see `selection_glide`). #[allow(clippy::too_many_arguments)] pub(crate) fn paint_selection( @@ -1414,7 +1447,9 @@ pub(crate) fn paint_selection( } } if cur_here { - p.rect_stroke(outline.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { + p.rect_filled(side, 0.0, t.sel_border); + } } if active_here && sel.ranges.len() > 1 { p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); @@ -1742,6 +1777,29 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } + // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. + if resp.double_clicked() + && let Some(p) = pos + && on_handle(p) + { + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let mut last = src.end.row; + while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 + { + last += 1; + } + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); + } + app.grid.drag = Drag::None; + app.grid.drag_select = false; + resp.request_focus(); + return; + } + // Press: decide the drag mode. if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). @@ -1769,6 +1827,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + if point_header(app, wb, sh, RangeRef::cols(c, c)) { + return; + } if resp.drag_started() && !mods.any() && sel.ranges.len() == 1 @@ -1789,6 +1850,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let r = geo.row_at(sh, p.y); + if point_header(app, wb, sh, RangeRef::rows(r, r)) { + return; + } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); @@ -1807,7 +1871,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } // Filter dropdown buttons. if is_filter_button(sh, geo, p) { - app.grid.filter_menu = Some((c.col, geo.mirror_pos(p))); + app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); return; } // Point mode while editing a formula: clicking inserts a reference. @@ -2045,25 +2109,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.drag_select = false; } - // Double-click the fill handle. - if resp.double_clicked() - && let Some(p) = pos - && on_handle(p) - { - // Fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 - { - last += 1; - } - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } - } - // Right-click: context menu. if resp.secondary_clicked() && let Some(p) = pos @@ -2096,6 +2141,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & keyboard(app, &ctx, resp, geo, sh); } +/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting. +fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool { + let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; + let r = range.a1(); + let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r }; + ed.insert_ref(&r); + ed.request_focus = true; + true +} + fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { let cell = CellRef::new( geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), @@ -2370,6 +2425,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let mv = |app: &mut SheetApp, dr: i64, dc: i64| { let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); app.grid.ensure_visible = true; + app.grid.reveal_moving_end = shift; }; match key { Key::ArrowUp => mv(app, -1, 0), @@ -2397,6 +2453,12 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); app.grid.ensure_visible = true; } + // Excel: Enter while a copied range is marked pastes it and ends copy mode. + Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { + app.run_or_alert("edit.paste", json!({})); + app.session.clipboard = None; + app.grid.ensure_visible = true; + } Key::Enter => { if m.alt || m.ctrl { app.begin_edit(None, false); @@ -2479,20 +2541,20 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) } +/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. +fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { + sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) +} + fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some((col, at)) = app.grid.filter_menu else { return }; + let Some((col, row, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); let si = wb.active_sheet; let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh - .tables - .iter() - .find(|t| t.range.start.row <= at.y as u32 || true) - .filter(|t| col >= t.range.start.col && col <= t.range.end.col) - .map(|t| t.range); + let table_range = filter_table_range(sh, col, row); if sh.autofilter.is_none() && let Some(r) = table_range { @@ -2676,7 +2738,17 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte let from = ed.point_cell.unwrap_or(ed.cell); let to = from.offset_clamped(dr, dc); let text = if extend { - let anchor = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(|t| CellRef::parse(t.split(':').next().unwrap_or(""))).unwrap_or(from); + // The anchor is the corner opposite the moving end: the written range is normalized, so + // after extending up or left its first cell is the moving end, not the anchor. + let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); + let anchor = pointed + .map(|r| { + CellRef::new( + if from.row == r.start.row { r.end.row } else { r.start.row }, + if from.col == r.start.col { r.end.col } else { r.start.col }, + ) + }) + .unwrap_or(from); RangeRef::new(anchor, to).a1() } else { to.a1() @@ -2717,8 +2789,9 @@ pub(crate) fn paint_overlays( p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside); crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60)); } - // Input message of the active cell's validation. + // Input message of the active cell's validation, hidden while its list is open: it would cover the items. if app.editor.is_none() + && app.grid.list_picker != Some(sel.active) && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) && dv.show_input && !(dv.input_title.is_empty() && dv.input_message.is_empty()) @@ -3056,6 +3129,32 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn filter_menu_uses_the_table_whose_header_was_clicked() { + use gridcraft_engine::model::Table; + let table = |id: u32, r0: u32, r1: u32| Table { + id, + name: format!("T{id}"), + range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), + header_row: true, + totals_row: false, + columns: vec![], + style: String::new(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + }; + let mut sh = Sheet::new("S"); + sh.tables.push(table(1, 0, 2)); + sh.tables.push(table(2, 4, 7)); + assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); + assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); + assert_eq!(filter_table_range(&sh, 1, 4), None); + } + #[test] fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); @@ -3241,3 +3340,27 @@ mod pane_tests { assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); } } + +#[cfg(test)] +mod outline_band_tests { + use super::*; + + #[test] + fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); + let sides = outline_bands(col, 2.0, clip); + // Left, right and top (in view); the bottom is 20 million points away. + assert_eq!(sides.len(), 3); + assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); + assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); + assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); + } + + #[test] + fn outline_outside_the_view_and_non_finite_draw_nothing() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); + assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6c7febedc..9b9511e63 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,6 +296,12 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select\nObjects", "オブジェクトの選択"), ("Themes", "テーマ"), ("Margins", "余白"), + ("Left Margin", "左余白"), + ("Right Margin", "右余白"), + ("Top Margin", "上余白"), + ("Bottom Margin", "下余白"), + ("Header Margin", "ヘッダーの余白"), + ("Footer Margin", "フッターの余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 2ed53f0b3..e7c9ca6f6 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -116,6 +116,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Vybrat\nobjekty", "Themes" => "Motivy", "Margins" => "Okraje", + "Left Margin" => "Levý okraj", + "Right Margin" => "Pravý okraj", + "Top Margin" => "Horní okraj", + "Bottom Margin" => "Dolní okraj", + "Header Margin" => "Okraj záhlaví", + "Footer Margin" => "Okraj zápatí", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", @@ -401,6 +407,133 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Další\nfunkce", "Sheet Options|View" => "Zobrazit", "Print" => "Tisknout", + + // Additional runtime labels and tooltips, grouped by source file. + // formula_bar.rs (alphabetical keys). + "Cancel" => "Zrušit", + "Expand formula bar" => "Rozbalit řádek vzorců", + "Insert Function" => "Vložit funkci", + // grid.rs (alphabetical keys). + "Copy" => "Kopírovat", + "Cut" => "Vyjmout", + "Define Name…" => "Definovat název…", + "Delete…" => "Odstranit…", + "Filter by Selected Cell's Value" => "Filtrovat podle hodnoty vybrané buňky", + "Hide" => "Skrýt", + "Insert…" => "Vložit…", + "Link…" => "Odkaz…", + "New Comment" => "Nový komentář", + "Pick From Drop-down List…" => "Vybrat z rozevíracího seznamu…", + "Unhide" => "Zobrazit", + // ribbon.rs (alphabetical keys). + "$ English (United States)" => "$ Angličtina (Spojené státy)", + "Add Chart Element" => "Přidat prvek grafu", + "Align Left" => "Zarovnat doleva", + "Align Right" => "Zarovnat doprava", + "Bottom Align" => "Zarovnat dolů", + "Calculate Now (F9)" => "Přepočítat (F9)", + "Calculate Sheet (⇧F9)" => "Přepočítat list (⇧F9)", + "Calculation Options" => "Možnosti výpočtu", + "Cell Styles" => "Styly buněk", + "Change Chart Type" => "Změnit typ grafu", + "Check Accessibility" => "Zkontrolovat přístupnost", + "Clear Filter" => "Vymazat filtr", + "Column or Bar" => "Sloupcový nebo pruhový graf", + "Comments pane" => "Podokno komentářů", + "Convert the last ink stroke to a shape" => "Převést poslední tah rukopisu na obrazec", + "Convert to Range" => "Převést na oblast", + "Custom Sort" => "Vlastní řazení", + "Delete Comment" => "Odstranit komentář", + "Delete Sheet" => "Odstranit list", + "Delete chart" => "Odstranit graf", + "Down" => "Dolů", + "Edit chart title" => "Upravit název grafu", + "Erase ink strokes" => "Vymazat tahy rukopisu", + "Every command run in this session (replayable)" => "Všechny příkazy spuštěné v této relaci (lze znovu přehrát)", + "Filter (⌘⇧F)" => "Filtr (⌘⇧F)", + "Find & Select" => "Najít a vybrat", + "Flash Fill (⌘E)" => "Dynamické doplňování (⌘E)", + "Forecast Sheet" => "List prognózy", + "Format Chart pane" => "Podokno formátu grafu", + "Format as Table" => "Formátovat jako tabulku", + "Hide Sheet" => "Skrýt list", + "Hierarchy & more" => "Hierarchické a další grafy", + "Highlighter" => "Zvýrazňovač", + "How agents drive GridCraft (MCP / control channel)" => "Jak agenti ovládají GridCraft (MCP / řídicí kanál)", + "Import a text or CSV file" => "Importovat textový soubor nebo CSV", + "Insert\nSlicer" => "Vložit\nprůřez", + "Insert Function (⇧F3)" => "Vložit funkci (⇧F3)", + "Insert Link (⌘K)" => "Vložit odkaz (⌘K)", + "Insert Sheet" => "Vložit list", + "Insert Slicer" => "Vložit průřez", + "Insert a picture" => "Vložit obrázek", + "Insert a slicer to filter a table" => "Vložit průřez pro filtrování tabulky", + "Insert an icon" => "Vložit ikonu", + "Line or Area" => "Spojnicový nebo plošný graf", + "Macros" => "Makra", + "Macros and scripts in this workbook (Alt+F8)" => "Makra a skripty v tomto sešitu (Alt+F8)", + "Middle Align" => "Zarovnat doprostřed", + "More commands" => "Další příkazy", + "Move or Copy Sheet…" => "Přesunout nebo zkopírovat list…", + "Name Manager" => "Správce názvů", + "Page Breaks" => "Konce stránek", + "Paper Size" => "Velikost papíru", + "Paste (⌘V)" => "Vložit (⌘V)", + "Pen\nBlack" => "Pero\nčerné", + "Pen\nBlue" => "Pero\nmodré", + "Pen\nRed" => "Pero\nčervené", + "Pie or Doughnut" => "Výsečový nebo prstencový graf", + "Print Area" => "Oblast tisku", + "Print Titles" => "Tisk názvů", + "Protect\nSheet" => "Zamknout\nlist", + "Protect Sheet" => "Zamknout list", + "Protect Workbook structure" => "Zamknout strukturu sešitu", + "Recommended Charts" => "Doporučené grafy", + "Redo (⌘Y)" => "Znovu (⌘Y)", + "Remove Duplicates" => "Odebrat duplicity", + "Rename Sheet" => "Přejmenovat list", + "Save (⌘S)" => "Uložit (⌘S)", + "Scatter or Bubble" => "Bodový nebo bublinový graf", + "Search and run any command" => "Vyhledat a spustit libovolný příkaz", + "Search commands (⌘⇧Space)" => "Vyhledat příkazy (⌘⇧Space)", + "Select Data" => "Vybrat data", + "Select and move ink and shapes" => "Vybrat a přesunout rukopis a obrazce", + "Series…" => "Řady…", + "Share: export or save a copy" => "Sdílet: exportovat nebo uložit kopii", + "Show Formulas (⌃`)" => "Zobrazit vzorce (⌃`)", + "Slicer" => "Průřez", + "Sort & Filter" => "Seřadit a filtrovat", + "Spelling (F7)" => "Pravopis (F7)", + "Statistic" => "Statistické grafy", + "Switch Row/Column" => "Prohodit řádky/sloupce", + "Table (⌘T)" => "Tabulka (⌘T)", + "Table Styles" => "Styly tabulky", + "Text Box" => "Textové pole", + "Text to Columns" => "Text do sloupců", + "Top Align" => "Zarovnat nahoru", + "Undo (⌘Z)" => "Zpět (⌘Z)", + "Undo list" => "Seznam akcí k vrácení", + "Unhide Sheet…" => "Zobrazit list…", + "Up" => "Nahoru", + "Version, contributors and the AI models that helped" => "Verze, přispěvatelé a modely AI, které pomáhaly", + "Watch\nWindow" => "Okno\nsledování", + "What-If Analysis" => "Citlivostní analýza", + "Workbook\nStatistics" => "Statistika\nsešitu", + "Workbook Statistics" => "Statistika sešitu", + "Zoom to 100%" => "Přiblížit na 100 %", + "Zoom to Selection" => "Přiblížit na výběr", + "Zoom…" => "Lupa…", + "£ English (United Kingdom)" => "£ Angličtina (Spojené království)", + "¥ Japanese" => "¥ Japonština", + "€ Euro" => "€ Euro", + // tabs.rs (alphabetical keys). + "New sheet (⇧F11)" => "Nový list (⇧F11)", + "Next sheet" => "Další list", + "Previous sheet" => "Předchozí list", + "Zoom In" => "Přiblížit", + "Zoom Out" => "Oddálit", + // widgets.rs (alphabetical keys). + "Dismiss" => "Zavřít", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0b43bb771..6ceabdf1b 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -116,6 +116,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Objekte\nauswählen", "Themes" => "Designs", "Margins" => "Seitenränder", + "Left Margin" => "Linker Seitenrand", + "Right Margin" => "Rechter Seitenrand", + "Top Margin" => "Oberer Seitenrand", + "Bottom Margin" => "Unterer Seitenrand", + "Header Margin" => "Kopfzeilenabstand", + "Footer Margin" => "Fußzeilenabstand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 11a2f77a8..372ef1d98 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -117,6 +117,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Seleccionar\nobjetos", "Themes" => "Temas", "Margins" => "Márgenes", + "Left Margin" => "Margen izquierdo", + "Right Margin" => "Margen derecho", + "Top Margin" => "Margen superior", + "Bottom Margin" => "Margen inferior", + "Header Margin" => "Margen del encabezado", + "Footer Margin" => "Margen del pie de página", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 1797f01fd..34370b740 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -124,6 +124,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Sélectionner les\nobjets", "Themes" => "Thèmes", "Margins" => "Marges", + "Left Margin" => "Marge gauche", + "Right Margin" => "Marge droite", + "Top Margin" => "Marge haute", + "Bottom Margin" => "Marge basse", + "Header Margin" => "Marge de l'en-tête", + "Footer Margin" => "Marge du pied de page", "Size" => "Taille", "Print\nArea" => "Zone\nd'impression", "Set Print Area" => "Définir la zone d'impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c23b60c41..9e5a85ea9 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "개체\n선택", "Themes" => "테마", "Margins" => "여백", + "Left Margin" => "왼쪽 여백", + "Right Margin" => "오른쪽 여백", + "Top Margin" => "위쪽 여백", + "Bottom Margin" => "아래쪽 여백", + "Header Margin" => "머리글 여백", + "Footer Margin" => "바닥글 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 19151462a..dcf6563b2 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -122,6 +122,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Selecionar\nObjetos", "Themes" => "Temas", "Margins" => "Margens", + "Left Margin" => "Margem esquerda", + "Right Margin" => "Margem direita", + "Top Margin" => "Margem superior", + "Bottom Margin" => "Margem inferior", + "Header Margin" => "Margem do cabeçalho", + "Footer Margin" => "Margem do rodapé", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", @@ -346,7 +352,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Modified" => "Modificado", "Not available" => "Indisponível", "Just now" => "Agora mesmo", - "Yesterday" => "Ontem", "{n} min ago" => "há {n} min", "{n} h ago" => "há {n} h", "{n} days ago" => "há {n} dias", @@ -404,6 +409,552 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Cálculo da pasta de trabalho", "Enable iterative calculation" => "Habilitar cálculo iterativo", "Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.", + "OK" => "OK", + "Cancel" => "Cancelar", + "Don't Save" => "Não Salvar", + "Open…" => "Abrir…", + "Show password" => "Mostrar senha", + "Start a new workbook" => "Criar uma pasta de trabalho", + "Household Budget" => "Orçamento Doméstico", + "Quarterly Sales Dashboard" => "Painel de Vendas Trimestrais", + "Gradebook" => "Boletim de Notas", + "this workbook" => "esta pasta de trabalho", + "This workbook" => "Esta pasta de trabalho", + "Do you want to save the changes you made to {title}?" => "Deseja salvar as alterações feitas em {title}?", + "Save a copy as .{format}" => "Salvar uma cópia como .{format}", + "Select a chart first." => "Selecione um gráfico primeiro.", + "Text Box" => "Caixa de Texto", + "Select Data" => "Selecionar Dados", + "Row Height" => "Altura da Linha", + "Column Width" => "Largura da Coluna", + "Standard Width" => "Largura Padrão", + "Rename Sheet" => "Renomear Planilha", + "Move or Copy" => "Mover ou Copiar", + "Protect Sheet" => "Proteger Planilha", + "Protect Structure and Windows" => "Proteger Estrutura e Janelas", + "Unprotect Workbook" => "Desproteger Pasta de Trabalho", + "Unprotect Sheet" => "Desproteger Planilha", + "Password" => "Senha", + "Unhide" => "Reexibir", + "Paste Special" => "Colar Especial", + "Series" => "Série", + "New Comment" => "Novo Comentário", + "Insert Hyperlink" => "Inserir Hiperlink", + "Create Sparklines" => "Criar Minigráficos", + "Page Setup" => "Configurar Página", + "Convert Text to Columns" => "Converter Texto em Colunas", + "Remove Duplicates" => "Remover Duplicatas", + "Goal Seek" => "Atingir Meta", + "Create Forecast Worksheet" => "Criar Planilha de Previsão", + "Solver Parameters" => "Parâmetros do Solver", + "Solver Results" => "Resultados do Solver", + "Edit Name" => "Editar Nome", + "New Name" => "Novo Nome", + "Format Cells" => "Formatar Células", + "Formula Builder" => "Construtor de Fórmulas", + "Find and Replace" => "Localizar e Substituir", + "Go To" => "Ir para", + "Insert Calculated Field" => "Inserir Campo Calculado", + "Go To Special" => "Ir para Especial", + "Name Manager" => "Gerenciador de Nomes", + "Conditional Formatting Rules Manager" => "Gerenciador de Regras de Formatação Condicional", + "Search Commands" => "Pesquisar Comandos", + "Agent Control" => "Controle por Agentes", + "About GridCraft" => "Sobre o GridCraft", + "Insert Chart" => "Inserir Gráfico", + "Select Data Source" => "Selecionar Fonte de Dados", + "Action Journal" => "Registro de Ações", + "Macros" => "Macros", + "Workbook Statistics" => "Estatísticas da Pasta de Trabalho", + "Accessibility" => "Acessibilidade", + "Chart Title" => "Título do Gráfico", + "Place Picture in Cell" => "Colocar Imagem na Célula", + "Place Picture over Cells" => "Colocar Imagem sobre as Células", + "Pick From List" => "Escolher na Lista", + "Save a Copy" => "Salvar uma Cópia", + "Save Changes?" => "Salvar Alterações?", + "Shift cells right" => "Deslocar células para a direita", + "Shift cells down" => "Deslocar células para baixo", + "Shift cells left" => "Deslocar células para a esquerda", + "Shift cells up" => "Deslocar células para cima", + "Entire row" => "Linha inteira", + "Entire column" => "Coluna inteira", + "Row height (pixels):" => "Altura da linha (pixels):", + "Column width (characters):" => "Largura da coluna (caracteres):", + "Standard column width (pixels):" => "Largura padrão da coluna (pixels):", + "Name:" => "Nome:", + "Before sheet (position, 0 = first):" => "Antes da planilha (posição, 0 = primeira):", + "Create a copy" => "Criar uma cópia", + "Sheets: {sheets}" => "Planilhas: {sheets}", + "Password (optional):" => "Senha (opcional):", + "Password:" => "Senha:", + "Allow all users of this sheet to:" => "Permitir que todos os usuários desta planilha possam:", + "Format cells" => "Formatar células", + "Format columns" => "Formatar colunas", + "Format rows" => "Formatar linhas", + "Insert rows" => "Inserir linhas", + "Delete rows" => "Excluir linhas", + "Use AutoFilter" => "Usar AutoFiltro", + " (password, unlocked)" => " (senha, desbloqueado)", + " (password)" => " (senha)", + "No ranges yet." => "Nenhum intervalo ainda.", + "Ranges that stay editable when the sheet is protected:" => "Intervalos que continuam editáveis quando a planilha está protegida:", + "Action" => "Ação", + "Modify" => "Modificar", + "Unlock with password" => "Desbloquear com senha", + "Title:" => "Título:", + "Refers to cells:" => "Refere-se às células:", + "Range password:" => "Senha do intervalo:", + "New title (Modify):" => "Novo título (Modificar):", + "Remove the password (Modify)" => "Remover a senha (Modificar)", + "'{file}' is protected." => "'{file}' está protegido.", + "Unhide sheet:" => "Reexibir planilha:", + "Hidden: {sheets}" => "Ocultas: {sheets}", + "All" => "Tudo", + "Formulas (F)" => "Fórmulas (F)", + "Values (V)" => "Valores (V)", + "Formats (T)" => "Formatos (T)", + "Comments and notes (C)" => "Comentários e notas (C)", + "Validation" => "Validação", + "All except borders" => "Tudo, exceto bordas", + "Column widths" => "Larguras da coluna", + "Formulas and number formats" => "Fórmulas e formatos de número", + "Values and number formats" => "Valores e formatos de número", + "Operation" => "Operação", + "Add" => "Adicionar", + "Subtract" => "Subtrair", + "Multiply" => "Multiplicar", + "Divide" => "Dividir", + "Skip blanks" => "Ignorar em branco", + "Transpose (E)" => "Transpor (E)", + "Series in" => "Série em", + "Columns" => "Colunas", + "Rows" => "Linhas", + "Type" => "Tipo", + "Linear" => "Linear", + "Growth" => "Crescimento", + "Date unit" => "Unidade de data", + "Day" => "Dia", + "Weekday" => "Dia da semana", + "Month" => "Mês", + "Year" => "Ano", + "Step value:" => "Incremento:", + "Stop value:" => "Limite:", + "Start a conversation:" => "Iniciar uma conversa:", + "Note:" => "Nota:", + "Display:" => "Texto para exibição:", + "Link to (web address, e-mail or Sheet!A1):" => "Vincular a (endereço da Web, e-mail ou Planilha!A1):", + "ScreenTip:" => "Dica de tela:", + "Data Range:" => "Intervalo de dados:", + "Location Range:" => "Intervalo de local:", + "Markers" => "Marcadores", + "Footer:" => "Rodapé:", + "Left margin (in):" => "Margem esquerda (pol.):", + "Right margin (in):" => "Margem direita (pol.):", + "Top margin (in):" => "Margem superior (pol.):", + "Bottom margin (in):" => "Margem inferior (pol.):", + "Header (in):" => "Cabeçalho (pol.):", + "Footer (in):" => "Rodapé (pol.):", + "Tab" => "Tabulação", + "Semicolon" => "Ponto e vírgula", + "Comma" => "Vírgula", + "Space" => "Espaço", + "Other:" => "Outros:", + "Treat consecutive delimiters as one" => "Considerar delimitadores consecutivos como um só", + "Destination:" => "Destino:", + "My data has headers" => "Meus dados contêm cabeçalhos", + "Columns (blank = all, e.g. A,C):" => "Colunas (em branco = todas, p. ex. A,C):", + "Timeline range (blank: first column of the selection):" => "Intervalo da linha do tempo (em branco: primeira coluna da seleção):", + "Values range (blank: second column of the selection):" => "Intervalo de valores (em branco: segunda coluna da seleção):", + "Confidence interval" => "Intervalo de confiança", + "Confidence level (%):" => "Nível de confiança (%):", + "Seasonality (auto, 0 for none, or a season length):" => "Sazonalidade (automática, 0 para nenhuma ou a duração de um período):", + "Fill missing points using" => "Preencher pontos ausentes usando", + "Interpolation" => "Interpolação", + "Zeros" => "Zeros", + "Aggregate duplicates using" => "Agregar duplicatas usando", + "Average" => "Média", + "Count" => "Contagem", + "CountA" => "Cont.Valores", + "Max" => "Máx", + "Median" => "Mediana", + "Min" => "Mín", + "Sum" => "Soma", + "Product" => "Produto", + "Include forecast statistics" => "Incluir estatísticas de previsão", + "Chart" => "Gráfico", + "Line chart" => "Gráfico de linhas", + "Column chart" => "Gráfico de colunas", + "At each change in (column letter):" => "A cada alteração em (letra da coluna):", + "Use function" => "Usar função", + "Add subtotal to (columns, e.g. C,D):" => "Adicionar subtotal a (colunas, p. ex. C,D):", + "Magnification" => "Ampliação", + "Custom (%):" => "Personalizado (%):", + "Scope (Workbook or sheet name):" => "Escopo (Pasta de Trabalho ou nome da planilha):", + "Comment:" => "Comentário:", + "Refers to:" => "Refere-se a:", + "Allow" => "Permitir", + "Any value" => "Qualquer valor", + "Whole number" => "Número inteiro", + "Decimal" => "Decimal", + "List" => "Lista", + "Text length" => "Comprimento do texto", + "between" => "está entre", + "not between" => "não está entre", + "equal to" => "é igual a", + "not equal to" => "é diferente de", + "greater than" => "é maior do que", + "less than" => "é menor do que", + "greater than or equal to" => "é maior ou igual a", + "less than or equal to" => "é menor ou igual a", + "Minimum / Source / Formula:" => "Mínimo / Fonte / Fórmula:", + "Maximum:" => "Máximo:", + "Ignore blank" => "Ignorar em branco", + "In-cell dropdown" => "Menu suspenso na célula", + "Input message:" => "Mensagem de entrada:", + "Error style" => "Estilo de erro", + "Stop" => "Parar", + "Warning" => "Aviso", + "Information" => "Informações", + "Error message:" => "Mensagem de erro:", + "Format cells that are (value):" => "Formatar células que são (valor):", + "and:" => "e:", + "Format cells that contain the text:" => "Formatar células que contêm o texto:", + "Format cells that contain a date occurring:" => "Formatar células que contêm uma data:", + "Yesterday" => "Ontem", + "Today" => "Hoje", + "Tomorrow" => "Amanhã", + "In the last 7 days" => "Nos últimos 7 dias", + "Last week" => "Semana passada", + "This week" => "Esta semana", + "Next week" => "Próxima semana", + "Last month" => "Mês passado", + "This month" => "Este mês", + "Next month" => "Próximo mês", + "Format values where this formula is true:" => "Formatar valores em que esta fórmula é verdadeira:", + "with" => "com", + "Light Red Fill with Dark Red Text" => "Preenchimento Vermelho Claro e Texto Vermelho Escuro", + "Yellow Fill with Dark Yellow Text" => "Preenchimento Amarelo e Texto Amarelo Escuro", + "Green Fill with Dark Green Text" => "Preenchimento Verde e Texto Verde Escuro", + "Light Red Fill" => "Preenchimento Vermelho Claro", + "Red Text" => "Texto Vermelho", + "Red Border" => "Borda Vermelha", + "Formula:" => "Fórmula:", + "Select" => "Selecionar", + "Blanks" => "Células em branco", + "Errors" => "Erros", + "Numbers" => "Números", + "Current region" => "Região atual", + "Last cell" => "Última célula", + "Visible cells only" => "Somente células visíveis", + "Conditional formats" => "Formatos condicionais", + "Data validation" => "Validação de dados", + "Horizontal axis title:" => "Título do eixo horizontal:", + "Vertical axis title:" => "Título do eixo vertical:", + "No picture selected" => "Nenhuma imagem selecionada", + "Picture file path (PNG or JPEG):" => "Caminho do arquivo de imagem (PNG ou JPEG):", + "Description (optional):" => "Descrição (opcional):", + "Fits inside the selected cell and moves with its content." => "Ajusta-se dentro da célula selecionada e move-se com o conteúdo dela.", + "Choose a PNG or JPEG no larger than 16 MB." => "Escolha um PNG ou JPEG de no máximo 16 MB.", + "About" => "Sobre", + "Contributors" => "Colaboradores", + "Models" => "Modelos", + "Version {version}" => "Versão {version}", + "Join the ArtCraft Discord" => "Participe do Discord da ArtCraft", + "This workbook has no scripts or macros." => "Esta pasta de trabalho não tem scripts nem macros.", + "(unnamed)" => "(sem nome)", + "— VBA project (preserved, not run)" => "— Projeto VBA (preservado, não executado)", + "— 1 step" => "— 1 etapa", + "— {steps} steps" => "— {steps} etapas", + "Alignment" => "Alinhamento", + "Font" => "Fonte", + "Border" => "Borda", + "Format Cells|Fill" => "Preenchimento", + "Protection" => "Proteção", + "Category:" => "Categoria:", + "Sample" => "Exemplo", + "Type (custom format code):" => "Tipo (código de formato personalizado):", + "Vertical" => "Vertical", + "Justify" => "Justificar", + "Center Across Selection" => "Centralizar Seleção", + "Distributed" => "Distribuído", + "Top" => "Superior", + "Bottom" => "Inferior", + "Indent" => "Recuo", + "Orientation (degrees)" => "Orientação (graus)", + "Wrap text" => "Quebrar texto automaticamente", + "Shrink to fit" => "Reduzir para caber", + "Bold" => "Negrito", + "Italic" => "Itálico", + "None" => "Nenhum", + "Single" => "Simples", + "Double" => "Duplo", + "Single Accounting" => "Simples (Contábil)", + "Double Accounting" => "Duplo (Contábil)", + "Superscript" => "Sobrescrito", + "Subscript" => "Subscrito", + "Color:" => "Cor:", + "Outline" => "Contorno", + "Diagonal ↘" => "Diagonal ↘", + "Diagonal ↗" => "Diagonal ↗", + "none" => "nenhuma", + "thin" => "fina", + "medium" => "média", + "thick" => "espessa", + "dashed" => "tracejada", + "dotted" => "pontilhada", + "double" => "dupla", + "Background Color:" => "Cor do plano de fundo:", + "Locked" => "Bloqueadas", + "Hidden" => "Ocultas", + "Search:" => "Pesquisar:", + "e.g. lookup, average, date" => "p. ex. pesquisa, média, data", + "Date & Time" => "Data e Hora", + "Lookup & Reference" => "Pesquisa e Referência", + "Math & Trig" => "Matemática e Trigonometria", + "Statistical" => "Estatística", + "Engineering" => "Engenharia", + "Database" => "Banco de Dados", + "Compatibility" => "Compatibilidade", + "Web" => "Web", + "Cube" => "Cubo", + "Add-in and Automation" => "Suplemento e Automação", + "Find" => "Localizar", + "Replace" => "Substituir", + "Find what:" => "Localizar:", + "Replace with:" => "Substituir por:", + "Match case" => "Diferenciar maiúsculas e minúsculas", + "Match entire cell contents" => "Coincidir conteúdo da célula inteira", + "Within workbook" => "Na pasta de trabalho", + "Replace All" => "Substituir Tudo", + "Find All" => "Localizar Tudo", + "Find Next" => "Localizar Próxima", + "All done. We made {count} replacements." => "Tudo pronto. Fizemos {count} substituições.", + "{count} cell(s) found" => "{count} célula(s) encontrada(s)", + "Reference:" => "Referência:", + "Special…" => "Especial…", + "My list has headers" => "Meus dados contêm cabeçalhos", + "Sort by" => "Classificar por", + "Then by" => "E depois por", + "Cell Values" => "Valores de Célula", + "Cell Color" => "Cor da Célula", + "A to Z" => "De A a Z", + "Z to A" => "De Z a A", + "+ Add Level" => "+ Adicionar Nível", + "New…" => "Novo…", + "Edit…" => "Editar…", + "Name" => "Nome", + "Value" => "Valor", + "Refers To" => "Refere-se a", + "Scope" => "Escopo", + "Workbook" => "Pasta de Trabalho", + "No names defined" => "Nenhum nome definido", + "Recommended Charts" => "Gráficos Recomendados", + "All Charts" => "Todos os Gráficos", + "Clustered Column" => "Colunas Agrupadas", + "Stacked Column" => "Colunas Empilhadas", + "100% Stacked Column" => "Colunas 100% Empilhadas", + "Clustered Bar" => "Barras Agrupadas", + "Stacked Bar" => "Barras Empilhadas", + "100% Stacked Bar" => "Barras 100% Empilhadas", + "Line" => "Linhas", + "Line with Markers" => "Linhas com Marcadores", + "Stacked Line" => "Linhas Empilhadas", + "Pie" => "Pizza", + "Doughnut" => "Rosca", + "Area" => "Área", + "Stacked Area" => "Área Empilhada", + "Scatter" => "Dispersão", + "Scatter with Lines" => "Dispersão com Linhas", + "Radar" => "Radar", + "Histogram" => "Histograma", + "Waterfall" => "Cascata", + "Funnel" => "Funil", + "Combo" => "Combinação", + "Chart data range:" => "Intervalo de dados do gráfico:", + "Legend Entries (Series)" => "Entradas de Legenda (Série)", + "Series{n}" => "Série{n}", + "Keep" => "Manter", + "Remove" => "Remover", + "Horizontal (Category) Axis Labels:" => "Rótulos do Eixo Horizontal (Categorias):", + "There are no conditional formatting rules on this sheet." => "Não há regras de formatação condicional nesta planilha.", + "applies to" => "aplica-se a", + "Type a command, e.g. freeze, chart, bold…" => "Digite um comando, p. ex. congelar, gráfico, negrito…", + "Set cell:" => "Definir célula:", + "To value:" => "Para valor:", + "By changing cell:" => "Alternando célula:", + "Goal Seeking found a solution: {value}" => "Atingir Meta encontrou uma solução: {value}", + "Analysis Tools" => "Ferramentas de Análise", + "Anova: Single Factor" => "Anova: fator único", + "Correlation" => "Correlação", + "Covariance" => "Covariância", + "Descriptive Statistics" => "Estatística descritiva", + "Exponential Smoothing" => "Ajuste exponencial", + "F-Test Two-Sample for Variances" => "Teste F: duas amostras para variâncias", + "Moving Average" => "Média móvel", + "Rank and Percentile" => "Ordem e percentil", + "Regression" => "Regressão", + "t-Test: Paired Two Sample for Means" => "Teste t: duas amostras em par para médias", + "t-Test: Two-Sample Assuming Equal Variances" => "Teste t: duas amostras presumindo variâncias equivalentes", + "t-Test: Two-Sample Assuming Unequal Variances" => "Teste t: duas amostras presumindo variâncias diferentes", + "Output options" => "Opções de saída", + "Output Range:" => "Intervalo de saída:", + "New Worksheet Ply:" => "Nova planilha:", + "Grouped By:" => "Agrupado por:", + "Labels in first row" => "Rótulos na primeira linha", + "Input Range:" => "Intervalo de entrada:", + "Variable 1 Range:" => "Intervalo da variável 1:", + "Variable 2 Range:" => "Intervalo da variável 2:", + "Summary statistics" => "Resumo estatístico", + "Confidence Level for Mean (%), blank for none:" => "Nível de confiabilidade para a média (%), em branco para nenhum:", + "Kth Largest (blank for none):" => "K-ésimo maior (em branco para nenhum):", + "Kth Smallest (blank for none):" => "K-ésimo menor (em branco para nenhum):", + "Bin Range (blank for automatic bins):" => "Intervalo do bloco (em branco para blocos automáticos):", + "Pareto (sorted histogram)" => "Pareto (histograma classificado)", + "Cumulative Percentage" => "Porcentagem cumulativa", + "Alpha:" => "Alfa:", + "Input Y Range:" => "Intervalo Y de entrada:", + "Input X Range:" => "Intervalo X de entrada:", + "Constant is Zero" => "Constante é zero", + "Confidence Level (%):" => "Nível de confiança (%):", + "Residuals" => "Resíduos", + "Standardized Residuals" => "Resíduos padronizados", + "Normal Probability" => "Plotagem de probabilidade normal", + "Interval:" => "Intervalo:", + "Standard Errors" => "Erros padrão", + "Damping factor:" => "Fator de amortecimento:", + "Hypothesized Mean Difference:" => "Hipótese da diferença de média:", + "Set Objective:" => "Definir Objetivo:", + "To:" => "Para:", + "Value Of:" => "Valor de:", + "By Changing Variable Cells:" => "Alterando Células Variáveis:", + "Subject to the Constraints:" => "Sujeito às Restrições:", + "Make Unconstrained Variables Non-Negative" => "Tornar Variáveis Irrestritas Não Negativas", + "Select a Solving Method:" => "Selecionar um Método de Solução:", + "GRG Nonlinear" => "GRG Não Linear", + "Solve" => "Resolver", + "Keep Solver Solution" => "Manter Solução do Solver", + "Restore Original Values" => "Restaurar Valores Originais", + "The spelling check is complete for the entire sheet." => "A verificação ortográfica da planilha inteira foi concluída.", + "Not in Dictionary ({cell}):" => "Não consta no dicionário ({cell}):", + "Suggestions:" => "Sugestões:", + "(No suggestions)" => "(Nenhuma sugestão)", + "Ignore Once" => "Ignorar uma vez", + "Add to Dictionary" => "Adicionar ao dicionário", + "Change" => "Alterar", + "Change All" => "Alterar todas", + "$ English (United States)" => "$ Inglês (Estados Unidos)", + "Add Chart Element" => "Adicionar Elemento Gráfico", + "Align Left" => "Alinhar à Esquerda", + "Align Right" => "Alinhar à Direita", + "Bottom Align" => "Alinhar Embaixo", + "Calculate Now (F9)" => "Calcular Agora (F9)", + "Calculate Sheet (⇧F9)" => "Calcular Planilha (⇧F9)", + "Calculation Options" => "Opções de Cálculo", + "Cell Styles" => "Estilos de Célula", + "Change Chart Type" => "Alterar Tipo de Gráfico", + "Check Accessibility" => "Verificar Acessibilidade", + "Clear Filter" => "Limpar Filtro", + "Column or Bar" => "Coluna ou Barra", + "Comments pane" => "Painel de comentários", + "Convert the last ink stroke to a shape" => "Converter o último traço de tinta em forma", + "Convert to Range" => "Converter em Intervalo", + "Copy" => "Copiar", + "Custom Sort" => "Classificação Personalizada", + "Cut" => "Recortar", + "Define Name…" => "Definir Nome…", + "Delete Comment" => "Excluir Comentário", + "Delete Sheet" => "Excluir Planilha", + "Delete chart" => "Excluir gráfico", + "Delete…" => "Excluir…", + "Dismiss" => "Fechar", + "Down" => "Para Baixo", + "Edit chart title" => "Editar título do gráfico", + "Erase ink strokes" => "Apagar traços de tinta", + "Every command run in this session (replayable)" => "Todos os comandos executados nesta sessão (podem ser repetidos)", + "Expand formula bar" => "Expandir barra de fórmulas", + "Filter (⌘⇧F)" => "Filtro (⌘⇧F)", + "Filter by Selected Cell's Value" => "Filtrar pelo Valor da Célula Selecionada", + "Find & Select" => "Localizar e Selecionar", + "Flash Fill (⌘E)" => "Preenchimento Relâmpago (⌘E)", + "Forecast Sheet" => "Planilha de Previsão", + "Forecast end (date or number; blank: a third of the history):" => "Fim da previsão (data ou número; em branco: um terço do histórico):", + "Format Chart pane" => "Painel Formatar Gráfico", + "Format as Table" => "Formatar como Tabela", + "Hide" => "Ocultar", + "Hide Sheet" => "Ocultar Planilha", + "Hierarchy & more" => "Hierarquia e mais", + "Highlighter" => "Marca-texto", + "How agents drive GridCraft (MCP / control channel)" => "Como os agentes controlam o GridCraft (MCP / canal de controle)", + "Import a text or CSV file" => "Importar um arquivo de texto ou CSV", + "Insert Function" => "Inserir Função", + "Insert Function (⇧F3)" => "Inserir Função (⇧F3)", + "Insert Link (⌘K)" => "Inserir Link (⌘K)", + "Insert Sheet" => "Inserir Planilha", + "Insert Slicer" => "Inserir Segmentação de Dados", + "Insert a picture" => "Inserir uma imagem", + "Insert a slicer to filter a table" => "Inserir uma segmentação de dados para filtrar uma tabela", + "Insert an icon" => "Inserir um ícone", + "Insert…" => "Inserir…", + "Line or Area" => "Linha ou Área", + "Link…" => "Link…", + "Macros and scripts in this workbook (Alt+F8)" => "Macros e scripts nesta pasta de trabalho (Alt+F8)", + "Middle Align" => "Alinhar no Meio", + "More commands" => "Mais comandos", + "Move or Copy Sheet…" => "Mover ou Copiar Planilha…", + "New sheet (⇧F11)" => "Nova planilha (⇧F11)", + "Next sheet" => "Próxima planilha", + "Page Breaks" => "Quebras", + "Paper Size" => "Tamanho", + "Paste (⌘V)" => "Colar (⌘V)", + "Pick From Drop-down List…" => "Escolher na Lista Suspensa…", + "Pie or Doughnut" => "Pizza ou Rosca", + "Previous sheet" => "Planilha anterior", + "Print Area" => "Área de Impressão", + "Print Titles" => "Imprimir Títulos", + "Protect Workbook structure" => "Proteger estrutura da pasta de trabalho", + "Redo (⌘Y)" => "Refazer (⌘Y)", + "Save (⌘S)" => "Salvar (⌘S)", + "Scatter or Bubble" => "Dispersão ou Bolhas", + "Search and run any command" => "Pesquisar e executar qualquer comando", + "Search commands (⌘⇧Space)" => "Pesquisar comandos (⌘⇧Space)", + "Select and move ink and shapes" => "Selecionar e mover tinta e formas", + "Series…" => "Série…", + "Share: export or save a copy" => "Compartilhar: exportar ou salvar uma cópia", + "Show Formulas (⌃`)" => "Mostrar Fórmulas (⌃`)", + "Slicer" => "Segmentação de Dados", + "Sort & Filter" => "Classificar e Filtrar", + "Spelling (F7)" => "Ortografia (F7)", + "Statistic" => "Estatístico", + "Switch Row/Column" => "Alternar Linha/Coluna", + "Table (⌘T)" => "Tabela (⌘T)", + "Table Styles" => "Estilos de Tabela", + "Text to Columns" => "Texto para Colunas", + "The output range already holds data. Replace it with the report?" => "O intervalo de saída já contém dados. Substituí-los pelo relatório?", + "Top Align" => "Alinhar em Cima", + "Undo (⌘Z)" => "Desfazer (⌘Z)", + "Undo list" => "Lista de desfazer", + "Unhide Sheet…" => "Reexibir Planilha…", + "Up" => "Para Cima", + "Version, contributors and the AI models that helped" => "Versão, colaboradores e os modelos de IA que ajudaram", + "What-If Analysis" => "Teste de Hipóteses", + "Zoom In" => "Ampliar", + "Zoom Out" => "Reduzir", + "Zoom to 100%" => "Zoom em 100%", + "Zoom to Selection" => "Zoom na Seleção", + "Zoom…" => "Zoom…", + "£ English (United Kingdom)" => "£ Inglês (Reino Unido)", + "¥ Japanese" => "¥ Japonês", + "€ Euro" => "€ Euro", + "Tab|Insert" => "Inserir", + "AutoSave" => "Salvamento Automático", + "Insert\nSlicer" => "Inserir\nSegmentação", + "Pen\nBlack" => "Caneta\nPreta", + "Pen\nBlue" => "Caneta\nAzul", + "Pen\nRed" => "Caneta\nVermelha", + "Protect\nSheet" => "Proteger\nPlanilha", + "Watch\nWindow" => "Janela de\nInspeção", + "Workbook\nStatistics" => "Estatísticas da\nPasta de Trabalho", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 66ec0a2b1..5b7cd1106 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Выбрать\nобъекты", "Themes" => "Темы", "Margins" => "Поля", + "Left Margin" => "Левое поле", + "Right Margin" => "Правое поле", + "Top Margin" => "Верхнее поле", + "Bottom Margin" => "Нижнее поле", + "Header Margin" => "Поле верхнего колонтитула", + "Footer Margin" => "Поле нижнего колонтитула", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 80d91f427..fea3387a8 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -401,6 +401,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Izračunavanje radne sveske", "Enable iterative calculation" => "Omogući iterativno izračunavanje", "Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.", + "Left Margin" => "Leva margina", + "Right Margin" => "Desna margina", + "Top Margin" => "Gornja margina", + "Bottom Margin" => "Donja margina", + "Header Margin" => "Margina zaglavlja", + "Footer Margin" => "Margina podnožja", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 4b783e65f..f30fcc9b2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "选择\n对象", "Themes" => "主题", "Margins" => "页边距", + "Left Margin" => "左边距", + "Right Margin" => "右边距", + "Top Margin" => "上边距", + "Bottom Margin" => "下边距", + "Header Margin" => "页眉边距", + "Footer Margin" => "页脚边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fe9f7bf51..3e5cdf379 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -113,6 +113,8 @@ pub enum Icon { /// Light/dark appearance: a circle, its right half filled. Appearance, Sheet, + /// All Sheets navigator next to the sheet tabs: a list of rows with a bullet each. + SheetList, Book, Folder, /// Header & Footer: a page with its top / bottom band marked. @@ -419,6 +421,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.line(&[(2.0, 8.0), (8.0, 2.5), (14.0, 8.0)], c, 1.2); g.line(&[(4.0, 6.5), (4.0, 13.5), (12.0, 13.5), (12.0, 6.5)], c, 1.2); } + Icon::SheetList => { + for y in [4.0, 8.0, 12.0] { + g.rect(2.5, y - 1.0, 2.0, 2.0, Some(c), None); + g.stroke(&[(6.5, y), (13.5, y)], c); + } + } Icon::More => { for x in [4.0, 8.0, 12.0] { g.circle(x, 8.0, 1.0, Some(c), None); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 3372e1d36..032db1a2d 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -214,12 +214,16 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols }; let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift})); app.grid.ensure_visible = true; + app.grid.reveal_moving_end = m.shift; } Shortcut::Edit => app.begin_edit(None, false), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), Shortcut::DropDown => { if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) { app.grid.list_picker = Some(active); + } else if sh.has_filter_button(active) { + let at = geo.mirror_pos(crate::grid::filter_button_rect(geo, sh, active).center()); + app.grid.filter_menu = Some((active.col, active.row, at)); } } Shortcut::Zoom(step) => { @@ -245,4 +249,5 @@ fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) { d.selection.anchor = anchor; } app.grid.ensure_visible = true; + app.grid.reveal_moving_end = true; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 747a5e077..be0319972 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -378,6 +378,13 @@ impl SheetApp { } let r = self.session.run(id, params); self.after_engine(); + // The new chart is selected, as after inserting any object, so Delete removes it, not its data. + if id == "insert.chart" + && let Ok(r) = &r + && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) + { + self.selected_chart = Some(chart); + } if id == "file.exportPdf" && let Ok(r) = &r { @@ -489,11 +496,39 @@ impl SheetApp { } return self.print_now(params); } + // Menu-style Export as PDF with no file named: ask where to save, like Save As does. + // Programmatic callers use `run` and keep getting the PDF bytes back. + if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { + return self.export_pdf_with_picker(params); + } if let Err(e) = self.run(id, params) { self.alert(e); } } + /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other + /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. + fn export_pdf_with_picker(&mut self, mut params: Json) { + if !self.commit_edit(0, 0, false, false) { + return; + } + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + let Some(pick) = &self.services.pick_save else { return }; + let Some(path) = pick(&format!("{stem}.pdf")) else { return }; + // The picker may suggest a workbook extension; the export writes a PDF. + let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); + match &mut params { + Json::Object(map) => { + map.insert("path".into(), Json::String(path)); + } + other => *other = json!({"path": path}), + } + if let Err(e) = self.run("file.exportPdf", params) { + self.alert(e); + } + } + /// Shows a command error: a toast for an unavailable command, else a message box. fn alert(&mut self, e: String) { if e.contains("is not available right now") { diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 7eaf1337e..62e2cc36d 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -142,6 +142,13 @@ impl Dir<'_> { let (a0, a1) = (a.max(top), (a + size).min(self.hi)); (size > 0.0 && a1 > a0).then_some((a0, a1)) } + /// The first on-screen line in `start..=end`: the frozen ones first, then the scrolling ones from the line + /// at the pane's edge, so a long group is not scanned from its far-off start. + fn first_visible(&self, start: u32, end: u32) -> Option<(u32, (f32, f32))> { + let at = if self.rows { self.geo.row_at(self.sh, self.split) } else { self.geo.col_at(self.sh, self.split) }; + let scrolled = start.max(self.frozen).max(at); + (start..self.frozen.min(end.saturating_add(1))).chain(scrolled..=end).find_map(|l| self.visible(l).map(|v| (l, v))) + } fn point(&self, along: f32, across: f32) -> Pos2 { if self.rows { pos2(across, along) } else { pos2(along, across) } } @@ -198,7 +205,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { continue; } // The bracket, over the part of the detail that is on screen. - let first = (grp.start..=grp.end).take(4096).find_map(|l| dir.visible(l).map(|v| (l, v))); + let first = dir.first_visible(grp.start, grp.end); let Some((first_line, (a0, _))) = first else { continue }; let a1 = match button { Some(mid) => mid - side / 2.0, @@ -298,6 +305,28 @@ mod tests { assert_eq!(g.head_left(), g.rect.left()); } + #[test] + fn long_groups_keep_their_bracket_past_4096_lines() { + for rows in [true, false] { + let mut sh = Sheet::new("S"); + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + for i in 0..5000u32 { + map.entry(i).or_default().outline = 1; + } + for line in [0u32, 4095, 4096, 4200, 4970] { + let scroll = if rows { vec2(0.0, sh.row_top(line) as f32) } else { vec2(sh.col_left(line) as f32, 0.0) }; + let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll); + let l = layout(&g, &sh); + assert_eq!(l.brackets.len(), 1, "rows={rows} line={line}"); + assert_eq!(l.brackets[0].tick.is_some(), line == 0, "rows={rows} line={line}"); + } + // Past the group's summary line nothing is left to bracket. + let scroll = if rows { vec2(0.0, sh.row_top(5100) as f32) } else { vec2(sh.col_left(5100) as f32, 0.0) }; + let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll); + assert!(layout(&g, &sh).brackets.is_empty(), "rows={rows}"); + } + } + #[test] fn bands_push_the_headers_and_cells_in() { let sh = sheet(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 3100cfeda..aaa9bec32 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -17,7 +17,10 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { } let Some(pane) = app.grid.pane.clone().or_else(|| app.session.comments_pane.then(|| "comments".to_string())) else { return }; let t = Tokens::get(ui.ctx()); - egui::Panel::right("task_pane").default_size(300.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { + // Capped at half the window so a pane can never cover the sheet, whatever its content asks for. + let max = (ui.available_width() / 2.0).max(300.0); + let panel = egui::Panel::right("task_pane").default_size(300.0).max_size(max).resizable(true); + panel.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { ui.horizontal(|ui| { let title = match pane.as_str() { "comments" => "Comments", @@ -62,7 +65,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { .stroke(egui::Stroke::new(1.0, Color32::from_gray(215))) .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.link(egui::RichText::new(&cell).strong()).clicked() { let _ = app.run("selection.set", json!({"cell": cell})); app.grid.ensure_visible = true; @@ -78,18 +81,23 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { } let key = egui::Id::new(("reply", &cell)); let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default(); + // Buttons first, right to left, so the reply field gets exactly the width they leave: + // a field sized by a guess wider than the row pushes the resizable pane wider every frame. ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut draft).hint_text("Reply…").desired_width(ui.available_width() - 120.0)); - if (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) || ui.small_button("Send").clicked()) && !draft.is_empty() { - app.run_or_alert("review.replyComment", json!({"cell": cell, "text": draft})); - draft.clear(); - } - if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { - app.run_or_alert("review.resolveComment", json!({"cell": cell, "resolved": !c.resolved})); - } - if ui.small_button("Delete").clicked() { - app.run_or_alert("review.deleteComment", json!({"cell": cell})); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Delete").clicked() { + app.run_or_alert("review.deleteComment", json!({"cell": cell})); + } + if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + app.run_or_alert("review.resolveComment", json!({"cell": cell, "resolved": !c.resolved})); + } + let send = ui.small_button("Send").clicked(); + let r = ui.add(egui::TextEdit::singleline(&mut draft).hint_text("Reply…").desired_width(ui.available_width())); + if (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) || send) && !draft.is_empty() { + app.run_or_alert("review.replyComment", json!({"cell": cell, "text": draft})); + draft.clear(); + } + }); }); ui.ctx().data_mut(|m| m.insert_temp(key, draft)); }); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 110193a64..621a97903 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -284,6 +284,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo commit(app); } let Some(pg) = geo.paper.clone() else { return }; + ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet); if std::mem::take(&mut app.grid.hf_start) { begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); } @@ -510,6 +511,14 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { app.grid.hf_edit = Some(e); } +/// Screen rectangles of the horizontal and vertical rulers. Right-to-left sheets mirror both: +/// the vertical ruler sits on the right, beside the row headings. +fn ruler_rects(outer: Rect, geo: &Geo) -> (Rect, Rect) { + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); + let (x0, x1) = if geo.rtl { (outer.right() - RULER, outer.right()) } else { (outer.left(), outer.left() + RULER) }; + (hr, Rect::from_min_max(pos2(x0, geo.cells.top()), pos2(x1, geo.cells.bottom()))) +} + /// Horizontal and vertical rulers (`outer` is the grid area including the rulers). pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) { let Some(pg) = geo.paper.clone() else { return }; @@ -523,17 +532,20 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); + // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the + // right margin leftwards). + let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); - p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); + p.rect_filled(Rect::from_min_max(pos2(vr.left(), outer.top()), pos2(vr.right(), outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| { let band = |a: f32, b: f32| { if horizontal { - Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0)) + let (a, b) = (mx(a), mx(b)); + Rect::from_min_max(pos2(a.min(b), area.top() + 2.0), pos2(a.max(b), area.bottom() - 2.0)) } else { Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b)) } @@ -550,6 +562,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, if pos > m1 + 0.5 { break; } + let pos = if horizontal { mx(pos) } else { pos }; let major = s.is_multiple_of(minor as usize); let len = if major { 6.0 @@ -569,6 +582,8 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, } let (a, b) = if horizontal { (pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0)) + } else if geo.rtl { + (pos2(area.left() + 2.0, pos), pos2(area.left() + 2.0 + len, pos)) } else { (pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos)) }; @@ -586,14 +601,246 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let a = screen(geo, 0.0, y).y; let b = screen(geo, 0.0, y + h).y; span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr); + // Header and footer margins: a small marker at each distance, grabbable like the margins. + let pc = p.with_clip_rect(vr); + for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { + let (x, d) = if geo.rtl { (vr.right() - 2.0, -6.0) } else { (vr.left() + 2.0, 6.0) }; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + d, at)], t.header_text, Stroke::NONE)); + } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( - [pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())], + if geo.rtl { + [pos2(vr.left() + 0.5, outer.top()), pos2(vr.left() + 0.5, outer.bottom())] + } else { + [pos2(vr.right() - 0.5, outer.top()), pos2(vr.right() - 0.5, outer.bottom())] + }, Stroke::new(1.0, t.header_line), ); } +/// A page edge that can be dragged on a ruler. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + Left, + Right, + Top, + Bottom, + /// Distance of the header from the top of the page. + Header, + /// Distance of the footer from the bottom of the page. + Footer, +} + +impl Edge { + /// Index into `PrintSettings::margins` / `PageGrid::margins`. + fn index(self) -> usize { + match self { + Edge::Left => 0, + Edge::Right => 1, + Edge::Top => 2, + Edge::Bottom => 3, + Edge::Header => 4, + Edge::Footer => 5, + } + } + /// The `pageLayout.pageSetup` margin key. + fn key(self) -> &'static str { + ["left", "right", "top", "bottom", "header", "footer"][self.index()] + } + fn label(self) -> &'static str { + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin", "Header Margin", "Footer Margin"][self.index()] + } + fn horizontal(self) -> bool { + matches!(self, Edge::Left | Edge::Right) + } +} + +/// A margin being dragged on a ruler: the page it started on and the previewed value (inches). +#[derive(Clone, Debug)] +pub struct RulerDrag { + pub sheet: usize, + pub edge: Edge, + /// Page column (horizontal ruler) or row (vertical ruler) the drag started on. + pub page: usize, + pub start_inches: f32, + pub inches: f32, + /// The sheet re-paginated with `inches` (page breaks and fit-to-page scale as a commit would + /// give them), rebuilt when the value changes. + preview: Option<(f32, Arc)>, +} + +/// Content that must stay between the margins, in points. +const MIN_CONTENT_PT: f64 = 36.0; +/// Screen distance within which a ruler margin edge is grabbed. +const EDGE_GRAB: f32 = 4.0; + +/// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's +/// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two +/// margins of its axis. `None` for NaN/infinite input or a page without a size. +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 6]) -> Option { + let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); + if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { + return None; + } + let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; + let raw = match edge { + Edge::Left | Edge::Top | Edge::Header => rel_pt, + Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, + }; + let mut max = (page - other - MIN_CONTENT_PT).max(0.0); + let mut min = 0.0_f64; + let pt_of = |i: usize| f64::from(margins_in.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).max(0.0) * 72.0; + match edge { + // The header/footer sits within its page margin (conservative: never past the margin edge). + Edge::Header => max = max.min(pt_of(2)), + Edge::Footer => max = max.min(pt_of(3)), + // ... so the top/bottom margin can't be dragged inside it. + Edge::Top => min = pt_of(4), + Edge::Bottom => min = pt_of(5), + _ => {} + } + let pt = raw.clamp(min.min(max), max); + Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) +} + +/// The grid while a margin is dragged: the sheet is re-paginated with the previewed margin, so +/// the content area, page breaks and the fit-to-page scale are those a commit would give. +pub(crate) fn with_dragged_margin(app: &mut SheetApp, wb: &Workbook, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_mut().filter(|d| d.sheet == si) else { return pg }; + if let Some((v, g)) = &d.preview + && (*v - d.inches).abs() < f32::EPSILON + { + return g.clone(); + } + let mut w = wb.clone(); + let Some(sheet) = w.sheets.get_mut(si) else { return pg }; + let Some(slot) = Arc::make_mut(sheet).print.margins.get_mut(d.edge.index()) else { return pg }; + *slot = d.inches; + let rows = pg.row_bands.last().map_or(0, |b| b.1); + let cols = pg.col_bands.last().map_or(0, |b| b.1); + let Some(g) = PageGrid::build(&w, si, rows, cols) else { return pg }; + let g = Arc::new(g); + d.preview = Some((d.inches, g.clone())); + g +} + +/// Points per layout unit of the grid (paper size over the layout page size). +fn pt_per_unit(pg: &PageGrid) -> Option { + let k = f64::from(pg.paper_pt.0) / pg.page_w; + (k.is_finite() && k > 0.0).then_some(k) +} + +/// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. +fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { + let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); + let [ml, mr, mt, mb, mh, mf] = pg.margins; + let mut best: Option<(f32, Edge, usize)> = None; + let mut consider = |d: f32, e: Edge, page: usize| { + if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { + best = Some((d, e, page)); + } + }; + if h { + for j in j0..=j1.min(j0.saturating_add(200)) { + let (x, _, w, _) = pg.page_rect(0, j); + consider((screen(geo, x + ml, 0.0).x - pos.x).abs(), Edge::Left, j); + consider((screen(geo, x + w - mr, 0.0).x - pos.x).abs(), Edge::Right, j); + } + } else { + for i in i0..=i1.min(i0.saturating_add(200)) { + let (_, y, _, hh) = pg.page_rect(i, 0); + consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); + consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + consider((screen(geo, 0.0, y + mh).y - pos.y).abs(), Edge::Header, i); + consider((screen(geo, 0.0, y + hh - mf).y - pos.y).abs(), Edge::Footer, i); + } + } + best.map(|(_, e, p)| (e, p)) +} + +/// Dragging the margin edges on the rulers sets the page margins: a resize cursor and a tooltip +/// on hover, a live preview while dragging, one `pageLayout.pageSetup` (one undo step) on +/// release, Esc cancels. +fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid, sh: &Sheet, si: usize) { + let Some(outer) = app.grid.rect else { return }; + if geo.rect.top() - outer.top() < RULER - 0.5 { + app.grid.ruler_drag = None; + return; + } + // Leaving the sheet or the view drops the drag. + if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { + app.grid.ruler_drag = None; + } + let (hr, vr) = ruler_rects(outer, geo); + // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); + let margins_in = |sh: &Sheet| -> [f32; 6] { + let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); + [m(0), m(1), m(2), m(3), m(4), m(5)] + }; + let current = margins_in(sh); + let metric = metric(app); + let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| { + let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") }; + let text = format!("{}: {value}", app.ui.language.tr(edge.label())); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(text); + }); + }; + let cursor = |ui: &egui::Ui, edge: Edge| { + ui.ctx().set_cursor_icon(if edge.horizontal() { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + }; + if let Some(d) = app.grid.ruler_drag.clone() { + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + app.grid.ruler_drag = None; + return; + } + cursor(ui, d.edge); + let down = ui.input(|i| i.pointer.primary_down()); + let pos = ui.input(|i| i.pointer.latest_pos()).map(|p| geo.mirror_pos(p)); + let mut cur = d.clone(); + if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { + let rel = if d.edge.horizontal() { + let page_x = pg.page_x(d.page); + f64::from(geo.scroll.x) + f64::from((pos.x - geo.cells.left()) / geo.z) - page_x + } else { + let page_y = pg.page_y(d.page); + f64::from(geo.scroll.y) + f64::from((pos.y - geo.cells.top()) / geo.z) - page_y + }; + if let Some(v) = margin_from_drag(d.edge, rel * k, pg.paper_pt, current) { + cur.inches = v; + } + } + if down { + tip(ui, app, cur.edge, cur.inches); + app.grid.ruler_drag = Some(cur); + ui.ctx().request_repaint(); + } else { + app.grid.ruler_drag = None; + if (cur.inches - cur.start_inches).abs() > f32::EPSILON { + app.run_or_alert("pageLayout.pageSetup", json!({"sheet": si, "margins": {cur.edge.key(): cur.inches}})); + } + } + return; + } + for (resp, h) in [(&hresp, true), (&vresp, false)] { + let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let origin = geo.mirror_pos(origin); + let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; + let value = current.get(edge.index()).copied().unwrap_or(0.5); + if resp.drag_started() { + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value, preview: None }); + cursor(ui, edge); + } else if resp.hovered() { + cursor(ui, edge); + tip(ui, app, edge, value); + } + } +} + /// The Header & Footer contextual ribbon tab. pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { use crate::widgets::big_button; @@ -667,3 +914,47 @@ fn sep(ui: &mut egui::Ui) { let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover()); ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); } + +#[cfg(test)] +mod tests { + use super::*; + + const A4: (f32, f32) = (595.0, 842.0); + + #[test] + fn drag_maps_to_margins_and_clamps_inside_the_page() { + let m = [0.7, 0.7, 0.75, 0.75, 0.3, 0.3]; + // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. + assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); + assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); + // Dragged off the page: never negative (top/bottom keep the header/footer distance). + assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.3)); + // Dragged across the opposite margin: stops a minimum content width short of it. + let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); + assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); + // Hostile input is ignored, not clamped to something arbitrary. + assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7, 0.3, 0.3]), None); + // Oversized opposite margin leaves nothing to drag into. + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0, 0.3, 0.3]), Some(0.0)); + // Header/footer distances are measured from the top / bottom edge. + assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); + assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); + // Header stays within the top margin (0.75 in), footer within the bottom margin. + assert_eq!(margin_from_drag(Edge::Header, 500.0, A4, m), Some(0.75)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 500.0, A4, m), Some(0.75)); + // The top/bottom margin can't be dragged inside the header/footer distance (0.3 in). + assert_eq!(margin_from_drag(Edge::Top, 2.0, A4, m), Some(0.3)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) - 2.0, A4, m), Some(0.3)); + // Hostile header/footer values (NaN, negative, huge) never panic or escape the page. + assert_eq!(margin_from_drag(Edge::Header, 10.0, A4, [0.7, 0.7, 0.75, 0.75, f32::NAN, 0.3]), Some(0.14)); + assert_eq!(margin_from_drag(Edge::Top, 300.0, A4, [0.7, 0.7, 0.75, 0.75, -5.0, f32::INFINITY]), Some(4.17)); + assert_eq!(margin_from_drag(Edge::Header, f64::NAN, A4, m), None); + assert!(margin_from_drag(Edge::Footer, 10.0, A4, [0.0, 0.0, 1e9, 1e9, 0.3, 1e9]).is_some_and(|v| (0.0..=11.7).contains(&v))); + } +} diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index 14d69db96..065702622 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -64,13 +64,26 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }) .unwrap_or_default() }; + // Σ Values (two or more value fields) sits in Rows or Columns; it moves only between them. + const VALUES_FIELD: &str = "Σ Values"; + let values_key = if info["valuesOnRows"].as_bool().unwrap_or(false) { "rows" } else { "columns" }; + let values_at = info["valuesPosition"].as_u64().map(|p| p as usize); + let area_with_values = |key: &str| -> Vec { + let mut v = area(key); + if key == values_key && area("values").len() > 1 { + let at = values_at.unwrap_or(v.len()).min(v.len()); + v.insert(at, VALUES_FIELD.to_string()); + } + v + }; ui.columns(2, |cols| { for (i, (key, label)) in [("filters", "Filters"), ("columns", "Columns"), ("rows", "Rows"), ("values", "Values")].iter().enumerate() { let ui = &mut cols[i % 2]; ui.label(egui::RichText::new(*label).strong()); egui::Frame::NONE.fill(t.window).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { ui.set_min_size(vec2(ui.available_width(), 60.0)); - for f in area(key) { + for f in area_with_values(key) { + let is_values = f == VALUES_FIELD; let b = ui.add( egui::Button::new(egui::RichText::new(format!("{f} ▾")).font(theme::ui_font(12.0))) .fill(Color32::WHITE) @@ -83,10 +96,13 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ("filters", "Move to Report Filter"), ("values", "Move to Values"), ] { - if to != *key && ui.button(l).clicked() { + if to != *key && (!is_values || matches!(to, "rows" | "columns")) && ui.button(l).clicked() { app.run_or_alert("pivot.moveField", json!({"pivot": name, "field": f, "from": key, "to": to})); } } + if is_values { + return; + } if *key == "values" { ui.separator(); for (func, l) in [("sum", "Sum"), ("count", "Count"), ("average", "Average"), ("max", "Max"), ("min", "Min")] { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6ce895e24..576e0040d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -131,6 +131,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); egui::Popup::menu(&share).show(|ui| { + if ui.button("Export as PDF…").clicked() { + app.run_or_alert("file.exportPdf", json!({})); + } for (label, fmt) in [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] { @@ -373,7 +376,7 @@ struct ComboState { } /// What the user did with an [`editable_combo`]. -enum ComboResult { +pub(crate) enum ComboResult { None, /// A row was applied: clicked or Enter on the highlighted row. Picked(String), @@ -384,7 +387,7 @@ enum ComboResult { /// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. /// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) /// or clicked, the popup opens, focuses the filter field, and selects its text. -fn editable_combo( +pub(crate) fn editable_combo( ui: &mut Ui, anchor: &egui::Response, salt: &'static str, @@ -2033,9 +2036,6 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("comment", json!({"threaded": true})); } - if big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false).clicked() { - app.grid.pane = Some("comments".into()); - } let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); control_keytip(app, ui, &__k, "review.deleteComment"); if __k.clicked() { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575c6..f9669d627 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -1,7 +1,9 @@ //! Sheet tabs and the status bar. +use std::sync::Arc; + use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use gridcraft_engine::model::Visibility; +use gridcraft_engine::model::{Sheet, Visibility}; use serde_json::json; use crate::SheetApp; @@ -14,6 +16,25 @@ fn rename_needs_focus(has_focus: bool, lost_focus: bool) -> bool { !has_focus && !lost_focus } +/// Rows of the All Sheets navigator as `(workbook index, name)`. Hidden sheets stay out, like on the tab strip: +/// Unhide is the way to reach them. +fn navigator_items(sheets: &[Arc]) -> Vec<(String, String)> { + sheets.iter().enumerate().filter(|(_, s)| s.visibility == Visibility::Visible).map(|(i, s)| (i.to_string(), s.name.clone())).collect() +} + +/// Switch to sheet `i` from the tab strip or the navigator. In point mode the formula being typed keeps going on the +/// other sheet; otherwise the edit is committed first. +fn activate_sheet(app: &mut SheetApp, i: usize) { + if app.editor.as_ref().is_some_and(|e| e.can_point()) { + let _ = app.session.run("sheet.activate", json!({"sheet": i})); + } else { + if app.editor.is_some() { + app.commit_edit(0, 0, false, false); + } + app.run_or_alert("sheet.activate", json!({"sheet": i})); + } +} + pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { @@ -28,6 +49,16 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, "Next sheet", vec2(24.0, 24.0)).clicked() { app.run_or_alert("sheet.next", json!({})); } + let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, "All sheets", vec2(24.0, 24.0)); + all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "All sheets")); + let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); + let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); + if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) + && let Ok(i) = i.parse::() + { + activate_sheet(app, i); + } ui.add_space(8.0); let Some(d) = app.session.active() else { return }; let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> = @@ -40,6 +71,15 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // Keep worksheet tabs in their own horizontally scrollable // viewport, leaving room for the New Sheet button and grid scrollbar. let tabs_width = (ui.available_width() - 180.0).max(100.0); + // Reveal the selected sheet after navigation or layout changes, but + // allow browsing other tabs without snapping back on every frame. + let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_id = ui.id().with("worksheet_tabs_reveal"); + let reveal_active = ui.ctx().data_mut(|data| { + let changed = data.get_temp::(reveal_id) != Some(reveal_key); + data.insert_temp(reveal_id, reveal_key); + changed + }); ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { // Translate vertical wheel movement into horizontal // worksheet-tab scrolling. @@ -68,6 +108,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; let w = (tw + 32.0).max(72.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + if active && reveal_active { + resp.scroll_to_me(None); + } let r = r.shrink2(vec2(0.0, 1.0)); if active { ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); @@ -106,15 +149,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } if resp.clicked() { - if app.editor.as_ref().is_some_and(|e| e.can_point()) { - // Point mode across sheets: switch without committing. - let _ = app.session.run("sheet.activate", json!({"sheet": i})); - } else { - if app.editor.is_some() { - app.commit_edit(0, 0, false, false); - } - app.run_or_alert("sheet.activate", json!({"sheet": i})); - } + activate_sheet(app, i); } if resp.double_clicked() { app.grid.renaming_tab = Some(i); @@ -332,7 +367,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { #[cfg(test)] mod tests { - use super::rename_needs_focus; + use super::{navigator_items, rename_needs_focus}; + use gridcraft_engine::model::{Sheet, Visibility}; + use std::sync::Arc; #[test] fn rename_field_takes_focus_once_and_lets_go_on_commit() { @@ -340,4 +377,14 @@ mod tests { assert!(!rename_needs_focus(true, false), "already focused: do not ask again"); assert!(!rename_needs_focus(false, true), "Enter or click-away: must not take focus back"); } + + #[test] + fn navigator_lists_visible_sheets_by_workbook_index() { + let mut hidden = Sheet::new("Hidden"); + hidden.visibility = Visibility::Hidden; + let mut very_hidden = Sheet::new("VeryHidden"); + very_hidden.visibility = Visibility::VeryHidden; + let sheets = [Sheet::new("Data"), hidden, very_hidden, Sheet::new("Summary")].map(Arc::new); + assert_eq!(navigator_items(&sheets), vec![("0".to_string(), "Data".to_string()), ("3".to_string(), "Summary".to_string())]); + } } diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index c386e76f3..08f83b53c 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -120,6 +120,27 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() { assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays"); } +#[test] +fn enter_pastes_the_copied_cells_and_ends_copy_mode() { + // #845: with the marching ants showing, Enter pastes into the selection and cancels the copy. + let mut h = harness(Services::default()); + h.input_mut().events.push(Event::Copy); + h.step(); + h.state_mut().run("selection.set", json!({"cell": "D5"})).unwrap(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + let value = |r, c| h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(gridcraft_engine::core::CellRef::new(r, c)))); + assert_eq!(value(4, 3), Some(gridcraft_engine::core::Value::text("Name"))); + assert_eq!(value(5, 4), Some(gridcraft_engine::core::Value::number(42.0))); + assert!(h.state().session.clipboard.is_none(), "the marching ants are gone"); + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D5".to_string()), "Enter does not also move down"); + + // Copy mode is over: the next Enter just moves. + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D6".to_string())); +} + #[test] fn clicking_the_ribbon_paste_button_or_its_menu_does_not_freeze() { // #273: a click on Paste deadlocked the UI thread (a click-state query inside the egui context diff --git a/crates/ui-egui/tests/comments_pane.rs b/crates/ui-egui/tests/comments_pane.rs new file mode 100644 index 000000000..fe615084b --- /dev/null +++ b/crates/ui-egui/tests/comments_pane.rs @@ -0,0 +1,105 @@ +//! The Comments pane: one Show Comments button, and a pane that keeps its width (#710). + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const WIDTH: f32 = 1200.0; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(WIDTH, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn grid_right(h: &Harness) -> f32 { + h.state().grid.rect.unwrap().right() +} + +/// A comment whose author line and reply row are wide enough to push the pane if they don't wrap. +fn open_pane_on_long_comment(h: &mut Harness) { + let text = "A long comment that should wrap inside the pane instead of pushing it wider. ".repeat(6); + let author = "Someone With A Rather Long Display Name, Finance Department, Head Office"; + h.state_mut().run("review.newComment", json!({"cell": "B2", "text": text, "author": author})).unwrap(); + h.state_mut().run("review.showComments", json!({"on": true})).unwrap(); + h.run_steps(3); +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([Event::PointerMoved(from), press(from, true)]); + h.step(); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * t)); + h.step(); + } + h.input_mut().events.push(press(to, false)); + h.run_steps(3); +} + +#[test] +fn the_pane_does_not_grow_on_its_own_and_keeps_a_manual_resize() { + let mut h = harness(); + // Between the grid and the right edge of the window, before any pane is open. + let gutter = WIDTH - grid_right(&h); + open_pane_on_long_comment(&mut h); + let opened = grid_right(&h); + assert!(WIDTH - opened < WIDTH / 2.0, "the pane opens at its default width, got {}", WIDTH - opened); + h.run_steps(60); + assert_eq!(grid_right(&h), opened, "the pane must not widen itself frame after frame"); + + // Drag the pane's left edge 150px to the left: the new width stays. + let edge = pos2(opened + gutter, 450.0); + drag(&mut h, edge, edge - vec2(150.0, 0.0)); + let resized = grid_right(&h); + // The grab point is a few pixels off the exact edge, so allow some slack on the 150px. + assert!(opened - resized > 120.0, "the drag widens the pane, grid edge {opened} -> {resized}"); + h.run_steps(60); + assert_eq!(grid_right(&h), resized, "a manual resize must hold"); + + // Dragged almost to the window's left edge, the pane stops at half the window. + let edge = pos2(resized + gutter, 450.0); + drag(&mut h, edge, pos2(60.0, 450.0)); + assert!(grid_right(&h) > WIDTH / 2.0 - gutter - 30.0, "the pane is capped at half the window, grid edge {}", grid_right(&h)); +} + +/// How many separate ribbon buttons show `tip` when hovered, scanning the big-button row. +fn buttons_with_tip(h: &mut Harness, tip: &str) -> usize { + h.ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0); + let (mut count, mut inside) = (0, false); + let mut x = 2.0; + while x < WIDTH { + h.input_mut().events.push(Event::PointerMoved(pos2(x, 120.0))); + h.run_steps(6); + let shown = h.query_all_by_value(tip).next().is_some(); + if shown && !inside { + count += 1; + } + inside = shown; + x += 8.0; + } + count +} + +#[test] +fn review_has_a_single_show_comments_button() { + let mut h = harness(); + h.state_mut().ui.ribbon_tab = "Review".into(); + h.run_steps(2); + // The scan has to find a known single button, or a count of one below would prove nothing. + assert_eq!(buttons_with_tip(&mut h, "New Comment"), 1); + assert_eq!(buttons_with_tip(&mut h, "Comments pane"), 1); +} diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index c6ef2b212..691eef367 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -171,3 +171,49 @@ fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() { assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file"); let _ = std::fs::remove_dir_all(&dir); } + +fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { + let asked = Arc::new(Mutex::new(Vec::::new())); + let (capture, target) = (asked.clone(), dir.to_path_buf()); + let h = harness(Services { + pick_save: Some(Box::new(move |suggested| { + capture.lock().unwrap().push(suggested.to_string()); + Some(target.join("Report.xlsx").to_string_lossy().into_owned()) + })), + ..Default::default() + }); + (asked, h) +} + +#[test] +fn the_palette_export_as_pdf_asks_where_to_save() { + let dir = std::env::temp_dir().join(format!("gridcraft-export-pdf-palette-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (asked, mut h) = pdf_picker_harness(&dir); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + // The palette and menus go through run_or_alert. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name"); + assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"), "the PDF is written, whatever extension the picker returned"); + assert!(h.state().message.is_none()); + let d = h.state().session.active().unwrap(); + assert!(d.path.is_none(), "a PDF is a copy, not the workbook's file"); + // Other params survive the round trip through the picker. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"range": "nope"})); + assert!(h.state().message.is_some(), "a bad range is reported, not swallowed"); + // A path in the params skips the picker. + let before = asked.lock().unwrap().len(); + let named = dir.join("Named.pdf"); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"path": named.to_string_lossy()})); + assert_eq!(asked.lock().unwrap().len(), before); + assert!(named.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_cancelled_export_pdf_picker_writes_nothing() { + let mut h = harness(Services { pick_save: Some(Box::new(|_| None)), ..Default::default() }); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(h.state().message.is_none()); + assert!(h.state_mut().session.take_ui_requests().is_empty()); +} diff --git a/crates/ui-egui/tests/fill_handle_double_click.rs b/crates/ui-egui/tests/fill_handle_double_click.rs new file mode 100644 index 000000000..88b87376f --- /dev/null +++ b/crates/ui-egui/tests/fill_handle_double_click.rs @@ -0,0 +1,59 @@ +//! Double-clicking the fill handle fills down to the neighbouring column's last used row, +//! even with the pointer up for a frame between the two clicks. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap(); + session.execute("range.setValues", json!({"range": "B1", "values": [[10], [20], [30]]})).unwrap(); + session.execute("selection.set", json!({"range": "B1:B3"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn handle(h: &egui_kittest::Harness<'_, SheetApp>) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::new(2, 1)).right_bottom() - vec2(1.0, 1.0) +} + +fn button(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn number(h: &egui_kittest::Harness<'_, SheetApp>, row: u32) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(row, 1)) +} + +#[test] +fn double_click_on_the_fill_handle_fills_down_with_a_frame_between_the_clicks() { + let mut h = harness(); + let pos = handle(&h); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.step(); // One idle frame between the clicks, well inside the double-click interval. + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(2); + + assert_eq!([number(&h, 3), number(&h, 4), number(&h, 5)], [Value::Number(40.0), Value::Number(50.0), Value::Number(60.0)]); + assert_eq!(h.state().session.active().unwrap().selection.a1(), "B1:B6"); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count(), 1); +} diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index fe21218b3..855869377 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -1,7 +1,7 @@ //! Language selection must affect UI entry, never the canonical engine/file boundary. use gridcraft_engine::{ Session, - core::{CellRef, Value}, + core::{CellError, CellRef, Value}, }; use gridcraft_ui_egui::{SheetApp, UiState, i18n::Language}; use serde_json::json; @@ -201,3 +201,21 @@ fn czech_interface_retains_its_canonical_formula_fallback() { let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::Cs); } + +#[test] +fn user_defined_prefix_is_hidden_while_editing_and_kept_on_commit() { + // Excel shows `_xludf.EOMONTH()` as `=EOMONTH()`; the cell still holds the user-defined call + // (#726), and committing the edit unchanged must not turn it into the built-in. + let mut app = app(); + app.run("cell.set", json!({"cell":"A1","input":"=_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"})).unwrap(); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=EOMONTH()&CONCAT(\"_xludf.x\")"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(formula(&app), "_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Error(CellError::Name)); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=EOMONTH()&CONCAT(\"_xludf.x\")"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(formula(&app), "_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"); +} diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/formula_point_headers.rs new file mode 100644 index 000000000..4313fb9f6 --- /dev/null +++ b/crates/ui-egui/tests/formula_point_headers.rs @@ -0,0 +1,75 @@ +//! Point mode on the headers: while a formula waits for a reference, a header click inserts a +//! whole-column or whole-row reference, driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": "F8"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + h +} + +fn header_center(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + if rows { + pos2(geo.rect.left() + geo.header_w * 0.5, geo.y(sheet, index) + sheet.row_height(index) * geo.z * 0.5) + } else { + pos2(geo.x(sheet, index) + sheet.col_width(index) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5) + } +} + +fn click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + for pressed in [true, false] { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); + } + h.run_steps(2); +} + +#[test] +fn column_header_click_inserts_a_column_reference() { + let mut h = harness(); + let at = header_center(&h, false, 2); + click(&mut h, at); + let app = h.state(); + assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(C:C")); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn row_header_click_inserts_a_row_reference() { + let mut h = harness(); + let at = header_center(&h, true, 3); + click(&mut h, at); + let app = h.state(); + assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(4:4")); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn a_second_header_click_replaces_the_pointed_reference() { + let mut h = harness(); + let first = header_center(&h, false, 2); + click(&mut h, first); + let second = header_center(&h, false, 4); + click(&mut h, second); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(E:E")); +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index fc9ee52b4..b7b66bdc1 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -191,6 +191,25 @@ fn arrows_escape_and_undo() { assert_eq!(value(&h, "B2"), Value::Empty); } +#[test] +fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() { + let mut s = blank(); + s.execute("selection.set", json!({"cell": "A11"})).unwrap(); + let mut h = harness(s); + text(&mut h, "=SUM("); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone()); + assert_eq!(edited(&h).as_deref(), Some("=SUM(A10")); + // Shift is held down, as on a real keyboard: the backend reports it before the arrow keys. + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + for expected in ["=SUM(A9:A10", "=SUM(A8:A10", "=SUM(A7:A10"] { + key(&mut h, Key::ArrowUp, Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some(expected)); + } + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor"); +} + #[test] fn shortcuts_format_and_extend() { let mut s = blank(); @@ -224,6 +243,25 @@ fn delete_key_removes_the_selected_chart() { assert_eq!(value(&h, "A1"), Value::Number(1.0)); } +#[test] +fn inserted_chart_is_selected_so_delete_removes_it() { + // #668, #256: after Insert › Chart the selection stayed on the source cells, so Delete + // cleared the data and left the chart behind. + let mut session = blank(); + session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = harness(session); + + let id = h.state_mut().run("insert.chart", json!({"type": "column"})).unwrap()["chart"].as_u64().unwrap() as u32; + h.run_steps(2); + assert_eq!(h.state().selected_chart, Some(id), "the new chart is selected"); + + key(&mut h, Key::Delete, Modifiers::NONE); + + assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty()); + assert_eq!(value(&h, "A1"), Value::Number(1.0)); +} + #[test] fn renders_every_ribbon_tab_without_panicking() { let mut s = Session::new(); @@ -279,6 +317,25 @@ fn language_switch_translates_the_ribbon_and_persists() { assert_eq!(h.state().ui.language, Language::En); } +#[test] +fn format_cells_draws_its_labels_in_the_interface_language() { + let mut h = harness(blank()); + h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); + assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); + h.get_by_label("Wissenschaftlich").click(); + h.run_steps(2); + let style = &h.state().dialog.as_ref().unwrap().values["style"]; + assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); + h.state_mut().dialog = None; + h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Scientific").is_some(), "English is unchanged"); +} + #[test] fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() { // #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index 82d3f2fa8..c0a1307b3 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -149,3 +149,68 @@ fn space_shortcuts_per_platform() { // Cmd+Space is Spotlight on macOS. assert_eq!(space(mac_cmd, true), None); } + +#[test] +fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { + for rtl in [false, true] { + for cell in ["A1", "B1"] { + let mut session = book(cell); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + let mut h = harness(session); + let before = h.state().session.active().unwrap().wb.clone(); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + let (column, _row, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu"); + let d = h.state().session.active().unwrap(); + let active = CellRef::parse(cell).unwrap(); + assert_eq!(column, active.col); + assert_eq!(d.selection.active, active); + assert_eq!(d.wb, before); + let sh = d.wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sh, h.state().grid.rect.unwrap(), h.state().view().scroll); + assert_eq!(at, geo.mirror_pos(gridcraft_ui_egui::grid::filter_button_rect(&geo, sh, active).center())); + // Opening focuses the existing search box; typing must not edit the header. + h.input_mut().events.push(Event::Text("1".into())); + h.run_steps(2); + assert_eq!(h.ctx.data(|data| data.get_temp::(egui::Id::new(("filter_search", column)))).as_deref(), Some("1")); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.active().unwrap().wb, before); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().grid.filter_menu.is_none()); + } + } +} + +#[test] +fn alt_down_keeps_validation_priority_and_plain_down_navigation() { + let mut session = book("A1"); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"First,Second\""})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.list_picker, Some(CellRef::parse("A1").unwrap())); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert!(h.state().grid.filter_menu.is_none(), "a data cell has no filter button"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); +} + +#[test] +fn alt_down_only_opens_enabled_table_filter_buttons() { + for enabled in [false, true] { + let mut session = book("B1"); + session.execute("insert.table", json!({"range": "A1:B3"})).unwrap(); + if !enabled { + session.execute("table.headerRow", json!({"on": false})).unwrap(); + } + session.execute("selection.set", json!({"cell": "B1"})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.filter_menu.map(|(column, _, _)| column), enabled.then_some(1)); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); + } +} diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index f8850b3e2..b2e80d0d9 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -12,8 +12,20 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn fixture() -> Harness { + fixture_with(false, None) +} + +/// `rtl` mirrors the sheet; `setup` is extra `pageLayout.pageSetup` params. +fn fixture_with(rtl: bool, setup: Option) -> Harness { let mut session = Session::new(); session.new_workbook(); + if rtl { + session.execute("view.rightToLeft", json!({"on": true})).unwrap(); + } + if let Some(p) = setup { + session.execute("cell.set", json!({"cell": "Z1", "input": "wide"})).unwrap(); + session.execute("pageLayout.pageSetup", p).unwrap(); + } for r in 1..=60 { session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap(); } @@ -162,3 +174,201 @@ fn insert_header_and_footer_switches_to_page_layout_view() { let e = h.state().grid.hf_edit.as_ref().expect("the header is being edited"); assert_eq!((e.footer, e.section, e.texts[0].as_str()), (false, 1, "Left")); } + +fn margins(h: &Harness) -> [f32; 6] { + h.state().session.doc().unwrap().wb.active().unwrap().print.margins +} + +fn mouse(h: &mut Harness, ev: Event) { + h.input_mut().events.push(ev); + h.step(); +} + +#[test] +fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_cancels() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start_margins = margins(&h); + let ruler_y = h.state().grid.rect.unwrap().top() + 9.0; + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + // One inch further in = 96 layout pixels at 100% print scale; use the screen scale to be exact. + let per_inch = (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z; + let target = pos2(edge.x + per_inch, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + 5.0, ruler_y))); + mouse(&mut h, Event::PointerMoved(target)); + h.run_steps(2); + // Live preview: the page content moved, the document did not change yet. + let live = geo(&h).paper.unwrap().margins[0]; + assert!(live > pg.margins[0] + 1.0, "{live} vs {}", pg.margins[0]); + assert_eq!(margins(&h), start_margins); + let want = (start_margins[0] + 1.0 * 1.0).min(10.0); + mouse(&mut h, Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let after = margins(&h); + assert!((after[0] - want).abs() < 0.03, "{after:?} want left {want}"); + assert_eq!(after[1..], start_margins[1..]); + // One undo step restores it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); + // Esc during a drag cancels: nothing is committed. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + per_inch, ruler_y))); + mouse(&mut h, Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); +} + +fn press(h: &mut Harness, p: Pos2) { + h.run_steps(40); + mouse(h, Event::PointerMoved(p)); + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); +} + +fn release(h: &mut Harness, p: Pos2) { + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Screen pixels per inch of the page. +fn per_inch(g: &Geo) -> f32 { + let pg = g.paper.clone().unwrap(); + (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z +} + +#[test] +fn header_and_footer_margins_are_dragged_on_the_vertical_ruler() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + // Header: distance from the top of the page, 0.2 in further down. + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - (start[4] + 0.2)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!((after[..4].to_vec(), after[5]), (start[..4].to_vec(), start[5])); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start); + // Footer: distance from the bottom, dragged up by 0.2 in grows it (zoom out so the page + // bottom is on screen). + h.state_mut().run("view.zoom", json!({"percent": 50})).unwrap(); + h.run_steps(4); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let (_, py, _, ph) = pg.page_rect(0, 0); + let at = pos2(ruler_x, screen(&g, 0.0, py + ph - pg.margins[5]).y); + let to = pos2(ruler_x, at.y - 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y - 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + assert!((margins(&h)[5] - (start[5] + 0.2)).abs() < 0.03, "{:?}", margins(&h)); +} + +#[test] +fn right_to_left_sheets_mirror_the_horizontal_ruler_drag() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + // The left margin edge is drawn on the right of the mirrored page; dragging it towards the + // page centre (screen-left) grows the left margin. + let at = pos2(g.mirror_x(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x), y); + let to = pos2(at.x - per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x - 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[0] - (start[0] + 1.0)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!(after[1], start[1]); +} + +#[test] +fn preview_repaginates_so_fit_to_width_matches_the_commit() { + let mut h = fixture_with(false, Some(json!({"fitWidth": 1, "fitHeight": 0}))); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + let at = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, y); + let to = pos2(at.x + 2.0 * per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x + 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + h.run_steps(2); + let live_scale = geo(&h).paper.unwrap().scale; + assert_eq!(margins(&h), start); + assert!(live_scale < pg.scale, "live scale {live_scale} should shrink below {}", pg.scale); + release(&mut h, to); + h.run_steps(2); + let committed = geo(&h).paper.unwrap().scale; + assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); +} + +#[test] +fn right_to_left_sheets_carry_the_vertical_ruler_on_the_right() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let outer = h.state().grid.rect.unwrap(); + // The grid leaves the ruler's width free on the right, not on the left. + assert!((g.rect.right() - (outer.right() - gridcraft_ui_egui::paper_view::RULER)).abs() < 0.5, "{:?} {outer:?}", g.rect); + assert!((g.rect.left() - outer.left()).abs() < 0.5); + let x = outer.right() - 9.0; + let at = pos2(x, screen(&g, 0.0, pg.page_y(0) + pg.margins[2]).y); + let to = pos2(x, at.y + 0.25 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[2] - (start[2] + 0.25)).abs() < 0.03, "{after:?} vs {start:?}"); + // The left ruler strip is not a handle any more: nothing happens there. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let at = pos2(outer.left() + 9.0, screen(&g, 0.0, pg.page_y(0) + pg.margins[3]).y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x, at.y - 30.0))); + release(&mut h, pos2(at.x, at.y - 30.0)); + assert_eq!(margins(&h)[3], start[3]); +} + +#[test] +fn header_distance_cannot_be_dragged_past_the_top_margin() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 3.0 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - start[2]).abs() < 0.011, "header {} should stop at the top margin {}", after[4], start[2]); + assert_eq!(after[2], start[2]); +} diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs new file mode 100644 index 000000000..06e0d18ce --- /dev/null +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -0,0 +1,106 @@ +//! Extending the selection from the keyboard scrolls its moving end into view, not the anchor. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Ctrl on Windows and Linux sets both `ctrl` and `command`. +const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; +const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL }; + +/// Column A filled down to A200 and row 1 filled across to CV1, far past one screen each way. +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let down: Vec> = (1..=200).map(|r| vec![r]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": down})).unwrap(); + let across: Vec = (1..=100).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": [across]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, k: Key, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +/// Whether the cell's centre is on screen (a whole cell can sit a few pixels under the edge when +/// scrolling far down widens the row headings). +fn visible(h: &Harness, a1: &str) -> bool { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.contains(geo.cell_rect(sheet, CellRef::parse(a1).unwrap()).center()) +} + +fn assert_extended(h: &Harness, anchor: &str, moving_end: &str) { + let selection = &h.state().session.active().unwrap().selection; + let (anchor_cell, end_cell) = (CellRef::parse(anchor).unwrap(), CellRef::parse(moving_end).unwrap()); + assert_eq!(selection.current(), RangeRef::new(anchor_cell, end_cell)); + assert_eq!(selection.anchor, anchor_cell); + assert_eq!(selection.active, anchor_cell, "extending keeps the active cell on the anchor"); + assert!(visible(h, moving_end), "the moving end {moving_end} is scrolled into view"); +} + +#[test] +fn ctrl_shift_arrows_scroll_the_moving_end_into_view() { + // Down, then back Up past the top once the view has scrolled. + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL_SHIFT); + assert_extended(&h, "A1", "A200"); + assert!(!visible(&h, "A1"), "the view follows the moving end, away from the anchor"); + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(visible(&h, "A200") && !visible(&h, "A1")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + // Right, then back Left. + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL_SHIFT); + assert_extended(&h, "A1", "CV1"); + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(visible(&h, "CV1") && !visible(&h, "A1")); + key(&mut h, Key::ArrowLeft, CTRL_SHIFT); + assert_extended(&h, "CV1", "A1"); +} + +#[test] +fn shift_arrows_scroll_the_moving_end_into_view() { + let mut h = harness(); + for _ in 0..60 { + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "A61"); + for _ in 0..30 { + key(&mut h, Key::ArrowRight, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "AE61"); +} + +#[test] +fn ctrl_shift_home_and_end_scroll_the_moving_end_into_view() { + let mut h = harness(); + key(&mut h, Key::End, CTRL_SHIFT); + assert_extended(&h, "A1", "CV200"); + let mut h = harness(); + key(&mut h, Key::End, CTRL); + assert!(!visible(&h, "A1")); + key(&mut h, Key::Home, CTRL_SHIFT); + assert_extended(&h, "CV200", "A1"); +} diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/sheet_navigator.rs new file mode 100644 index 000000000..a16e306ed --- /dev/null +++ b/crates/ui-egui/tests/sheet_navigator.rs @@ -0,0 +1,44 @@ +//! The All Sheets navigator next to the sheet tabs: type part of a name, Enter, and that sheet is active (#839). + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut s = Session::new(); + s.new_workbook(); + for _ in 0..3 { + s.run("home.insertSheet", json!({})).unwrap(); + } + for (i, name) in ["Data", "Hidden", "Notes", "Summary"].iter().enumerate() { + s.run("sheet.rename", json!({"sheet": i, "name": name})).unwrap(); + } + s.run("sheet.hide", json!({"sheet": 1})).unwrap(); + s.run("sheet.activate", json!({"sheet": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +#[test] +fn typing_a_name_and_enter_activates_that_sheet() { + let mut h = harness(); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 0); + h.get_by_label("All sheets").click(); + h.run_steps(4); + h.input_mut().events.push(Event::Text("summ".into())); + h.run_steps(3); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 3, "Enter on the filtered row must activate Summary"); +} diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs new file mode 100644 index 000000000..e799f7a89 --- /dev/null +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -0,0 +1,178 @@ +//! Overflowing worksheet tabs, exercised through their actual painted geometry and input. +use egui::{Color32, Event, Modifiers, PointerButton, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(width: f32, active: usize) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); + for i in 1..30 { + s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet":active})).unwrap(); + let mut fonts_ready = false; + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions()); + fonts_ready = true; + return; + } + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(20); + h +} + +fn tab(h: &Harness, name: &str) -> Option<(Rect, Rect)> { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == name => Some((t.visual_bounding_rect(), s.clip_rect)), + _ => None, + }) +} + +fn assert_visible(h: &Harness, name: &str) { + let (text, clip) = tab(h, name).unwrap_or_else(|| panic!("{name} was not painted in the tab viewport")); + assert!(clip.contains_rect(text), "{name} is clipped: text={text:?}, viewport={clip:?}"); +} + +fn click(h: &mut Harness, at: egui::Pos2) { + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +#[test] +fn opening_and_activating_late_sheets_reveals_their_tabs() { + for width in [500.0, 1000.0] { + let mut h = harness(width, 29); + assert_visible(&h, "Tab29"); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab00"); + h.state_mut().run("sheet.activate", json!({"sheet":20})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab20"); + } +} + +#[test] +fn navigation_arrows_reveal_the_selected_tab() { + let mut h = harness(700.0, 0); + let (_, clip) = tab(&h, "Tab00").unwrap(); + // The Next arrow sits left of the All Sheets button (24 px) and the 8 px gap before the tabs. + let next = pos2(clip.left() - 44.0, clip.center().y); + for _ in 0..29 { + click(&mut h, next); + } + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_visible(&h, "Tab29"); +} + +#[test] +fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { + for delta in [vec2(0.0, -350.0), vec2(-350.0, 0.0)] { + let mut h = harness(700.0, 0); + let before = h.state().session.doc().unwrap().wb.clone(); + let (_, clip) = tab(&h, "Tab00").unwrap(); + h.input_mut().events.extend([ + Event::PointerMoved(clip.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Start, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }, + ]); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Move, + delta, + modifiers: Modifiers::NONE, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::End, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "gesture should browse away from the selected tab: {delta:?}"); + assert_eq!(h.state().session.doc().unwrap().wb, before, "browsing tabs must not edit the workbook"); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "idle frames must not snap back"); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab29"); + } +} +#[test] +fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { + let mut h = harness(1000.0, 0); + for sheet in 1..10 { + h.state_mut().run("sheet.hide", json!({"sheet":sheet})).unwrap(); + } + let name = "Quarterly results and forecasts"; + h.state_mut().run("sheet.rename", json!({"sheet":29,"name":name})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, name); + // Narrow, but still wide enough for the long tab next to the arrows and the All Sheets button. + h.set_size(vec2(560.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(1000.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); +} + +#[test] +fn new_sheet_button_stays_reachable_and_reveals_the_inserted_tab() { + let mut h = harness(700.0, 29); + let (_, clip) = tab(&h, "Tab29").unwrap(); + click(&mut h, pos2(clip.right() + 18.0, clip.center().y)); + let d = h.state().session.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 31); + let name = d.wb.active().unwrap().name.clone(); + assert_visible(&h, &name); +} + +#[test] +fn overflow_leaves_the_worksheet_scrollbar_reachable() { + for width in [500.0, 700.0, 1200.0] { + let mut h = harness(width, 0); + let (bar, clip) = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == Color32::from_gray(190) && r.rect.height() == 8.0 => Some((r.rect, s.clip_rect)), + _ => None, + }) + .expect("worksheet scrollbar thumb must be painted"); + assert!(clip.contains_rect(bar), "worksheet scrollbar is offscreen: {bar:?}, {clip:?}"); + let at = bar.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerMoved(at + vec2(12.0, 0.0))); + h.step(); + assert!(h.state().view().scroll.x > 0.0, "worksheet scrollbar must respond to dragging"); + } +} diff --git a/crates/ui-egui/tests/validation_input_message.rs b/crates/ui-egui/tests/validation_input_message.rs new file mode 100644 index 000000000..e05dbcefe --- /dev/null +++ b/crates/ui-egui/tests/validation_input_message.rs @@ -0,0 +1,56 @@ +//! A list validation's input message must not cover the dropdown it describes (#505). + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const MESSAGE: &str = "Pick a colour from the list"; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("data.validation", json!({"range": "B2", "type": "list", "formula1": "\"Red,Green,Blue\"", "inputMessage": MESSAGE})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn message_shown(h: &Harness) -> bool { + h.query_by_label(MESSAGE).is_some() +} + +#[test] +fn input_message_hides_while_the_list_is_open() { + let mut h = harness(); + assert!(message_shown(&h), "the selected cell's input message is shown"); + + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let pos = grid::validation_arrow(sh, &geo, CellRef::parse("B2").unwrap()).unwrap().center(); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + + assert!(h.state().grid.list_picker.is_some(), "the dropdown arrow opens the list"); + assert!(h.query_by_label("Green").is_some(), "the list shows its items"); + assert!(!message_shown(&h), "the input message must not cover the open list"); + + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().grid.list_picker.is_none(), "Escape closes the list"); + assert!(message_shown(&h), "the input message comes back once the list is closed"); +} diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs index 3a7471852..5f85e4301 100644 --- a/crates/xlsx/src/cfb.rs +++ b/crates/xlsx/src/cfb.rs @@ -209,13 +209,19 @@ impl<'a> Compound<'a> { } /// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the - /// FAT (so a cycle) or pointing outside the file is an error. + /// FAT (so a cycle) or pointing outside the file is an error. The file may end inside the + /// chain's last sector (some writers don't pad it, and Excel opens those files): that sector + /// reads short, and the caller checks the stream's declared size against what was read. fn read_chain(&self, start: u32, limit: u64) -> Result, String> { let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX); let mut out = Vec::new(); let mut id = start; let mut seen = vec![false; self.fat.len()]; + let mut cut = false; while id != END_OF_CHAIN && out.len() < limit { + if cut { + return Err("damaged compound file: sector out of range (the file may be truncated)".into()); + } if id >= MAX_REG_SECT { return Err("damaged compound file: broken sector chain".into()); } @@ -226,20 +232,26 @@ impl<'a> Compound<'a> { } let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size)); let s = start - .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?)) + .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?.min(self.bytes.len()))) .ok_or("damaged compound file: sector out of range (the file may be truncated)")?; + cut = s.len() < self.sector_size; out.extend_from_slice(s); id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?; } Ok(out) } - /// Follows a mini FAT chain inside the mini stream container. + /// Follows a mini FAT chain inside the mini stream container. Like `read_chain`, the + /// container may end inside the chain's last mini sector. fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result, String> { let mut out = Vec::new(); let mut id = start; let mut seen = vec![false; self.mini_fat.len()]; + let mut cut = false; while id != END_OF_CHAIN && out.len() < limit { + if cut { + return Err("damaged compound file: mini sector out of range".into()); + } if id >= MAX_REG_SECT { return Err("damaged compound file: broken mini sector chain".into()); } @@ -249,8 +261,10 @@ impl<'a> Compound<'a> { None => return Err("damaged compound file: mini sector missing from the mini FAT".into()), } let at = (id as usize).checked_mul(self.mini_sector_size); - let s = - at.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?)).ok_or("damaged compound file: mini sector out of range")?; + let s = at + .and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?.min(container.len()))) + .ok_or("damaged compound file: mini sector out of range")?; + cut = s.len() < self.mini_sector_size; out.extend_from_slice(s); id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?; } @@ -379,4 +393,63 @@ pub(crate) mod tests { b[516..520].copy_from_slice(&1u32.to_le_bytes()); assert!(!is_encrypted(&b)); } + + /// `compound_file` with one regular-sector stream of `len` bytes (FAT in sector 0, directory + /// in sector 1, the stream from sector 2), cut right after the stream's last byte, the way + /// some writers leave the final sector unpadded. + fn unpadded(len: usize) -> (Vec, Vec) { + let data: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Workbook", &data)]); + file.truncate(512 * 3 + len); + (file, data) + } + + #[test] + fn an_unpadded_last_sector_still_reads() { + let (file, data) = unpadded(5000); + assert_ne!(file.len() % 512, 0); + let cf = Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); + // Bytes the stream needs that the file doesn't hold are still damage. + let cf = Compound::open(&file[..file.len() - 1]).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn an_unpadded_last_mini_sector_still_reads() { + let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Workbook", &data)]); + // Header, FAT, directory, mini FAT, then the mini stream holding the stream's bytes. + file.truncate(512 * 4 + data.len()); + let cf = Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); + let cf = Compound::open(&file[..file.len() - 1]).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn a_cut_sector_inside_a_chain_is_damage() { + let (mut file, _) = unpadded(5000); + // The cut sector (11) points on to the directory sector and the stream claims those + // bytes too: the partial sector would splice 392 bytes into the middle of the stream. + file[512 + 11 * 4..512 + 12 * 4].copy_from_slice(&1u32.to_le_bytes()); + file[1024 + 128 + 120..1024 + 128 + 124].copy_from_slice(&5512u32.to_le_bytes()); + let cf = Compound::open(&file).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn a_cut_mini_sector_inside_a_chain_is_damage() { + let small = [7u8; 100]; + let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Small", &small), ("Workbook", &data)]); + // "Small" holds mini sectors 0-1, "Workbook" 2-17; the mini stream starts at sector 3. + file.truncate(512 * 4 + 128 + data.len()); + assert_eq!(Compound::open(&file).unwrap().root_stream("Workbook").unwrap().unwrap(), data); + // The cut mini sector (17) points on to sector 0 and the stream claims those bytes too. + file[1536 + 17 * 4..1536 + 18 * 4].copy_from_slice(&0u32.to_le_bytes()); + file[1024 + 2 * 128 + 120..1024 + 2 * 128 + 124].copy_from_slice(&1064u32.to_le_bytes()); + let cf = Compound::open(&file).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } } diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 2becc0e8f..47486bfa2 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -8,6 +8,9 @@ use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; use crate::{CsvOptions, IoError}; +/// The largest CSV or tab-separated file written (bytes). +pub const MAX_CSV_BYTES: usize = 512 << 20; + /// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1). const CP1252: [u32; 32] = [ 0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, @@ -187,10 +190,31 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { /// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives /// each cell's field text: the caller decides how values look (Save As CSV writes them as /// displayed, number formats applied); quoting is done here. -pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec { +/// +/// The output is capped at [`MAX_CSV_BYTES`]: two cells far apart make the used range, and so the +/// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas). +/// An export that would pass the cap is refused before anything is allocated, or as soon as the +/// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in +/// its place. +pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { + write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text) +} + +/// [`write_csv`] with the output cap `limit` (bytes) given. +pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { let delim = if delimiter == 0 { ',' } else { delimiter as char }; let mut out = String::new(); - let Some(r) = sheet.used_range() else { return Vec::new() }; + let Some(r) = sheet.used_range() else { return Ok(Vec::new()) }; + let too_large = + |what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20)); + // Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower + // bound on the output that needs no cell text. + let (rows, cols) = (u64::from(r.end.row - r.start.row) + 1, u64::from(r.end.col - r.start.col) + 1); + let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2); + let least = rows.saturating_mul(per_row); + if least > limit as u64 { + return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20))); + } for row in r.start.row..=r.end.row { for col in r.start.col..=r.end.col { if col > r.start.col { @@ -206,6 +230,9 @@ pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef } } out.push_str("\r\n"); + if out.len() > limit { + return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1))); + } } - out.into_bytes() + Ok(out.into_bytes()) } diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 076148a53..6dbd17e7e 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -11,6 +11,8 @@ use crate::read::Ctx; use crate::tables::EMU_PER_PX; use crate::xml::{El, esc, esc_attr}; +mod ink; + // ---------------------------------------------------------------- reading fn emu_px(v: Option) -> f32 { @@ -195,6 +197,9 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( fn read_shape(obj: &El, anchor: Anchor) -> Option { let sp = obj.child("spPr")?; + if let Some(shape) = ink::read(obj, anchor) { + return Some(shape); + } let prst = sp.child("prstGeom").and_then(|g| g.attr("prst")).unwrap_or("rect"); let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false)); let ln = sp.child("ln"); @@ -358,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; s.push_str(&open); + if let Some(xml) = ink::write(sh, theme, id) { + s.push_str(&xml); + s.push_str(""); + s.push_str(close_tag); + continue; + } let (prst, tx) = match sh.kind { ShapeKind::Rectangle => ("rect", false), ShapeKind::RoundedRectangle => ("roundRect", false), diff --git a/crates/xlsx/src/drawing/ink.rs b/crates/xlsx/src/drawing/ink.rs new file mode 100644 index 000000000..b48d99ee8 --- /dev/null +++ b/crates/xlsx/src/drawing/ink.rs @@ -0,0 +1,207 @@ +//! The open, solid polylines the model can represent as editable ink. This is DrawingML +//! geometry, not Office InkML. Unsupported geometry keeps the existing shape-import fallback. + +use super::*; + +const MAX_POINTS: usize = 20_000; +const MAX_COORD: f64 = 1e7; + +fn bounded(v: f64) -> bool { + v.is_finite() && v.abs() <= MAX_COORD +} + +fn only_children(e: &El, names: &[&str]) -> bool { + e.children.iter().all(|c| names.contains(&c.name.as_str())) +} + +fn only_attrs(e: &El, names: &[&str]) -> bool { + e.attrs.iter().all(|(k, _)| names.contains(&k.as_str())) +} + +fn one<'a>(e: &'a El, name: &str) -> Option<&'a El> { + let mut found = e.children.iter().filter(|c| c.name == name); + let first = found.next()?; + found.next().is_none().then_some(first) +} + +/// Do not partially import a path: losing a curve, subpath, transform or line effect changes +/// the drawing. Accept only the explicit subset emitted below, including its line styling. +pub(super) fn read(obj: &El, anchor: Anchor) -> Option { + if obj.name != "sp" + || !only_children(obj, &["nvSpPr", "spPr"]) + || !only_attrs(obj, &["macro", "textlink"]) + || obj.attrs.iter().any(|(_, v)| !v.is_empty()) + || obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|e| e.flag("txBox", false)) + { + return None; + } + let sp = one(obj, "spPr")?; + if !only_children(sp, &["xfrm", "custGeom", "noFill", "ln"]) || !sp.attrs.is_empty() { + return None; + } + let no_fill = one(sp, "noFill")?; + if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() { + return None; + } + let xf = one(sp, "xfrm")?; + if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) { + return None; + } + let off = one(xf, "off")?; + if off.attr_i64("x")? != 0 || off.attr_i64("y")? != 0 { + return None; + } + let ext = one(xf, "ext")?; + let ew = ext.attr_i64("cx")?; + let eh = ext.attr_i64("cy")?; + if ew <= 0 || eh <= 0 || anchor.width <= 0.0 || anchor.height <= 0.0 || !bounded(anchor.width as f64) || !bounded(anchor.height as f64) { + return None; + } + let same_extent = + |emu: i64, extent: f32| (emu as f64 / EMU_PER_PX - extent as f64).abs() <= 0.001_f64.max(extent as f64 * f32::EPSILON as f64 * 2.0); + if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) { + return None; + } + let geom = one(sp, "custGeom")?; + if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) { + return None; + } + // Text bounds have no effect on this unfilled geometry, which has no caption. + if geom.children.iter().any(|c| !matches!(c.name.as_str(), "pathLst" | "rect") && (!c.children.is_empty() || !c.attrs.is_empty())) { + return None; + } + if geom.kids("rect").count() > 1 || geom.kids("rect").any(|r| !r.children.is_empty() || !only_attrs(r, &["l", "t", "r", "b"])) { + return None; + } + let paths = one(geom, "pathLst")?; + if paths.children.len() != 1 { + return None; + } + let path = one(paths, "path")?; + let w = path.attr_i64("w")?; + let h = path.attr_i64("h")?; + if w <= 0 + || h <= 0 + || path.attr("fill") != Some("none") + || !only_attrs(path, &["w", "h", "fill", "stroke", "extrusionOk"]) + || path.attr("stroke").is_some_and(|_| path.attr_bool("stroke") != Some(true)) + || path.attr("extrusionOk").is_some_and(|_| path.attr_bool("extrusionOk") != Some(false)) + || !(2..=MAX_POINTS).contains(&path.children.len()) + { + return None; + } + let ln = one(sp, "ln")?; + if !only_attrs(ln, &["w", "cap", "cmpd", "algn"]) + || ln.attr("cap") != Some("flat") + || ln.attr("cmpd").is_some_and(|v| v != "sng") + || ln.attr("algn").is_some_and(|v| v != "ctr") + || !only_children(ln, &["solidFill", "miter"]) + { + return None; + } + let width_emu = ln.attr_i64("w")?; + let unit = EMU_PER_PX as i64; + if width_emu % unit != 0 || !(1..=40).contains(&(width_emu / unit)) { + return None; + } + let width = width_emu / unit; + let join = one(ln, "miter")?; + if !join.attrs.is_empty() || !join.children.is_empty() { + return None; + } + let fill = one(ln, "solidFill")?; + if fill.children.len() != 1 { + return None; + } + let color = one(fill, "srgbClr")?; + if !only_attrs(color, &["val"]) || !only_children(color, &["alpha"]) { + return None; + } + let expected_alpha = if width > 8 { 45_000 } else { 100_000 }; + if color.children.is_empty() { + if expected_alpha != 100_000 { + return None; + } + } else { + let alpha = one(color, "alpha")?; + if !only_attrs(alpha, &["val"]) || !alpha.children.is_empty() || alpha.attr_i64("val")? != expected_alpha { + return None; + } + } + let line = Color::from_hex(color.attr("val")?)?; + let mut text = String::new(); + for (i, command) in path.children.iter().enumerate() { + if command.name != if i == 0 { "moveTo" } else { "lnTo" } || !command.attrs.is_empty() || command.children.len() != 1 { + return None; + } + let pt = one(command, "pt")?; + if !only_attrs(pt, &["x", "y"]) || !pt.children.is_empty() { + return None; + } + let x = pt.attr_i64("x")? as f64 * anchor.width as f64 / w as f64; + let y = pt.attr_i64("y")? as f64 * anchor.height as f64 / h as f64; + if !bounded(x) || !bounded(y) { + return None; + } + if i != 0 { + text.push(';'); + } + let _ = write!(text, "{},{}", x as f32, y as f32); + } + Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() }) +} + +/// Invalid/native out-of-range data uses the existing rectangle fallback. A successful +/// result is complete before it is appended, so malformed points never yield half a stroke. +pub(super) fn write(shape: &Shape, theme: &Theme, id: usize) -> Option { + if shape.kind != ShapeKind::Ink || shape.format.no_line { + return None; + } + let width = match shape.fill { + Color::Rgb(rgb) => ((rgb >> 16) & 0xff).max(1), + _ => 2, + }; + if !(1..=40).contains(&width) { + return None; + } + let a = &shape.anchor; + if !bounded(a.width as f64) || !bounded(a.height as f64) || a.width <= 0.0 || a.height <= 0.0 || !a.dx.is_finite() || !a.dy.is_finite() { + return None; + } + let w = (a.width as f64 * EMU_PER_PX).round().max(1.0) as i64; + let h = (a.height as f64 * EMU_PER_PX).round().max(1.0) as i64; + let mut points = Vec::new(); + for point in shape.text.split(';') { + if points.len() == MAX_POINTS { + return None; + } + let (x, y) = point.split_once(',')?; + let (x, y) = (x.parse::().ok()?, y.parse::().ok()?); + if !bounded(x) || !bounded(y) { + return None; + } + points.push(((x * EMU_PER_PX).round() as i64, (y * EMU_PER_PX).round() as i64)); + } + if points.len() < 2 { + return None; + } + let [r, g, b] = shape.line.resolve(theme)?; + let mut xml = format!( + "{}", + xfrm(a, "a:xfrm") + ); + for (i, (x, y)) in points.iter().enumerate() { + let tag = if i == 0 { "moveTo" } else { "lnTo" }; + let _ = write!(xml, ""); + } + let _ = write!( + xml, + "", + width as i64 * EMU_PER_PX as i64 + ); + if width > 8 { + xml.push_str(""); + } + xml.push_str(""); + Some(xml) +} diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 459364e09..ef263873e 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -82,6 +82,9 @@ fn expr_to_file(e: Expr) -> String { Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), // Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`. Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]), + // Nor the `[@Col]` shorthand: files spell it `[[#This Row],[Col]]`, or Excel repairs + // the file and drops the table. Carried as a name, which prints verbatim. + Expr::Struct(st) => Expr::Name(gridcraft_formula::printer::struct_ref_file(&st)), // Nor trim-reference operators: written as the equivalent `TRIMRANGE(range,n,n)`, which // every Excel with trim references reads. (Excel's own file spelling of `A1:.E10` is // not publicly documented; the `_xlfn._TRO_*` call forms are accepted on read.) @@ -152,6 +155,20 @@ mod tests { assert_eq!(from_file("Sales[[#This Row],Units]*[[#This Row],Price]"), "Sales[@Units]*[@Price]"); } + #[test] + fn this_row_long_form() { + // `[@Col]` is only Excel's display shorthand; a file holding it is repaired by Excel, + // which drops the table. + assert_eq!(to_file(&Formula::new("Sales[@Units]*Sales[@[Unit Price]]")), "Sales[[#This Row],[Units]]*Sales[[#This Row],[Unit Price]]"); + assert_eq!(text_to_file("=[@Units]&COUNTA(Sales[@])"), "[[#This Row],[Units]]&COUNTA(Sales[#This Row])"); + assert_eq!(text_to_file("=SUM(Sales[Units])+[#Totals]"), "SUM(Sales[Units])+[#Totals]"); + for f in ["Sales[@Units]*[@[Unit Price]]", "Sales[@]", "[@[Unit Price]]+1"] { + let file = to_file(&Formula::new(f)); + assert!(!file.contains("[@"), "{file}"); + assert_eq!(from_file(&file), f); + } + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index b5e4bb66e..6fd80d9c2 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -45,7 +45,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{read_csv, write_csv}; +pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited}; pub use ods::read_ods; pub use xls::read_xls; pub use xlsb::read_xlsb; diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index f121977e2..7db21fdfc 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -6,7 +6,8 @@ //! their ``, data connections) and each sheet (query tables, form controls, //! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's //! ``, `` and `` XML, the `r:id` of `` and the legacy -//! VML shapes those controls need). +//! VML shapes those controls need). The workbook also keeps its `` and the +//! calcFeatures extension, which Excel needs to trust the saved values. //! //! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in //! kept XML are rewritten to match. Kept content is conservative: @@ -83,6 +84,11 @@ const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"]; const SHEET_ROOTS: &[&str] = &["queryTable"]; /// Sheet relationship kinds that mean the sheet XML may hold elements worth keeping. const SHEET_ELEMENT_KINDS: &[&str] = &["oleObject", "ctrlProp", "control", "image", "package", "printerSettings"]; +/// Attributes of `` (CT_FileVersion). +const FILE_VERSION_ATTRS: &[&str] = &["appName", "lastEdited", "lowestEdited", "rupBuild", "codeName"]; +/// The workbook extension listing the calculation features the saved values were computed with. +const CALC_FEATURES_URI: &str = "{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}"; +const NS_CALC_FEATURES: &str = "http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures"; /// Kept sheet elements, in schema order (all after ``, before ``). const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"]; @@ -369,6 +375,30 @@ impl KeptReader { Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), } } + // Excel trusts the saved cell values only when both are present; without them it + // recalculates everything on open, and links into closed workbooks come back as errors. + if let Some(v) = wb_xml.child("fileVersion") { + let mut x = String::from(""); + p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); + } + let calc_features = wb_xml + .child("extLst") + .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) + .and_then(|e| e.child("calcFeatures")); + if let Some(c) = calc_features { + let mut x = format!(""); + for name in c.kids("feature").filter_map(|f| f.attr("name")) { + let _ = write!(x, "", esc_attr(name)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); + } (!p.is_empty()).then(|| Arc::new(p)) } @@ -853,6 +883,12 @@ pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> Strin p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect() } +/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). +pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() +} + /// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. pub fn write_package_rels(root: &mut Rels, out: &mut Out) { for (ty, target, external) in std::mem::take(&mut out.kept.root) { diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 1d16ab34d..609ae530e 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -810,6 +810,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), } + if pt.values_on_rows { + s.push_str(" dataOnRows=\"1\""); + } if !pt.show_headers { s.push_str(" showHeaders=\"0\""); } @@ -826,13 +829,20 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn 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 first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(k > 1) }; - let first_data_col = if row_idx.is_empty() && col_idx.is_empty() { + // Σ 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())); + // 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() { 0 } else if compact { 1 } else { - row_idx.len().max(1) + row_levels.max(1) }; let _ = write!(s, ""); - if !row_idx.is_empty() { - let _ = write!(s, "", row_idx.len()); - for i in &row_idx { - let _ = write!(s, ""); - } - s.push_str(""); - } - let values_field = k > 1; - if !col_idx.is_empty() || values_field { - let _ = write!(s, "", col_idx.len() + usize::from(values_field)); - for i in &col_idx { - let _ = write!(s, ""); - } - if values_field { - s.push_str(""); - } - 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 { @@ -1011,6 +1005,25 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn s } +/// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. +fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option) { + let n = idx.len() + usize::from(values_at.is_some()); + if n == 0 { + return; + } + let _ = write!(s, "<{tag} count=\"{n}\">"); + for (j, i) in idx.iter().enumerate() { + if values_at == Some(j) { + s.push_str(""); + } + let _ = write!(s, ""); + } + if values_at.is_some_and(|p| p >= idx.len()) { + s.push_str(""); + } + let _ = write!(s, ""); +} + // ================================================================ reading /// A cache field as read. @@ -1253,18 +1266,21 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option = Vec::new(); - let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> Vec { + // (fields, position of Σ Values (`-2`) among them) + let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { let mut v = Vec::new(); + let mut values_at = None; for &i in list { + if i == -2 { + values_at = Some(v.len()); + continue; + } if i < 0 { continue; } @@ -1290,7 +1306,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option, x: &El, cache: &CacheIn, id: u32) -> Option { pub num_fmts: std::collections::HashMap, /// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute. pub cell_pictures: Vec>>, + /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. + pub cf_ext_ids: Vec<(String, usize)>, } impl Ctx<'_> { @@ -79,6 +81,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { 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()); @@ -120,6 +123,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if let Some(x) = cx.optional_xml(&r.target)? { let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes); cx.num_fmts = num_fmts; + // Cell format 0 is the Normal style: its font is what every unformatted cell shows. + if let Some(normal) = xfs.first() { + cx.styles = StyleTable::with_default(Style { font: normal.font.clone(), ..Style::default() }); + } 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; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d95b..1aa9b407b 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -60,6 +60,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { @@ -104,7 +105,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_hyperlinks(e, &rels, &mut sheet), + "hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet), "autoFilter" => sheet.autofilter = read_autofilter(cx, e), "printOptions" => { sheet.print.gridlines = e.flag("gridLines", false); @@ -541,7 +542,7 @@ pub fn parse_iso_datetime(s: &str, sys: DateSystem) -> Option { } pub fn parse_sqref(s: Option<&str>) -> Vec { - s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).take(10_000).collect() + s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect() } fn cf_op(s: Option<&str>) -> CfOperator { @@ -596,6 +597,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); let f0 = formulas.first().cloned().unwrap_or_default(); let text = r.attr("text").unwrap_or("").to_string(); + // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). + let ext_id = + r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); let rule = match r.attr("type").unwrap_or("") { "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, "expression" => CfRule::Expression { formula: f0, style }, @@ -649,6 +653,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { continue; } }; + if let Some(id) = ext_id { + cx.cf_ext_ids.push((id, sheet.cond_formats.len())); + } sheet.cond_formats.push(CondFormat { ranges: ranges.clone(), rule, @@ -709,11 +716,13 @@ fn read_validation(v: &El) -> Option { }) } -fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { +fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { let mut cells = 0u64; for h in e.kids("hyperlink") { let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default(); + // A relationship is a file or web page; a bare `location` is a place in this workbook. + let external = !target.is_empty(); if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { if target.is_empty() { target = loc.trim_start_matches('#').to_string(); @@ -724,9 +733,10 @@ fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { if target.is_empty() { continue; } - let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string) }; + let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string), external }; for c in range.iter() { if cells >= MAX_HYPERLINK_CELLS { + cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); return; } cells += 1; @@ -953,7 +963,23 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { } } } - "conditionalFormattings" => cx.warn("some Excel 2010 conditional format extensions were skipped"), + "conditionalFormattings" => { + let mut skipped = false; + for r in c.kids("conditionalFormatting").flat_map(|f| f.kids("cfRule")) { + let idx = r.attr("id").and_then(|id| cx.cf_ext_ids.iter().find(|(i, _)| i == id)).map(|(_, n)| *n); + match (idx.and_then(|n| sheet.cond_formats.get_mut(n)), r.child("dataBar")) { + (Some(CondFormat { rule: CfRule::DataBar { gradient, .. }, .. }), Some(db)) => { + *gradient = db.flag("gradient", true); + // Only the fill style is modelled; borders, lengths, axis or negative colours are lost. + skipped |= db.attrs.iter().any(|(k, _)| k != "gradient") || db.children.iter().any(|k| k.name != "cfvo"); + } + _ => skipped = true, + } + } + if skipped { + cx.warn("some Excel 2010 conditional format extensions were skipped"); + } + } _ => {} } } @@ -973,4 +999,12 @@ mod tests { assert_eq!(parse_iso_datetime("garbage", DateSystem::D1900), None); assert_eq!(parse_iso_datetime("2024-13-01", DateSystem::D1900), None); } + + #[test] + fn sqref_keeps_more_than_ten_thousand_ranges() { + let sq = (1..=12_000).map(|r| format!("A{r}")).collect::>().join(" "); + let ranges = parse_sqref(Some(&sq)); + assert_eq!(ranges.len(), 12_000); + assert_eq!(ranges.last().copied(), RangeRef::parse("A12000")); + } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d85..4196155f8 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -81,7 +81,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let tables: Vec<(&Table, Vec)> = sheet .tables .iter() - .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && !t.totals_row && t.range.height() >= 1)) + .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && t.range.height() > t.totals_row as u32)) .map(|t| (t, table_columns(t))) .collect(); for (t, cols) in &tables { @@ -388,7 +388,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if t.is_empty() { continue; } - let external = t.contains("://") + // `external` is what the file said, or what Insert › Link chose; the scheme test covers + // links made before the flag existed. + let external = h.external + || t.contains("://") || t.to_ascii_lowercase().starts_with("mailto:") || t.starts_with("\\\\") || t.to_ascii_lowercase().starts_with("file:"); @@ -573,9 +576,33 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } let _ = write!(s, "{parts}", tables.len()); } - // Sparklines (Excel 2010 extension). + // 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() { - s.push_str(""); + let _ = write!(s, ""); for sp in &sheet.sparklines { let ty = match sp.kind { SparklineKind::Line => "", @@ -593,7 +620,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( sp.cell.a1() ); } - s.push_str(""); + s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); } s.push_str(""); (s, rels) @@ -714,6 +744,33 @@ fn quote(t: &str) -> String { format!("\"{}\"", t.replace('"', "\"\"")) } +const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; +const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main"; + +/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. +fn solid_bar_id(priority: u32) -> String { + format!("{{00000000-0000-0000-0000-{priority:012}}}") +} + +/// The `cfRule` extension that points a solid data bar at its x14 rule. +fn solid_bar_ext(priority: u32) -> String { + let id = solid_bar_id(priority); + format!("{id}") +} + +/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. +fn x14_cfvo(v: &CfValueKind) -> String { + let (ty, val) = match v { + CfValueKind::Min => return "".into(), + CfValueKind::Max => return "".into(), + CfValueKind::Number(n) => ("num", num(*n)), + CfValueKind::Percent(n) => ("percent", num(*n)), + CfValueKind::Percentile(n) => ("percentile", num(*n)), + CfValueKind::Formula(f) => ("formula", text_to_file(f)), + }; + format!("{}", esc(&val)) +} + fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; @@ -809,12 +866,13 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { b.push_str(""); format!("{b}") } - CfRule::DataBar { min, max, color, show_value, .. } => format!( - "{}{}", + CfRule::DataBar { min, max, color, show_value, gradient } => format!( + "{}{}{}", if *show_value { "" } else { " showValue=\"0\"" }, cfvo_xml(min), cfvo_xml(max), - if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) } + if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, + if *gradient { String::new() } else { solid_bar_ext(priority) } ), CfRule::IconSet { set, thresholds, reverse, show_value } => { let mut b = format!(" String { } } -fn font_xml(f: &Font, dxf: bool) -> String { +/// `minor`: the font follows the theme's body font (``). +fn font_xml(f: &Font, dxf: bool, minor: bool) -> String { let mut s = String::from(""); if f.bold { s.push_str(""); @@ -429,6 +430,9 @@ fn font_xml(f: &Font, dxf: bool) -> String { } if !dxf { let _ = write!(s, "", esc_attr(&f.name)); + if minor { + s.push_str(""); + } } s.push_str(""); s @@ -667,7 +671,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St || d.font.color != Color::Auto || d.font.vert != VertAlign::Baseline { - dx.push_str(&font_xml(&d.font, true)); + dx.push_str(&font_xml(&d.font, true, false)); } if d.num_fmt != def.num_fmt { let id = nf.id(d.num_fmt.as_str()); @@ -699,8 +703,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St s.push_str(""); } let _ = write!(s, "", fonts.len()); - for f in &fonts { - s.push_str(&font_xml(f, false)); + for (i, f) in fonts.iter().enumerate() { + // A Normal font in the theme's body face follows the theme, as Excel writes it (#805). + s.push_str(&font_xml(f, false, i == 0 && f.name == wb.theme.minor_font)); } let _ = write!(s, "", fills.len()); for f in &fills { diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index 0491efd34..45764e428 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -200,6 +200,43 @@ fn calculated_table_column_keeps_formula_and_cell_value() { assert_eq!(sh.value(at("B2")), Value::Number(6.0)); } +#[test] +fn this_row_references_are_written_in_long_form() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cells.set(at("A2"), Cell::value(Value::Number(3.0))); + sh.cells.set(at("B2"), formula("Data[@Input]*2", 6.0)); + sh.tables.push(Table { + id: 1, + name: "Data".into(), + range: range("A1:B2"), + header_row: true, + totals_row: false, + columns: vec![ + TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None }, + TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("[@Input]*2".into()) }, + ], + style: "TableStyleLight1".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + }); + let bytes = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + assert_eq!(cell(&sheet, "B2").child("f").unwrap().text, "Data[[#This Row],[Input]]*2"); + let table = pkg.read_xml("xl/tables/table1.xml").unwrap().unwrap(); + let column = table.child("tableColumns").unwrap().kids("tableColumn").nth(1).unwrap(); + assert_eq!(column.child("calculatedColumnFormula").unwrap().text, "[[#This Row],[Input]]*2"); + let (back, _) = read_xlsx(&bytes).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.tables[0].columns[1].formula.as_deref(), Some("[@Input]*2")); + assert_eq!(sh.cell(at("B2")).unwrap().formula.as_ref().unwrap().text, "Data[@Input]*2"); +} + /// Compare both cell and formula metadata, collecting all boundaries in the group on failure. fn check_formula_metadata(mut wb: Workbook, sheet_index: usize, cases: &[(&str, bool)]) { for (i, (text, _)) in cases.iter().enumerate() { diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index c5951555e..cc52072e7 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, Value}; use gridcraft_model::{Sheet, Workbook}; use crate::csv::{decode_text, sniff_delimiter}; -use crate::{CsvOptions, Format, read_csv, sniff, write_csv}; +use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { wb.sheet(0).unwrap().value(CellRef::new(r, c)) @@ -109,11 +109,11 @@ fn writing() { Value::Error(e) => e.as_str().into(), Value::Array(_) => String::new(), }; - let out = String::from_utf8(write_csv(sh, b',', raw)).unwrap(); + let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap(); assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n"); - let semi = String::from_utf8(write_csv(sh, b';', raw)).unwrap(); + let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap(); assert!(semi.starts_with("a,b;")); - assert!(write_csv(&Sheet::new("E"), b',', raw).is_empty()); + assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty()); // Round trip. let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap(); assert_eq!(v(&back, 0, 0), Value::text("a,b")); @@ -142,3 +142,51 @@ fn hostile_csv() { let wb = read_csv(format!("{wide}x").as_bytes(), &CsvOptions::default()).unwrap(); assert!(wb.sheet(0).unwrap().cells.is_empty(), "fields past column XFD are dropped"); } + +/// A sheet with `a` in A1 and `b` in `corner`. +fn two_cells(corner: (u32, u32)) -> Sheet { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 0), Value::text("a")); + s.set_value(CellRef::new(corner.0, corner.1), Value::text("b")); + s +} + +fn text_of(sh: &Sheet) -> impl FnMut(CellRef) -> String + '_ { + |c| match sh.value(c) { + Value::Text(t) => t.to_string(), + _ => String::new(), + } +} + +#[test] +fn two_far_apart_cells_are_refused_before_anything_is_allocated() { + // A1 and XFD1048576 span the whole grid: 16 GiB of commas. Refused at once, with the numbers. + let s = two_cells((gridcraft_core::MAX_ROWS - 1, gridcraft_core::MAX_COLS - 1)); + let err = write_csv(&s, b',', text_of(&s)).unwrap_err(); + assert!(matches!(err, IoError::TooLarge(_)), "{err}"); + let msg = err.to_string(); + assert!(msg.contains("1048576 rows by 16384 columns") && msg.contains("512 MiB"), "{msg}"); + // A single row or column across the grid is refused too when it is over the cap. + let wide = two_cells((gridcraft_core::MAX_ROWS - 1, 0)); + assert!(write_csv_limited(&wide, b',', 1 << 20, text_of(&wide)).is_err()); +} + +#[test] +fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_position() { + // 3 rows x 3 columns, 14 bytes: accepted at the cap, refused one byte under. + let s = two_cells((2, 2)); + let ok = write_csv_limited(&s, b',', 14, text_of(&s)).unwrap(); + assert_eq!(String::from_utf8(ok).unwrap(), "a,,\r\n,,\r\n,,b\r\n"); + assert!(write_csv_limited(&s, b',', 13, text_of(&s)).is_err()); + // Rows that pass the lower bound but hold long text stop as soon as the cap is passed. + let mut long = Sheet::new("L"); + for r in 0..50u32 { + long.set_value(CellRef::new(r, 0), Value::text("x".repeat(100))); + } + let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err(); + assert!(err.to_string().contains("stopped at row"), "{err}"); + assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok()); + // A tab is one byte, like a comma. + let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap(); + assert_eq!(tabs.len(), 14); +} diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 47dde5adc..39ea30951 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -278,6 +278,63 @@ Check this"#; assert_eq!(s.sparklines[0].kind, gridcraft_model::SparklineKind::Column); } +/// The sheet's hyperlinks as written: cell, relationship target (external only), location. +fn written_links(bytes: &[u8]) -> Vec<(String, Option, Option)> { + let mut pkg = crate::package::Package::open(bytes).unwrap(); + let rels = pkg.rels("xl/worksheets/sheet1.xml").unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + let Some(links) = sheet.child("hyperlinks") else { return vec![] }; + links + .kids("hyperlink") + .map(|h| { + let rel = h.attr("id").map(|id| rels.iter().find(|r| r.id == id).unwrap()); + let target = rel.map(|r| { + assert!(r.external && r.kind == "hyperlink", "{r:?}"); + r.target.clone() + }); + (h.attr("ref").unwrap().to_string(), target, h.attr("location").map(str::to_string)) + }) + .collect() +} + +/// A link to another file is a relationship (`TargetMode="External"`) in the file, with the place in +/// it as `location`. Saved as a bare `location`, Excel takes it for a place in this workbook and the +/// link no longer opens the file (#800). C16 is Apache POI's `WithMoreVariousData.xlsx`. +#[test] +fn hyperlink_to_another_file_stays_external() { + let body = r#" + "#; + let rel = |id: &str, target: &str| { + format!( + r#""# + ) + }; + let sheet_rels = format!( + r#"{}{}{}{}"#, + rel("rId2", "WithVariousData.xlsx"), + rel("rId3", "Source.xlsx"), + rel("rId4", r"..\Reports\Q1.xlsx"), + rel("rId5", "https://example.com/a.xlsx"), + ); + let bytes = minimal(body, &[("xl/worksheets/_rels/sheet1.xml.rels", &sheet_rels)], "", ""); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let s = wb.sheet(0).unwrap(); + assert!(s.hyperlinks[&at("C16")].external && !s.hyperlinks[&at("C19")].external); + + let some = |s: &str| Some(s.to_string()); + let links = written_links(&crate::write_xlsx(&wb).unwrap()); + assert_eq!( + links, + vec![ + ("C16".into(), some("WithVariousData.xlsx"), None), + ("C17".into(), some("Source.xlsx"), some("Sheet1!A2")), + ("C18".into(), some(r"..\Reports\Q1.xlsx"), None), + ("C19".into(), None, some("Data!B2")), + ("C20".into(), some("https://example.com/a.xlsx"), None), + ] + ); +} + #[test] fn drawing_with_picture_and_chart() { let png: &[u8] = b"\x89PNG\r\n\x1a\nfakeimagedata"; @@ -495,3 +552,28 @@ fn text_box_formatting_is_read_and_written() { assert_eq!(rgb(g.text_outline.map(|o| o.0)), rgb(f.text_outline.map(|o| o.0))); assert_eq!(g.text_outline.map(|o| o.1), Some(1.5)); } + +/// `_xludf.` marks a user-defined call even when a built-in has the same name; dropping it turns +/// the call into the built-in, and `_xludf.EOMONTH()` into a call Excel repairs away (#726). +#[test] +fn user_defined_function_prefix_round_trips() { + let cases = + [("A1", "_xludf.EOMONTH()"), ("A2", "_xludf.CONCAT(\"a\",\"b\")"), ("A3", "_xludf.SUM(1,2)"), ("A4", "MYUDF(1)"), ("A5", "_xludf.MYUDF(1)")]; + let rows: String = cases + .iter() + .enumerate() + .map(|(i, (a, f))| format!(r#"{}"#, i + 1, f.replace('"', """))) + .collect(); + let (wb, _) = read_xlsx(&minimal(&format!("{rows}"), &[], "", "")).unwrap(); + let s = wb.sheet(0).unwrap(); + for (a, f) in cases { + assert_eq!(s.cell(at(a)).unwrap().formula.as_ref().unwrap().text, f, "read {a}"); + } + let bytes = crate::write_xlsx(&wb).unwrap(); + let mut pkg = crate::package::Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for (a, f) in cases { + let cell = sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(a)).unwrap(); + assert_eq!(cell.child("f").unwrap().text, f, "written {a}"); + } +} diff --git a/crates/xlsx/src/tests/ink_roundtrip.rs b/crates/xlsx/src/tests/ink_roundtrip.rs new file mode 100644 index 000000000..28827652a --- /dev/null +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -0,0 +1,194 @@ +//! Ink uses visible DrawingML geometry, and only representable polylines become editable ink. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, AnchorMode, Color, Shape, ShapeKind, Sheet, Workbook}; + +use super::minimal; +use crate::xml::{self, El}; +use crate::{read_xlsx, write_xlsx}; + +const POINTS: &str = r#""#; +const PATH: &str = r#""#; + +fn drawing() -> String { + format!( + r#" + 01905000285750 + + + {PATH}{POINTS} + + "# + ) +} + +fn read_drawing(drawing: &str) -> Workbook { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)], + "", + "", + ); + read_xlsx(&bytes).unwrap().0 +} + +fn points(shape: &Shape) -> Vec<[f64; 2]> { + shape + .text + .split(';') + .map(|pair| { + let (x, y) = pair.split_once(',').unwrap(); + [x.parse().unwrap(), y.parse().unwrap()] + }) + .collect() +} + +fn has_caption(element: &El) -> bool { + (element.name == "t" && !element.text.is_empty()) || element.children.iter().any(has_caption) +} + +#[test] +fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() { + let mut sheet = Sheet::new("Ink"); + for (id, width) in [(1, 3), (2, 12)] { + sheet.shapes.push(Shape { + id, + kind: ShapeKind::Ink, + anchor: Anchor { width: 100.0, height: 50.0, mode: AnchorMode::Absolute, ..Default::default() }, + fill: Color::Rgb(width << 16), + line: Color::Rgb(0xA12BCD), + text: "-10.0,5.0;50.0,40.0;90.0,10.0".into(), + format: Default::default(), + }); + } + let wb = Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() }; + let bytes = write_xlsx(&wb).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut drawing = String::new(); + archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap(); + let root = xml::parse(drawing.as_bytes()).unwrap(); + let shapes: Vec<_> = root.children.iter().filter_map(|anchor| anchor.child("sp")).collect(); + assert_eq!(shapes.len(), 2); + for (shape, width) in shapes.iter().zip([3, 12]) { + assert!(!has_caption(shape), "stroke coordinates must not be visible text"); + let properties = shape.child("spPr").unwrap(); + assert!(properties.child("prstGeom").is_none()); + assert!(properties.child("noFill").is_some()); + let paths: Vec<_> = properties.path(&["custGeom", "pathLst"]).unwrap().kids("path").collect(); + assert_eq!(paths.len(), 1); + let path = paths[0]; + assert_eq!(path.attr("fill"), Some("none")); + let w = path.attr_i64("w").unwrap(); + let h = path.attr_i64("h").unwrap(); + assert!(w > 0 && h > 0); + assert_eq!(path.children.iter().map(|c| c.name.as_str()).collect::>(), ["moveTo", "lnTo", "lnTo"]); + for (command, [x, y]) in path.children.iter().zip([[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]) { + let point = command.child("pt").unwrap(); + let actual_x = point.attr_i64("x").unwrap() as f64 * 100.0 / w as f64; + let actual_y = point.attr_i64("y").unwrap() as f64 * 50.0 / h as f64; + assert!((actual_x - x).abs() < 0.001 && (actual_y - y).abs() < 0.001); + } + let line = properties.child("ln").unwrap(); + assert_eq!(line.attr_i64("w"), Some(9525 * width)); + assert_eq!(line.attr("cap"), Some("flat")); + assert!(line.child("miter").is_some()); + let color = line.path(&["solidFill", "srgbClr"]).unwrap(); + assert_eq!(color.attr("val"), Some("A12BCD")); + if width > 8 { + assert_eq!(color.child_val("alpha"), Some("45000")); + } else { + assert!(color.child("alpha").is_none_or(|a| a.attr("val") == Some("100000"))); + } + } +} + +#[test] +fn numeric_polyline_from_an_independent_package_is_scaled_into_editable_ink() { + for (width, alpha) in [(3, ""), (12, r#""#)] { + let xml = drawing() + .replace("w=\"28575\"", &format!("w=\"{}\"", width * 9525)) + .replace(r#""#, &format!(r#"{alpha}"#)); + let wb = read_drawing(&xml); + let shapes = &wb.sheet(0).unwrap().shapes; + assert_eq!(shapes.len(), 1); + let shape = &shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + assert_eq!(points(shape), [[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]); + assert_eq!(shape.line, Color::Rgb(0xA12BCD)); + assert_eq!(shape.fill, Color::Rgb(width << 16)); + assert_eq!((shape.anchor.dx, shape.anchor.dy), (20.0, 30.0)); + assert_eq!((shape.anchor.width, shape.anchor.height), (100.0, 50.0)); + } +} + +#[test] +fn imported_points_are_not_snapped_to_native_tenth_units() { + let xml = drawing().replace("w=\"200\"", "w=\"2000000\"").replace( + POINTS, + r#""#, + ); + let wb = read_drawing(&xml); + let shape = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + let original = points(shape); + assert!((original[0][0] - 0.10005).abs() < 0.00000001, "an imported coordinate is not necessarily a native tenth-unit point"); + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let after = &reopened.sheet(0).unwrap().shapes[0]; + assert_eq!(after.kind, ShapeKind::Ink); + for (actual, expected) in points(after).iter().zip(original) { + for axis in 0..2 { + // A saved DrawingML coordinate is quantized to one EMU, plus f32 model precision. + assert!((actual[axis] - expected[axis]).abs() < 0.00006); + } + } +} + +#[test] +fn unsupported_geometry_or_appearance_is_not_reclassified_as_editable_ink() { + let original = drawing(); + let cases = [ + ("formula coordinate", original.replace("x=\"-20\"", "x=\"wd2\"")), + ("nonfinite coordinate", original.replace("x=\"-20\"", "x=\"NaN\"")), + ("overflow coordinate", original.replace("x=\"-20\"", "x=\"9223372036854775808\"")), + ("zero path width", original.replace("w=\"200\"", "w=\"0\"")), + ("missing point", original.replace(r#""#, "")), + ( + "curve", + original + .replace(r#""#, r#""#), + ), + ("closed path", original.replace("", "")), + ("multiple paths", original.replace("", &format!("{PATH}{POINTS}"))), + ("filled path", original.replace("fill=\"none\"", "fill=\"norm\"")), + ("filled shape", original.replace("", "")), + ("rotation", original.replace("", "")), + ("reflection", original.replace("", "")), + ("square caps", original.replace("cap=\"flat\"", "cap=\"sq\"")), + ("round join", original.replace("", "")), + ("dash", original.replace("", "")), + ("arrow", original.replace("", "")), + ( + "caption", + original.replace("", "Caption"), + ), + ("fractional width", original.replace("w=\"28575\"", "w=\"33338\"")), + ("unsupported opacity", original.replace(r#""#, r#""#)), + ]; + for (name, xml) in cases { + let wb = read_drawing(&xml); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink), "{name} was silently converted to editable ink"); + } +} + +#[test] +fn oversized_polyline_is_not_truncated_into_different_ink() { + let mut commands = String::from(r#""#); + for _ in 0..20_000 { + commands.push_str(r#""#); + } + let wb = read_drawing(&drawing().replace(POINTS, &commands)); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink)); +} diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index b14e53ab8..4700f8bdb 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -193,3 +193,19 @@ fn hostile_protected_ranges_stay_locked() { assert!(!matches!(h.verify("password"), Ok(true)), "{}", r.title); } } + +#[test] +fn hyperlinks_past_the_cell_cap_are_reported() { + // A styles part, so the only possible warning is the hyperlink one. + let parts = [("xl/styles.xml", r#""#)]; + let rels = r#""#; + let body = r#""#; + let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 10_000); + assert!(rep.warnings.iter().any(|w| w.contains("hyperlinks past the first 10000 cells were dropped")), "{:?}", rep.warnings); + // A small list reads in full with no warning. + let body = r#""#; + let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 3); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index c7cee75d9..517113d0f 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -5,6 +5,7 @@ mod chart_bool_default; mod csv_tests; mod encrypted_tests; mod fixtures; +mod ink_roundtrip; mod literal_category_roundtrip; mod malformed; mod ods_tests; diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 4cde9d69e..aac9b6b86 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -483,3 +483,41 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap(); assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some()))); } + +#[test] +fn file_version_and_calc_features_survive_a_save() { + // Excel trusts the saved cell values only when workbook.xml carries both and the + // calcFeatures extension; without them it recalculates everything on open (#799). + let calc_features = r#""#; + let workbook = format!( + r#"{calc_features}"# + ); + let sheet = format!( + r#"1A1*22"# + ); + let bytes = make_zip(&[ + ("[Content_Types].xml", super::CT.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")]).as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ]); + let (wb, _) = read_xlsx(&bytes).unwrap(); + // Twice: what was kept on the first save is read back and kept again. + let (again, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for saved in [write_xlsx(&wb).unwrap(), write_xlsx(&again).unwrap()] { + let u = Unpacked::new(&saved); + u.assert_valid(); + let text = u.text("xl/workbook.xml"); + let root = xml::parse(text.as_bytes()).unwrap(); + // Schema order: fileVersion is the first child of , extLst the last. + assert_eq!(root.children.first().map(|e| e.name.as_str()), Some("fileVersion"), "{text}"); + assert_eq!(root.children.last().map(|e| e.name.as_str()), Some("extLst"), "{text}"); + assert!(text.contains(r#""#), "{text}"); + assert!(text.contains(&format!("{calc_features}")), "{text}"); + } + + // A workbook that had neither gets neither. + let plain = Unpacked::new(&write_xlsx(&gridcraft_model::Workbook::new()).unwrap()).text("xl/workbook.xml"); + assert!(!plain.contains("fileVersion") && !plain.contains("extLst"), "{plain}"); +} diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs index c854432e5..0c845aa2c 100644 --- a/crates/xlsx/src/tests/pivot_tests.rs +++ b/crates/xlsx/src/tests/pivot_tests.rs @@ -109,6 +109,8 @@ fn workbook() -> Workbook { show_headers: false, last_range: Some(rr("A2:H12")), calculated_fields: vec![], + values_on_rows: false, + values_position: None, }; // Pivot 2: table source, date grouping on two levels, show-values-as, multi-select page // filter and a hidden-items filter on a column field. @@ -166,6 +168,8 @@ fn workbook() -> Workbook { show_headers: true, last_range: Some(rr("K2:Z30")), calculated_fields: vec![], + values_on_rows: false, + values_position: None, }; // Pivot 3: defaults, defined-name source, compact. let p3 = PivotTable { @@ -508,3 +512,76 @@ fn calculated_fields_round_trip() { assert_eq!(a, b, "pivot {} differs", a.name); } } + +/// The `x` attributes of an axis list (`rowFields`/`colFields`) in a pivotTable part. +fn axis_xs(table: &str, tag: &str) -> Vec { + let Some(start) = table.find(&format!("<{tag} ")) else { return vec![] }; + let end = table[start..].find(&format!("")).map_or(table.len(), |e| start + e); + table[start..end].split(""#; + let (wb, _) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap(); + let pt = &wb.sheet(0).unwrap().pivots[0]; + assert_eq!((pt.columns.len(), pt.values.len()), (1, 2)); + assert!(!pt.values_on_rows); + assert_eq!(pt.values_position, None); +} + +#[test] +fn data_on_rows_with_one_value_field_reads_without_warning() { + let table = r#""#; + let (wb, report) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap(); + assert!(report.warnings.iter().all(|w| !w.contains("values on rows")), "{:?}", report.warnings); + let pt = &wb.sheet(0).unwrap().pivots[0]; + assert!(pt.values_on_rows); + assert_eq!(pt.values_position, None); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(back.sheet(0).unwrap().pivots[0].values_on_rows); +} diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index b800e299a..e9d72d5ad 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -204,9 +204,9 @@ fn sample() -> Workbook { security_descriptor: None, }, ]; - s.hyperlinks.insert(at("A2"), Hyperlink { target: "https://example.com/?a=1&b=2".into(), tooltip: Some("go".into()) }); - s.hyperlinks.insert(at("A3"), Hyperlink { target: "'Data & Stuff'!B2".into(), tooltip: None }); - s.hyperlinks.insert(at("A4"), Hyperlink { target: "mailto:me@example.com".into(), tooltip: None }); + s.hyperlinks.insert(at("A2"), Hyperlink { target: "https://example.com/?a=1&b=2".into(), tooltip: Some("go".into()), external: true }); + s.hyperlinks.insert(at("A3"), Hyperlink { target: "'Data & Stuff'!B2".into(), tooltip: None, external: false }); + s.hyperlinks.insert(at("A4"), Hyperlink { target: "mailto:me@example.com".into(), tooltip: None, external: true }); s.comments.insert( at("B2"), Comment { @@ -988,6 +988,42 @@ fn table_newline_column_and_backslash_name_survive() { assert!(gridcraft_formula::parse("COUNTA(\\_Prime.1[Name])").is_ok(), "backslash table name parses as a structured reference"); } +/// A table that is only a header row and a totals row (Insert Table on one row, then Total +/// Row) is written like the header-only and one-data-row shapes, not dropped on save. +#[test] +fn header_and_totals_table_without_data_row_survives() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.tables.push(Table { + id: 1, + name: "Table1".into(), + range: rr("A1:B2"), + header_row: true, + totals_row: true, + columns: vec![ + TableColumn { name: "Name".into(), totals: TotalsFn::None, totals_label: Some("Total".into()), formula: None }, + TableColumn { name: "Amount".into(), totals: TotalsFn::Sum, totals_label: None, formula: None }, + ], + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: true, + last_col: true, + filter_button: true, + filter: vec![], + }); + let (back, report) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let tables = &back.sheet(0).unwrap().tables; + assert_eq!(tables.len(), 1); + let t = &tables[0]; + assert_eq!((t.name.as_str(), t.range.a1(), t.header_row, t.totals_row), ("Table1", "A1:B2".to_string(), true, true)); + assert_eq!((t.style.as_str(), t.banded_rows, t.first_col, t.last_col), ("TableStyleMedium2", true, true, true)); + assert_eq!(t.columns[0].totals_label.as_deref(), Some("Total")); + assert_eq!(t.columns[1].totals, TotalsFn::Sum); + assert_eq!(back.sheet(0).unwrap().value(at("A1")), Value::text("Name")); +} + /// Templates and macro-enabled files differ from `.xlsx` only in the workbook part's content /// type; a template saved with the workbook type is rejected by Excel. #[test] @@ -1015,6 +1051,76 @@ fn package_kind_sets_workbook_content_type() { assert_eq!(crate::XlsxKind::from_path("noext"), crate::XlsxKind::Workbook); } +/// The workbook's default font (`fonts[0]`, the Normal style's) is what every default-formatted +/// cell shows, typed or empty; saving keeps it instead of resetting it to Calibri 11 (#805). +#[test] +fn default_font_survives_a_round_trip() { + let styles = |font0: &str| { + format!( + r#"{font0} + + + + "# + ) + }; + let rels = r#""#; + let body = r#"typed text1"#; + let font0 = |bytes: &[u8]| { + let mut pkg = Package::open(bytes).unwrap(); + let st = pkg.read_xml("xl/styles.xml").unwrap().unwrap(); + let f = st.child("fonts").unwrap().kids("font").next().unwrap().clone(); + let val = |n: &str| f.child(n).and_then(|e| e.attr("val")).map(str::to_string); + (val("sz"), val("name"), val("family"), val("scheme")) + }; + + let file = + super::minimal(body, &[("xl/styles.xml", &styles(r#""#))], rels, ""); + let (wb, _) = read_xlsx(&file).unwrap(); + let saved = write_xlsx(&wb).unwrap(); + assert_eq!(font0(&saved), (Some("10".into()), Some("Arial".into()), Some("2".into()), None)); + let (back, _) = read_xlsx(&saved).unwrap(); + let normal = &back.styles.get(StyleId::DEFAULT).font; + assert_eq!((normal.name.as_str(), normal.size), ("Arial", 10.0), "empty cells and new input take the Normal font"); + let s = back.sheet(0).unwrap(); + assert_eq!(s.cell(at("A1")).unwrap().style, StyleId::DEFAULT); + let b1 = &back.styles.get(s.cell(at("B1")).unwrap().style).font; + assert!(b1.bold && b1.size == 14.0, "an explicit font is still its own: {b1:?}"); + + // Excel's own Normal font follows the theme's body font; the scheme link survives too. + let file = super::minimal( + body, + &[("xl/styles.xml", &styles(r#""#))], + rels, + "", + ); + let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap(); + assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into()))); +} + +#[test] +fn solid_data_bar_survives_with_sparklines_and_gradient_neighbours() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.set_value(at("A1"), Value::Number(1.0)); + let bar = + |gradient: bool, max: CfValueKind| CfRule::DataBar { min: CfValueKind::Min, max, color: Color::Rgb(0x638EC6), gradient, show_value: true }; + let rules = [(true, "A1", CfValueKind::Max), (false, "B1", CfValueKind::Formula("$C$1".into())), (false, "D1:D3", CfValueKind::Number(9.0))]; + for (i, (gradient, range, max)) in rules.into_iter().enumerate() { + s.cond_formats.push(CondFormat { ranges: vec![rr(range)], rule: bar(gradient, max), priority: i as u32 + 1, stop_if_true: false }); + } + s.sparklines.push(Sparkline { cell: at("F1"), source: "A1:C1".into(), kind: SparklineKind::Line, color: Color::Rgb(0x376092), markers: false }); + let bytes = write_xlsx(&wb).unwrap(); + // x14 cfvo values are `xm:f` children; a `val` attribute there is not valid x14. + let (xml, _) = sheet1_xml(&bytes); + assert!(xml.contains("9"), "{xml}"); + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); + let b = &back.sheets[0]; + assert_eq!(b.cond_formats, wb.sheets[0].cond_formats); + assert_eq!(b.sparklines.len(), 1); +} + #[test] fn doc_props_keep_boundary_whitespace() { let mut wb = Workbook::new(); diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795ec..91e63c18f 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -330,11 +330,12 @@ fn reads_values_formulas_formats_and_layout() { // 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 keeps its value. + // 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::Number(4.0)); + 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"); @@ -358,6 +359,21 @@ fn reads_values_formulas_formats_and_layout() { assert_eq!(wb.date_system, DateSystem::D1904); } +#[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 + // is kept. With the default XF the cell is blank until the String record brings the text. + let table = cat(&[&[0x02], &u16s(&[0, 0])]); + let sheet = cat(&[&bof(0x0010), &formula(0, 0, special(0, 0), &table), &rec(0x0207, &xlstr("103a")), &eof()]); + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])); + let len = cat(&[&bof(0x0005), &boundsheet(0), &eof()]).len() as u32; + let stream = cat(&[&bof(0x0005), &boundsheet(len), &eof(), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("what-if data tables")), "{:?}", report.warnings); + assert_eq!(ftext(&wb, "A1"), None); + assert_eq!(v(&wb, 0, "A1"), Value::from("103a")); +} + #[test] fn refuses_encrypted_old_and_foreign_files() { // FILEPASS in the globals: an encrypted workbook. @@ -476,3 +492,105 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn formulas_using_a_skipped_name_keep_their_saved_results() { + // Lbl "ExtRange" = [Source.xlsx]Sheet1!$A$1:$A$3 (PtgArea3d through an external SupBook), + // "Local" = Sheet1!$A$1 and "Twice" = ExtRange*2. Only the first is skipped on import; a + // name that uses it is still read as it was. + let ext = cat(&[&[0x3B], &1u16.to_le_bytes(), &u16s(&[0, 2, 0, 0])]); + let local = cat(&[&[0x3A], &0u16.to_le_bytes(), &u16s(&[0, 0])]); + let twice = cat(&[&[0x23], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let lbl = |name: &str, rgce: &[u8]| { + rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce])) + }; + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &boundsheet(pos), + &rec(0x01AE, &u16s(&[1, 0x0401])), + &rec(0x01AE, &cat(&[&u16s(&[1]), &xlstr("Source.xlsx"), &xlstr("Sheet1")])), + &rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[1, 0, 0])])), + &lbl("ExtRange", &ext), + &lbl("Local", &local), + &lbl("Twice", &twice), + &eof(), + ]) + }; + let sum_ext = cat(&[&[0x43], &1u32.to_le_bytes(), &[0x22, 1], &4u16.to_le_bytes()]); + let local_x2 = cat(&[&[0x43], &2u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &10.0f64.to_le_bytes()])), + &formula(5, 1, num(60.0), &sum_ext), + &formula(6, 1, num(20.0), &local_x2), + &eof(), + ]); + let pos = globals(0).len() as u32; + let (wb, report) = read_xls(&compound("Workbook", &cat(&[&globals(pos), &sheet]))).unwrap(); + let names: Vec<_> = wb.names.iter().map(|n| (n.name.as_str(), n.formula.as_str())).collect(); + assert_eq!(names, [("Local", "Sheet1!$A$1"), ("Twice", "ExtRange*2")]); + assert_eq!(ftext(&wb, "B6"), None); + assert_eq!(v(&wb, 0, "B6"), Value::Number(60.0)); + assert_eq!(ftext(&wb, "B7").as_deref(), Some("Local*2")); + assert!(report.warnings.iter().any(|w| w.contains("defined names") && w.contains("saved results")), "{:?}", report.warnings); +} + +#[test] +fn reads_iterative_calculation_settings() { + // CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only + // the first sheet carries them, so the sheet after it must not reset them to the defaults. + // CalcPrecision sits in the globals. + let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); + let sheet2 = cat(&[&bof(0x0010), &eof()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + sheet1.len() as u32; + let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); + assert!(wb.calc.iterative); + assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + assert_eq!(wb.calc.mode, gridcraft_model::CalcMode::Manual); + assert!(wb.calc.precision_as_displayed); + // Without the records: Excel's defaults, iteration off. + let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); + assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); +} + +#[test] +fn a_format_shorter_than_its_count_keeps_its_characters() { + // A FORMAT whose cch is one more than the characters in the record (seen in real files): + // Excel opens them, the record's characters are the format code. + let short = rec(0x041E, &cat(&[&165u16.to_le_bytes(), &6u16.to_le_bytes(), &[0], b"0.000"])); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &short, + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(165), + &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), + &eof(), + ]) + }; + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(1, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(2, 0, 2), &1.25f64.to_le_bytes()])), + &eof(), + ]); + let stream = cat(&[&globals(globals(0).len() as u32), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + let sh = &wb.sheets[0]; + let fmt = |a1: &str| wb.styles.get(sh.style_id(CellRef::parse(a1).unwrap())).num_fmt.as_str().to_string(); + assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0)); + assert_eq!(v(&wb, 0, "A3"), Value::Number(1.25)); + assert_eq!(fmt("A2"), "yyyy-mm-dd"); + assert_eq!(fmt("A3"), "0.000"); + assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 549275659..dddba98b2 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -295,6 +295,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { } let mut w = format!("{XML_DECL}"); + w.push_str(&crate::passthrough::workbook_elements(wb, "fileVersion")); w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "" } else { "" }); if wb.protected_structure { let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); @@ -331,6 +332,10 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { } w.push_str("/>"); w.push_str(&pivots.workbook_xml(&mut wb_rels)); + let exts = crate::passthrough::workbook_elements(wb, "ext"); + if !exts.is_empty() { + let _ = write!(w, "{exts}"); + } w.push_str(""); // Workbook-level parts. diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs index 87a512c59..3de860707 100644 --- a/crates/xlsx/src/xls/formula.rs +++ b/crates/xlsx/src/xls/formula.rs @@ -51,6 +51,8 @@ pub(super) struct Links { pub sheets: Vec, /// Defined names (Lbl records), in order; `None` for names that can't be referenced. pub names: Vec, + /// Per defined name: whether the import skips it (its formula can't be decoded). + pub skipped: Vec, pub books: Vec, pub xti: Vec, } @@ -330,13 +332,13 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } } 0x23 => { - let i = t.u32()?; - let name = usize::try_from(i) - .ok() - .and_then(|i| i.checked_sub(1)) - .and_then(|i| links.names.get(i)) - .filter(|n| !n.is_empty()) - .ok_or(Unsupported("reference to a missing defined name"))?; + let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1)); + let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?; + // A cell using a name the import drops would calculate to #NAME?: keep its + // saved result instead, as for references to other workbooks. + if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) { + return Err(Unsupported("defined names GridCraft skipped")); + } push(&mut stack, kids, Expr::Name(name.clone()))?; } 0x24 | 0x2C => { @@ -479,6 +481,7 @@ mod tests { Links { sheets: vec!["Sheet1".into(), "My Data".into()], names: vec!["Rate".into()], + skipped: vec![false], books: vec![ SupBook { internal: true, addin: false, names: vec![] }, SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] }, diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66a3..dde8641e5 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,10 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas and print titles. Everything else (fonts, -//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is -//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the calculation settings +//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, +//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +17,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -123,6 +124,7 @@ struct Globals { sst: Vec>, links: Links, names: Vec, + precision_as_displayed: bool, } /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. @@ -143,7 +145,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo } }; let mut report = ReadReport::default(); - let g = globals(&stream, &mut budget)?; + let g = globals(&stream, &mut budget, &mut report)?; let mut wb = Workbook::new(); wb.sheets.clear(); @@ -175,18 +177,23 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; } wb.check_sheet_name(&bs.name, None).map_err(invalid)?; - let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { + let Some((mut sheet, calc)) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { report.warn("Dialog sheets and unreadable sheets were skipped."); model_index.push(None); continue; }; sheet.visibility = bs.visibility; + // Every worksheet repeats the workbook's calculation settings; Excel applies the first. + if wb.sheets.is_empty() { + wb.calc = calc; + } model_index.push(Some(wb.sheets.len())); wb.sheets.push(Arc::new(sheet)); } if wb.sheets.is_empty() { return Err(invalid("the workbook has no worksheets GridCraft can read")); } + wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); // The overview comes first. @@ -205,7 +212,7 @@ fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { Ok((c.u16()?, c.u16()?)) } -fn globals(stream: &[u8], budget: &mut Budget) -> Result { +fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result { let mut recs = Records::new(stream, 0); let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?; let (vers, dt) = bof(&first)?; @@ -247,9 +254,18 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { g.sheets.push(BoundSheet { pos, name, visibility, kind }); } 0x0022 => g.date1904 = c.u16()? == 1, + 0x000E => g.precision_as_displayed = c.u16()? == 0, 0x041E => { let ifmt = c.u16()?; - let code = c.string()?; + let cch = usize::from(c.u16()?); + let high = c.u8()? & 1 != 0; + // Some writers count one character more than the record holds; Excel opens + // those files, so keep the characters that are there. + let fit = c.remaining() / if high { 2 } else { 1 }; + if cch > fit { + report.warn("Some number formats were shorter than their declared length; the characters present were kept."); + } + let code = c.chars(cch.min(fit), high)?; budget.text(code.len())?; g.formats.insert(ifmt, code); } @@ -297,6 +313,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { _ => {} } } + // The same test `defined_names` applies, before any cell formula is decoded. + let skipped = g.names.iter().map(|l| !l.rgce.is_empty() && decode(&l.rgce, &l.rgcb, CellRef::new(0, 0), Mode::Name, &g.links).is_err()).collect(); + g.links.skipped = skipped; Ok(g) } @@ -390,9 +409,12 @@ struct SheetState<'g> { shared: HashMap, arrays: HashMap, last_formula: Option, - pending_string: Option, + /// The cell waiting for its String record, with its style: a blank cell isn't stored, so a + /// formula kept as its value may have no cell to receive the text. + pending_string: Option<(CellRef, StyleId)>, dropped: usize, unsupported: Vec<&'static str>, + calc: CalcSettings, } impl SheetState<'_> { @@ -462,7 +484,7 @@ fn worksheet( styles: &[StyleId], budget: &mut Budget, report: &mut ReadReport, -) -> Result, IoError> { +) -> Result, IoError> { if bs.pos >= stream.len() { return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); } @@ -483,6 +505,7 @@ fn worksheet( pending_string: None, dropped: 0, unsupported: vec![], + calc: CalcSettings::default(), }; let mut depth = 1usize; while let Some(rec) = recs.next_record()? { @@ -507,9 +530,9 @@ fn worksheet( } // Shared and array formulas: their definitions follow the first cell that uses them. let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared)); - for p in std::mem::take(&mut st.pending) { + let pending = std::mem::take(&mut st.pending); + for p in &pending { if let Some((range, f)) = arrays.get(&p.anchor) { - // The other cells of an array formula keep only their values. if p.pos == p.anchor { st.attach(p.pos, f, Mode::Cell, Some(*range)); } @@ -519,13 +542,23 @@ fn worksheet( st.unsupported("a shared formula that is missing from the file"); } } + // The other cells of a legacy array formula hold only cached results: dropped, as when an + // .xlsx is read, so that the formula fills its range on recalculation. If the formula itself + // couldn't be imported, they keep their values. + for p in &pending { + let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos)); + if p.pos != p.anchor && filled { + let style = st.sheet.style_id(p.pos); + st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style }); + } + } if st.dropped > 0 { report.warn(format!("{} cells outside the grid were skipped.", st.dropped)); } for why in &st.unsupported { report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); } - Ok(Some(st.sheet)) + Ok(Some((st.sheet, st.calc))) } fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { @@ -594,11 +627,13 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, } 0x0006 => formula_cell(&mut c, st, budget)?, 0x0207 => { - if let Some(pos) = st.pending_string.take() { + if let Some((pos, style)) = st.pending_string.take() { let s = c.string()?; budget.text(s.len())?; if let Some(cell) = st.sheet.cells.get_mut(pos) { cell.value = Value::from(s); + } else { + st.sheet.cells.set(pos, Cell { value: Value::from(s), formula: None, style }); } } } @@ -620,6 +655,16 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 0x000D => { + st.calc.mode = match c.u16()? as i16 { + 0 => CalcMode::Manual, + -1 => CalcMode::AutomaticExceptTables, + _ => CalcMode::Automatic, + } + } + 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), + 0x0011 => st.calc.iterative = c.u16()? == 1, + 0x0010 => st.calc.set_max_change(c.f64()?), 0x0236 => report.warn("What-if data tables are not supported; their values were kept."), 0x00E5 => { let n = usize::from(c.u16()?); @@ -736,7 +781,7 @@ fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Bu st.sheet.cells.set(pos, Cell { value, formula: None, style }); st.last_formula = Some(pos); if string_result { - st.pending_string = Some(pos); + st.pending_string = Some((pos, style)); } // PtgExp: part of a shared or array formula anchored at another cell. if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) { diff --git a/docs/cli.md b/docs/cli.md index 372f43c72..ec9578d60 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -78,6 +78,11 @@ exactly. Skipped, and counted separately: - *volatile*: `RAND`, `RANDARRAY`, `RANDBETWEEN`, `NOW`, `TODAY`, `INFO`, `CELL("filename")`, names that use them, and every formula that reads such a cell (through the dependency graph). +A logical result the producer cached as the number 1 or 0, without `t="b"` (Business Central's "Open in Excel" +exports write `TRUE()1`), holds the same value: it matches GridCraft's TRUE or FALSE and is also +counted as *booleans saved as 1/0* (`boolAsNumber`). Excel types logical results, so on files it saved this count +should be 0; a non-zero count there would point at a formula where GridCraft returns a logical and Excel a number. + Each mismatch lists the cell, formula, expected (cached) and actual (GridCraft) values and the functions the formula calls. Mismatches roll up by function: *checked* (formula cells calling it), *mismatched*, and *primary* (mismatched while none of the cell's precedents mismatch — the likely culprit, as opposed to a downstream effect). They also roll @@ -85,6 +90,12 @@ up by *signature*, `expected type → actual type` (e.g. `number → #SPILL!`), **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is validated (every part has a content type, every content-type override and internal relationship target exists, relationship ids are +unique: what Excel would "repair"), compared with the source (no kind of relationship it had, such as `xmlMaps`, is +missing; a calculation chain doesn't count, Excel rebuilds it, nor do a link to a place in the workbook and a +relationship to an empty part, such as a comments part without comments) and read back. Categories compared: `package`, `sheets` (names and order), +`values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (rules +per sheet), `validations`, `charts` (kinds and series counts), `comments`, `hyperlinks`, `pivots`. `#SPILL!`/`#CALC!` +compare equal to `#VALUE!` and `#CIRC!` to 0, as that's what the file format stores. unique: what Excel would "repair") and read back. Categories compared: `package`, `sheets` (names and order), `values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (each sheet's rules in priority order, priority numbers aside), `validations`, `charts` (kinds and series counts), `comments`, @@ -111,6 +122,7 @@ added, a changed meaning bumps the version): "calc": { "formulaCells": 3, "checked": 2, "matched": 1, "mismatched": 1, "noOracle": 1, "volatile": 0, "staleCache": false, // fullCalcOnLoad: nothing compared + "boolAsNumber": 0, // matched TRUE/FALSE results the producer saved as 1/0 (no t="b") "mismatches": [{"cell": "Sheet1!B1", "formula": "=SUM(A1:A2)", "expected": 7, "actual": 6, "kind": "numeric", // numeric | text | bool | error | blank (GridCraft empty) | type "functions": ["SUM"], "primary": true}], @@ -124,7 +136,7 @@ added, a changed meaning bumps the version): } }], "totals": {"files": 1, "opened": 1, "failedToOpen": 0, "formulaCells": 3, "checked": 2, "matched": 1, - "mismatched": 1, "noOracle": 1, "volatile": 0, "roundTripFailed": 0, "roundTripDiffering": 1, + "mismatched": 1, "noOracle": 1, "volatile": 0, "boolAsNumber": 0, "roundTripFailed": 0, "roundTripDiffering": 1, "categoriesDiffering": {"values": 1}}, "functions": [{"name": "SUM", "checked": 1, "mismatched": 1, "primary": 1}], // all files, most mismatched first "signatures": {"number → number": 1} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 83c1849c2..0436d96cc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,5 +1,9 @@ # File-format parity +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` array formulas fill their range on recalculation) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (macOS Open With offers GridCraft for `.xlsb` and `.ods`) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. @@ -13,6 +17,13 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and ## Workbook formats +Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point +captions or private metadata. Supported numeric polylines reopen as editable ink, retaining points, +colour, integer width and anchor. Curves, filled paths, transforms and unsupported line styles are +not interpreted as ink. This does not implement Office InkML or fix inserted icons (#551). +Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering +and repair-free opening remain unverified. + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -22,7 +33,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -34,13 +45,13 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | -| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table) | Formatting not exported | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | | `.xml` with XML maps | Developer ▸ Import/Export | no | no | | @@ -78,6 +89,10 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | +| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | +| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls | +| 2026-10-12 | minor | `.xls` import: the other cells of an array formula ({=FREQUENCY(...)}, {=TREND(...)}) drop their cached results, as `.xlsx` import does, so the formula fills its range on recalculation instead of showing `#SPILL!` against its own old values; 79 fewer mismatching formulas and 16 fewer round-trip differences over 4,224 real-world files | | 2026-10-12 | minor | macOS: the app bundle declares `.xlsb` and `.ods`, so Finder's Open With offers GridCraft for them, as Linux and Windows already did (#321) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | diff --git a/docs/gaps.md b/docs/gaps.md index 667dca4f6..540d17a86 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,7 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (ink XLSX preservation; icons still pending) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (§8: PivotTable values on rows) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -79,7 +80,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, - `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields done). + `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields + and values on rows done). - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -105,6 +107,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) +- `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the + FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h. - Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and Limits reports; AllDifferent constraints, the Solver Options dialog, the Sensitivity and @@ -114,8 +118,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. - Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging - margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. + Margins are dragged on the rulers (#792). Remaining: pictures in headers/footers, Different First Page / Odd & Even pages. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -129,6 +132,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). ### 17. Insert objects (F) +- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their + identity/artwork on save (#551); native Office InkML import is not implemented. - SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h. @@ -164,6 +169,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | +| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | +| 2026-10-12 | minor | §17: ink geometry survives XLSX saves; the icon portion of #551 remains | +| 2026-10-12 | minor | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e7d1de9f7..bbcead7fd 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,7 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ribbon catalog added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Brazilian Portuguese catalog: dialogs, menus and tooltips) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -14,7 +15,8 @@ right-to-left sheets and full complex-script support. Part of system language (closes #20) and a View-tab switcher, with English-keyed lookup tables: Japanese 268 labels, Simplified Chinese, Korean, Russian, Brazilian Portuguese, German and Czech 266 each. The tables are used by - the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus, + the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells + dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus, context menus, task panes, status messages, command labels and function descriptions are still English literals. Untranslated labels fall back to English. - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command @@ -48,7 +50,7 @@ separators, number/date formats, machine translation with agent review, and scri | Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h | | Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h | -| Portuguese | pt-BR | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | +| Portuguese | pt-BR | 923 (~42%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h | | Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display | no | menus only | 8–12 h | | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | @@ -77,6 +79,8 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | +| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 45ce87947..5b4fa6daf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,7 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (PivotTables: values on rows) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -84,7 +85,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | -| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | +| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | @@ -301,7 +302,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land average, colour), tables with totals rows, remove duplicates, text to columns, data validation with error alerts, grouping/outline, subtotals. - PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts, - date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip. + values on rows or columns, date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip. - Page layout & print: Page Break Preview, print settings, PDF export and printing. - What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus. - Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded @@ -332,6 +333,8 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | +| 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 65de55ebf..e68240702 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,9 @@ # UI parity > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the File view's recent list tells when each workbook was last changed) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Alt+Down opens AutoFilter header menus) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (ribbon and menu Paste paste the system clipboard like Cmd/Ctrl+V; regression test for the Paste button freeze, #273; Paste button gets an accessible name) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -21,14 +24,14 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction @@ -117,6 +120,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Alt+V | Cmd+Control+V, Cmd+Option+V | Paste Special | **added** | | Ctrl+Alt+= / Ctrl+Alt+- | — | zoom in / out (10% steps) | **added** | | Alt+=, Alt+Down, Alt+Page Down / Up | Option+Down, Option+Page Down / Up | AutoSum, drop-down list, one screen right / left | **added** | +| Alt+Down on a filter header | Option+Down | Open the existing AutoFilter popup; validation dropdowns keep priority | **added** (AutoFilter portion of #441; Ribbon dropdowns and column value picker are separate work) | | Alt+F1, Alt+Shift+F1, Shift+F11 | Shift+F11 | embedded chart, new sheet | **added** | | Shift+F2, Ctrl+Shift+F2 | Shift+F2, Cmd+Shift+F2 | note, threaded comment | **added** | | Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** | @@ -161,6 +165,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | +| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | +| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | | 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) |