Merge main into #585

# Conflicts:
#	crates/chart/src/tests.rs
This commit is contained in:
Brandon Thomas 2026-10-13 00:07:17 -04:00
commit 588e289901
136 changed files with 8588 additions and 854 deletions

View File

@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

52
Cargo.lock generated
View File

@ -742,6 +742,22 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"egui",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -1480,9 +1496,10 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"arboard",
"craft-print",
"eframe",
"egui",
"fmv-macos-events",
@ -1491,6 +1508,8 @@ dependencies = [
"image",
"log",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"pollster 1.0.1",
"rfd",
"serde_json",
@ -1499,7 +1518,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@ -1511,7 +1530,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@ -1522,7 +1541,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@ -1534,7 +1553,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"js-sys",
"serde",
@ -1544,7 +1563,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@ -1565,7 +1584,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"serde",
@ -1574,7 +1593,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"regex",
@ -1582,7 +1601,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-engine",
"log",
@ -1591,7 +1610,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@ -1603,19 +1622,20 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.5.0"
version = "0.6.1"
[[package]]
name = "gridcraft-ui-egui"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"epaint_default_fonts",
@ -1635,7 +1655,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@ -1652,7 +1672,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"aes",
"gridcraft-core",
@ -4506,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"gridcraft-engine",
"serde_json",

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"

View File

@ -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-11 · **Change:** minor (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **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,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 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) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
@ -177,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |

View File

@ -97,6 +97,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<Mismatch>,
pub functions: BTreeMap<String, FnStat>,
@ -347,6 +350,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 (`<c><f>TRUE()</f><v>1</v></c>`, 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 +468,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;
@ -559,6 +573,14 @@ fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet<String>, b: &BTreeSet<Stri
}
}
/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number
/// itself is left out, so renumbering without changing the order isn't a difference.
fn cond_format_list(s: &Sheet) -> Vec<String> {
let mut cfs: Vec<_> = s.cond_formats.iter().collect();
cfs.sort_by_key(|c| c.priority);
cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect()
}
fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) {
if a != b {
d.push(|| format!("{sheet}: {a} → {b}"));
@ -634,7 +656,12 @@ pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec<Categor
.collect()
};
diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb));
diff_count(&mut cond_formats, &name, sa.cond_formats.len(), sb.cond_formats.len());
let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb));
if cfa.len() != cfb.len() {
cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len()));
} else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) {
cond_formats.push(|| format!("{name}: {x} → {y}"));
}
diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len());
let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::<Vec<_>>();
let (ca, cb) = (chart_list(sa), chart_list(sb));
@ -901,6 +928,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>,
@ -922,6 +950,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;
}
@ -982,7 +1011,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},
@ -996,7 +1026,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),
@ -1035,6 +1065,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!(
@ -1131,6 +1164,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<String> = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect();
outln!(
"round trip: {} of {} files differ{}{}",
@ -1235,6 +1271,31 @@ mod tests {
assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
}
/// Business Central writes TRUE and FALSE as `<c><f>TRUE()</f><v>1</v></c>`, 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 =
@ -1303,6 +1364,27 @@ mod tests {
assert_eq!(get("tables").diffs, 0);
}
#[test]
fn diff_detects_changed_cond_format_rule() {
use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat};
let bar = |gradient: bool, priority: u32| CondFormat {
ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()],
rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true },
priority,
stop_if_true: false,
};
let with = |cf: CondFormat| {
let mut wb = book(&[("A1", "2", n(2.0))]);
wb.sheet_mut(0).unwrap().cond_formats.push(cf);
wb
};
let diffs =
|a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs;
assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0);
}
fn zip_of(parts: &[(&str, &str)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, body) in parts {
@ -1362,7 +1444,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"]));
@ -1401,6 +1495,7 @@ mod tests {
"mismatched",
"noOracle",
"volatile",
"boolAsNumber",
"roundTripFailed",
"roundTripDiffering",
"categoriesDiffering"

View File

@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] }

View File

@ -0,0 +1,166 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//!
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
//! as PDF. The parent stores it and reports failures itself.
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the page
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
/// counts as saved under that name, as it would after Save on the desktop.
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// Names of the files handed to the parent since the last frame.
saved: Rc<RefCell<Vec<String>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
}
/// Send saved and exported files to the parent instead of downloading them, and hear files
/// from it.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
if let Err(e) = bridge.post_save(name, bytes) {
log::error!("couldn't hand {name} to the page: {e}");
}
}));
}
/// `?author=`: the host's name for the user signs new notes and comments.
pub fn set_author(app: &mut SheetApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.user_name = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
}
/// After each frame: a workbook handed to the parent this frame counts as saved under the
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
pub fn sync(&self, app: &mut SheetApp) {
let saved = std::mem::take(&mut *self.saved.borrow_mut());
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
&& let Some(doc) = app.session.active_mut()
{
doc.saved = doc.wb.clone();
if doc.path.is_none() {
doc.title = name.clone();
}
}
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
}
fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
self.saved.borrow_mut().push(name);
Ok(())
}
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("gridcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn is_workbook(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}

View File

@ -8,9 +8,14 @@
//! (bytes arrive asynchronously through `Services::inbox`); Save downloads the file; dropped
//! files are read asynchronously.
//!
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample.
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;

View File

@ -25,6 +25,10 @@ pub fn start() {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
// workbooks in it and stores what it saves (`host`).
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
let app_bridge = bridge.clone();
let result = eframe::WebRunner::new()
.start(
canvas,
@ -32,13 +36,18 @@ pub fn start() {
Box::new(move |cc| {
let inbox: Inbox = Inbox::default();
let mut session = Session::new();
let q = query();
// In host mode the parent sends the workbook to edit; no sample meanwhile.
let q = if app_bridge.is_some() { String::new() } else { query() };
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
let _ = session.execute("file.new", serde_json::json!({"sample": name}));
} else if q.contains("sample") {
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
}
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = SheetApp::new(session, services);
app.ui.system_theme = true;
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
@ -50,10 +59,19 @@ pub fn start() {
app.ui.language = browser;
}
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
Ok(Box::new(WebShell { app, inbox }))
if let Some(bridge) = &app_bridge {
if let Some(author) = query_param("author") {
crate::host::Bridge::set_author(&mut app, &author);
}
bridge.post_ready();
}
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
}),
)
.await;
if let (Err(e), Some(bridge)) = (&result, &bridge) {
bridge.post_failed(&format!("{e:?}"));
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@ -67,9 +85,14 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
struct WebShell {
app: SheetApp,
inbox: Inbox,
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
@ -98,6 +121,9 @@ impl eframe::App for WebShell {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
if let Some(bridge) = &self.bridge {
bridge.sync(&mut self.app);
}
}
}

View File

@ -24,6 +24,7 @@ image = { workspace = true }
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
rfd = { workspace = true }
arboard = { workspace = true }
@ -41,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

View File

@ -16,6 +16,7 @@ mod graphics;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod print;
use std::cell::RefCell;
use std::rc::Rc;
@ -39,6 +40,10 @@ impl eframe::App for App {
apple_events::poll(&self.2, &mut self.0, ctx);
}
self.0.logic(ctx);
self.0.poll_printing(ctx);
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
@ -53,6 +58,7 @@ impl eframe::App for App {
self.0.ui(ui);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
save_prefs(&self.0);
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown;
@ -129,10 +135,22 @@ fn save_prefs(app: &SheetApp) {
}
}
/// Read the system clipboard through the shared handle, creating it on first use.
fn read_clipboard<T>(
handle: &RefCell<Option<arboard::Clipboard>>,
read: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
) -> Option<T> {
let mut clipboard = handle.try_borrow_mut().ok()?;
if clipboard.is_none() {
*clipboard = arboard::Clipboard::new().ok();
}
read(clipboard.as_mut()?).ok()
}
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let paste_clipboard = clipboard.clone();
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
Services {
copy_html: Some(Box::new(move |html, text| {
let mut clipboard = clipboard.borrow_mut();
@ -141,13 +159,8 @@ fn services() -> Services {
}
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
})),
paste_html: Some(Box::new(move || {
let mut clipboard = paste_clipboard.try_borrow_mut().ok()?;
if clipboard.is_none() {
*clipboard = arboard::Clipboard::new().ok();
}
clipboard.as_mut()?.get().html().ok()
})),
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
@ -180,12 +193,8 @@ fn services() -> Services {
dialog.save_file().and_then(|p| p.to_str().map(str::to_string))
})),
open_url: Some(Box::new(|url: &str| open_with_system(url))),
// File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print
// command reaches the printer dialog (GridCraft has no printer driver of its own).
print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) {
Ok(path) => open_with_system(&path.to_string_lossy()),
Err(e) => log::warn!("print: {e}"),
})),
printers: Some(print::discover()),
print_native: Some(print::submit()),
..Default::default()
}
}
@ -204,32 +213,6 @@ fn open_with_system(target: &str) {
}
}
/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the
/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively
/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable
/// only by the user.
fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
use std::io::Write;
static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
for _ in 0..64 {
let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
match options.open(&path) {
Ok(mut file) => {
file.write_all(bytes)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name"))
}
fn icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
@ -395,31 +378,30 @@ fn main() -> eframe::Result<()> {
}
#[cfg(test)]
mod print_tests {
mod packaging_tests {
use gridcraft_engine::io::FileKind;
/// The text between `start` and `end` that follows each `marker` in `text`, sorted (e.g. the
/// `xlsb` in `Classes\.xlsb\OpenWithProgids`).
fn extensions(text: &str, marker: &str, start: &str, end: char) -> Vec<String> {
let mut v: Vec<String> = text.split(marker).skip(1).filter_map(|s| s.split_once(start)?.1.split(end).next()).map(str::to_string).collect();
v.sort();
v
}
/// macOS Open With (`CFBundleDocumentTypes`) offers GridCraft for the same files as Windows
/// Open with, and GridCraft opens each of them. #321 added `.xlsb` and `.ods` on Windows and
/// Linux only.
#[test]
fn print_pdfs_get_fresh_names_in_the_given_folder() {
let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap();
let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap();
assert_ne!(a, b);
assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf"));
assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b");
assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err());
// A file (or symlink) already at the next name is skipped, never overwritten.
let pid = std::process::id();
let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect();
for p in &taken {
std::fs::write(p, b"keep").unwrap();
fn macos_and_windows_offer_the_same_file_types() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let wxs = include_str!("../../../packaging/windows/gridcraft.wxs");
let mac = extensions(plist, "<key>CFBundleTypeExtensions</key>", "<string>", '<');
let win = extensions(wxs, "Key=\"Software\\Classes\\.", "", '\\');
assert!(mac.iter().any(|e| e == "xlsx"), "Info.plist.in extensions not found: {mac:?}");
assert_eq!(mac, win);
for ext in &mac {
assert!(FileKind::from_path(&format!("book.{ext}")).is_some(), "GridCraft doesn't open .{ext}");
}
let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap();
assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep"));
assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it");
}
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("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| {
@ -298,6 +309,8 @@ impl NativeMenu {
app.open_dialog(name, params);
} else if id == "edit.copy" || id == "edit.cut" {
app.copy_to_clipboard(ctx, &id);
} else if id == "edit.paste" {
app.paste_from_clipboard();
} else {
app.run_or_alert(&id, json!({}));
}
@ -315,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());

View File

@ -0,0 +1,75 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let _ = sender.send(printers);
});
receiver
})
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
}
}
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
copies: settings.copies.clamp(1, 999),
duplex: settings.duplex.map(|value| match value {
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
Duplex::Off => craft_print::Duplex::Off,
}),
grayscale: settings.grayscale,
scaling: craft_print::Scaling::ShrinkToFit,
title: "GridCraft document".into(),
..Default::default()
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
receiver
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shutdown_waits_for_pending_submission_workers() {
let (release, wait) = std::sync::mpsc::channel();
let (completed, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
wait.recv().unwrap();
completed.send(()).unwrap();
});
JOBS.lock().unwrap().push(worker);
let shutdown = std::thread::spawn(finish_pending_jobs);
assert!(completion.try_recv().is_err(), "the print worker is still pending");
release.send(()).unwrap();
shutdown.join().unwrap();
completion.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
}
}

View File

@ -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<String> = (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() {}

View File

@ -465,9 +465,22 @@ impl<'h> Evaluator<'h> {
}
}
_ => {
let x = self.value(a);
let y = self.value(b);
Ev::V(binary_values(op, &x, &y))
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
// of one level of recursion per operator (past the depth limit it was #CALC!).
let mut rest = vec![(op, b)];
let mut left = a;
while let Expr::Binary(o, l, r) = left
&& !matches!(o, BinOp::Range | BinOp::Union | BinOp::Intersect)
{
rest.push((*o, r.as_ref()));
left = l.as_ref();
}
let mut acc = self.value(left);
while let Some((o, r)) = rest.pop() {
let y = self.value(r);
acc = binary_values(o, &acc, &y);
}
Ev::V(acc)
}
}
}
@ -737,6 +750,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)
@ -860,7 +887,12 @@ impl<'h> Evaluator<'h> {
}
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
if args.len() > l.params.len() {
// Too many or too few argument positions is #VALUE!. An explicitly empty position, as in
// `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()`
// parses to no arguments, which for a one-parameter lambda can also be read as one empty
// position, so that call still binds the parameter as omitted.
let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1);
if !positions_ok {
return err(CellError::Value);
}
let mut bound = Vec::with_capacity(l.params.len());
@ -903,7 +935,26 @@ impl<'h> Evaluator<'h> {
r
}
fn lambda_arg(&mut self, e: Option<&Expr>) -> Option<Arc<Lambda>> {
/// 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<String> {
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<Arc<Lambda>> {
if let Some(f) = e.and_then(|x| self.function_by_name(x)) {
let params: Vec<String> = (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,
@ -922,7 +973,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<Arc<Array>> = 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);
@ -945,7 +996,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);
}
@ -969,7 +1020,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 };
@ -998,7 +1049,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);
}
@ -1054,10 +1105,10 @@ impl<'h> Evaluator<'h> {
if h == 0 || w == 0 {
return err(CellError::Ref);
}
let r0 = base.range.start.row as i64 + rows;
let c0 = base.range.start.col as i64 + cols;
let (r0, r1) = if h > 0 { (r0, r0 + h - 1) } else { (r0 + h + 1, r0) };
let (c0, c1) = if w > 0 { (c0, c0 + w - 1) } else { (c0 + w + 1, c0) };
let r0 = (base.range.start.row as i64).saturating_add(rows);
let c0 = (base.range.start.col as i64).saturating_add(cols);
let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) };
let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) };
if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 {
return err(CellError::Ref);
}
@ -1081,7 +1132,15 @@ impl<'h> Evaluator<'h> {
v.scalar().to_bool().unwrap_or(true)
}
};
let text = if a1 { text } else { r1c1_to_a1(&text, self.at).unwrap_or(text) };
let text = if a1 {
text
} else {
match r1c1_to_a1(&text, self.at) {
Ok(Some(a1_text)) => a1_text,
Ok(None) => text,
Err(e) => return err(e),
}
};
match gridcraft_formula::parse(&text) {
Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) {
r @ Ev::R(_) => r,
@ -1152,7 +1211,8 @@ impl<'h> Evaluator<'h> {
}
match src {
Ev::R(areas) => {
let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) };
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
let r = a.range;
// A single row or column with one index picks along it.
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
@ -1290,7 +1350,9 @@ impl<'h> Evaluator<'h> {
}
fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev {
if args.len() < 2 {
// SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1.
let min_args = if name == "AGGREGATE" { 3 } else { 2 };
if args.len() < min_args {
return err(CellError::Value);
}
let code = match self.arg_num(args, 0, 0.0) {
@ -1315,7 +1377,8 @@ impl<'h> Evaluator<'h> {
// SUBTOTAL documentation); 101-111 also leave out rows hidden by hand.
let skip_filtered = name == "SUBTOTAL";
let skip_errors = matches!(options, 2 | 3 | 6 | 7);
let skip_nested = options <= 3;
// SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3.
let skip_nested = name == "SUBTOTAL" || options <= 3;
let mut values = Vec::new();
let mut k = None;
let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest };
@ -1406,37 +1469,42 @@ fn same_kind(a: &Value, b: &Value) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty())
}
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)).
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option<String> {
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is
/// not R1C1; `Err` means it is R1C1 but points past the sheet.
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result<Option<String>, CellError> {
let (sheet, body) = match s.rsplit_once('!') {
Some((sh, b)) => (format!("{sh}!"), b),
None => (String::new(), s),
};
let conv = |part: &str| -> Option<String> {
let conv = |part: &str| -> Result<Option<String>, CellError> {
let u = part.to_ascii_uppercase();
let rest = u.strip_prefix('R')?;
let (rpart, cpart) = rest.split_once('C')?;
let num = |p: &str, base: u32| -> Option<(u32, bool)> {
if p.is_empty() {
return Some((base, false));
}
if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
let d: i64 = inner.parse().ok()?;
let v = base as i64 + d;
return if v >= 0 { Some((v as u32, false)) } else { None };
}
let v: u32 = p.parse().ok()?;
Some((v.checked_sub(1)?, true))
let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) };
// `None`: not R1C1. `Some(None)`: R1C1, but past the sheet.
let num = |p: &str, base: u32, max: u32| -> Option<Option<(u32, bool)>> {
let (v, absolute) = if p.is_empty() {
(Some(i64::from(base)), false)
} else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
(i64::from(base).checked_add(inner.parse().ok()?), false)
} else {
(p.parse::<u32>().ok()?.checked_sub(1).map(i64::from), true)
};
Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute)))
};
let (r, ra) = num(rpart, at.row)?;
let (c, ca) = num(cpart, at.col)?;
Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))
let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) };
let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) };
Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1)))
};
let out = match body.split_once(':') {
Some((a, b)) => format!("{}:{}", conv(a)?, conv(b)?),
None => conv(body)?,
Some((a, b)) => match (conv(a)?, conv(b)?) {
(Some(a), Some(b)) => format!("{a}:{b}"),
_ => return Ok(None),
},
None => match conv(body)? {
Some(x) => x,
None => return Ok(None),
},
};
Some(format!("{sheet}{out}"))
Ok(Some(format!("{sheet}{out}")))
}
/// The aggregates that can fold a reference without building an array.

View File

@ -898,11 +898,11 @@ impl Calc {
// Spill areas of changed anchors also change.
for &k in changed {
if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) {
seeds.extend(r.iter().take(65536).map(|c| (k.0, c)));
seeds.extend(r.iter().map(|c| (k.0, c)));
}
}
for (si, r) in &cleared {
seeds.extend(r.iter().take(65536).map(|c| (*si, c)));
seeds.extend(r.iter().map(|c| (*si, c)));
}
let t0 = prof_now();
let dirty = self.dirty_closure(wb, &seeds);

View File

@ -157,6 +157,21 @@ fn index_with_arrays_of_positions() {
assert_eq!(t.num("E7"), 6.0);
}
#[test]
fn index_area_number_overflow() {
let mut t = T::new();
t.set("A1", "7");
t.set("D1", "9");
for (area, expected) in [("1", 7.0), ("2", 9.0)] {
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
assert_eq!(t.num("G5"), expected, "area {area}");
}
for area in ["3", "1E300", "-1", "-1E300"] {
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
assert_eq!(t.get("G5"), Value::Error(CellError::Ref), "area {area}");
}
}
#[test]
fn errors_and_cycles() {
let mut t = T::new();
@ -167,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));
}
@ -412,6 +432,37 @@ fn references_functions() {
assert_eq!(t.num("B6"), 105.0);
}
#[test]
fn indirect_r1c1_out_of_bounds() {
for reference in ["R[9223372036854775807]C", "RC[9223372036854775807]", "R[4294967296]C", "R[4294967291]C", "R[2000000]C", "RC[20000]"] {
let mut t = T::new();
t.set("D5", &format!("=ROW(INDIRECT(\"{reference}\",FALSE))"));
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
}
}
#[test]
fn indirect_r1c1_past_the_sheet_does_not_fall_back_to_a1() {
let mut t = T::new();
t.set("RC16385", "78");
for reference in ["RC16385", "rc16385", "RC1:RC16385"] {
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
}
}
#[test]
fn indirect_r1c1_valid_references() {
let mut t = T::new();
t.set("E6", "11");
t.set("A1", "22");
t.set("D4", "33");
for (reference, expected) in [("R[1]C[1]", 11.0), ("R1C1", 22.0), ("R[-1]C", 33.0)] {
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
assert_eq!(t.num("D5"), expected, "{reference}");
}
}
#[test]
fn relative_names() {
let mut t = T::new();
@ -501,6 +552,56 @@ 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);
fn lambda_argument_count() {
let mut t = T::new();
t.wb.names.push(gridcraft_model::DefinedName {
name: "AddPair".into(),
scope: None,
formula: "LAMBDA(a,b,a+b)".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
let omit = "LAMBDA(x,y,IF(ISOMITTED(y),\"Missing\",x+y))";
t.set("A1", "=LAMBDA(a,b,a+b)(2)");
t.set("A2", "=LAMBDA(a,b,a+b)(2,3,4)");
t.set("A3", &format!("={omit}(1,)"));
t.set("A4", &format!("={omit}(1)"));
t.set("A5", &format!("={omit}(1,2)"));
t.set("A6", "=LAMBDA(7)()");
t.set("A7", "=AddPair(2)");
t.set("A8", "=AddPair(2,3)");
assert_eq!(t.get("A1"), Value::Error(CellError::Value));
assert_eq!(t.get("A2"), Value::Error(CellError::Value));
assert_eq!(t.get("A3"), Value::Text("Missing".into()));
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
assert_eq!(t.num("A5"), 3.0);
assert_eq!(t.num("A6"), 7.0);
assert_eq!(t.get("A7"), Value::Error(CellError::Value));
assert_eq!(t.num("A8"), 5.0);
t.set("A9", "=LAMBDA(x,ISOMITTED(x))()");
assert_eq!(t.get("A9"), Value::Bool(true));
}
#[test]
fn names_and_sheets() {
let mut t = T::new();
@ -549,6 +650,17 @@ fn text_function() {
assert_eq!(t.get("A1"), Value::from("1,234.50"));
}
#[test]
fn long_operator_chains() {
let mut t = T::new();
t.set("A1", "1");
// 1,000 additions: a formula of 3,000 characters, well within Excel's 8,192.
t.set("B1", &format!("={}", vec!["A1"; 1000].join("+")));
assert_eq!(t.num("B1"), 1000.0);
t.set("B2", &format!("=5{}", "+1-1".repeat(600)));
assert_eq!(t.num("B2"), 5.0);
}
#[test]
fn subtotal_skips_hidden() {
let mut t = T::new();
@ -562,6 +674,29 @@ fn subtotal_skips_hidden() {
assert_eq!(t.num("B2"), 6.0);
}
#[test]
fn subtotal_109_skips_nested_subtotal() {
let mut t = T::new();
t.set("A1", "2");
t.set("A2", "=SUBTOTAL(9,A1)");
t.set("C1", "=SUBTOTAL(109,A1:A2)");
t.set("C2", "=SUBTOTAL(9,A1:A2)");
t.set("C3", "=SUBTOTAL(109,A1)");
assert_eq!(t.num("C1"), 2.0);
assert_eq!(t.num("C2"), 2.0);
assert_eq!(t.num("C3"), 2.0);
// Manually hidden rows are still left out by 109 only.
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
t.set("A3", "=SUBTOTAL(9,A1:A2)");
t.wb.sheet_mut(0).unwrap().rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
t.set("B1", "=SUBTOTAL(109,A1:A3)");
t.set("B2", "=SUBTOTAL(9,A1:A3)");
assert_eq!(t.num("B1"), 1.0);
assert_eq!(t.num("B2"), 3.0);
}
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
/// under a filtered table adds only the visible rows).
@ -608,6 +743,23 @@ fn subtotal_skips_filtered_rows() {
}
}
#[test]
fn aggregate_without_data_argument_is_an_error() {
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
t.set("D1", "=AGGREGATE(9,0)");
t.set("D2", "=AGGREGATE(4,0)");
t.set("D3", "=AGGREGATE(9,0,A1:A2)");
t.set("D4", "=AGGREGATE(9,0,A3:A4)");
t.set("D5", "=AGGREGATE(9,0,A1:A2,A1:A2)");
assert_eq!(t.get("D1"), Value::Error(CellError::Value));
assert_eq!(t.get("D2"), Value::Error(CellError::Value));
assert_eq!(t.num("D3"), 3.0);
assert_eq!(t.num("D4"), 0.0);
assert_eq!(t.num("D5"), 6.0);
}
#[test]
fn recalc_all_after_load() {
let mut t = T::new();
@ -968,3 +1120,32 @@ fn trim_references() {
t.set("K20", "=SUM(Q1:.Q9)");
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
}
#[test]
fn offset_extreme_arguments_return_ref() {
let wb = Workbook::new();
for formula in [
"=OFFSET(B2,1E300,0)",
"=OFFSET(B2,0,1E300)",
"=OFFSET(B2,0,0,1E300,1)",
"=OFFSET(B2,0,0,1,1E300)",
"=OFFSET(B2,-1E300,0)",
"=OFFSET(B2,0,-1E300)",
"=OFFSET(B2,0,0,-1E300,1)",
"=OFFSET(B2,0,0,1,-1E300)",
"=OFFSET(B2,1E300,0,-1E300,1)",
"=OFFSET(B2,0,1E300,1,-1E300)",
"=OFFSET(B2,-1E300,0,-1E300,1)",
] {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Error(CellError::Ref), "{formula}");
}
for (formula, expected) in [
("=ROW(OFFSET(B2,1.9,0))", 3.0),
("=COLUMN(OFFSET(B2,0,-1.9))", 1.0),
("=ROWS(OFFSET(B2,0,0,-2,1))", 2.0),
("=COLUMNS(OFFSET(B2,0,0,1,-2))", 2.0),
("=ROWS(OFFSET(A1,0,0,1048576,1))", 1048576.0),
] {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}

View File

@ -138,7 +138,8 @@ fn circle(cx: f32, cy: f32, r: f32) -> Vec<[f32; 2]> {
}
fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> {
let r = r.clamp(0.0, w.min(h) / 2.0);
// A negative or NaN extent gives an upper bound of 0 (clamp panics if max < min or NaN).
let r = r.clamp(0.0, (w.min(h) / 2.0).max(0.0));
if r < 0.5 {
return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
}

View File

@ -971,6 +971,8 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
let nslots = slot_keys.len().max(1) as f32;
let bw = cw / (nslots + 1.5);
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
let mut area_labels = Vec::new();
// Areas first, then columns, then lines on top.
for pass in 0..3 {
@ -999,6 +1001,13 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
let c = center(j);
top.push([c, vpos(t, &sc)]);
base.push([c, vpos(b, &sc)]);
// Source values (not stack totals), just above the top edge like the line labels.
if labels_on
&& sp.is_some()
&& let Some(v) = s.values.get(j).copied().flatten()
{
area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None)));
}
}
if top.len() == 1 {
// A single point: give it some width.
@ -1073,6 +1082,9 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
}
}
}
for (x, y, t) in area_labels.drain(..) {
ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
}
}
// Trendlines on top: x is the category number 1..n.
let mut trend_labels = Vec::new();
@ -1202,10 +1214,12 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
bins = 1;
}
let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 };
// Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value
// equal to an edge belongs to the bin that ends there.
let edges: Vec<f64> = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect();
let mut counts = vec![0usize; bins];
for v in &vals {
let b = if width > 0.0 { ((v - lo) / width).floor() } else { 0.0 };
let b = if b.is_finite() && b > 0.0 { (b as usize).min(bins - 1) } else { 0 };
let b = edges.partition_point(|e| e < v).min(bins - 1);
if let Some(c) = counts.get_mut(b) {
*c += 1;
}
@ -1214,7 +1228,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let labels: Vec<String> = (0..bins)
.map(|b| {
let a = lo + b as f64 * width;
let e = if b + 1 == bins { hi.max(a) } else { a + width };
let e = edges.get(b).copied().unwrap_or(a);
let open = if b == 0 { '[' } else { '(' };
format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0)))
})

View File

@ -421,6 +421,16 @@ fn rasterize_bar_center_has_bar_color() {
assert!(rasterize(&[], 100_000, 100_000, [0; 4]).is_empty());
}
#[test]
fn rasterize_does_not_panic_on_negative_rect_extents() {
let red = [255, 0, 0, 255];
for (w, h) in [(-10.0, 10.0), (10.0, -10.0), (-10.0, -10.0), (f32::NAN, f32::NAN)] {
let rect = Prim::Rect { x: 5.0, y: 5.0, w, h, fill: Some(red), stroke: Some((red, 2.0)), radius: 0.0 };
let img = rasterize(&[rect], 20, 20, [0, 0, 0, 0]);
assert_eq!(img.len(), 20 * 20 * 4);
}
}
#[test]
fn pie_starts_at_twelve_clockwise() {
let c = chart(ChartKind::Pie);
@ -888,6 +898,28 @@ fn treemap_and_histogram_shapes() {
assert_eq!(bins, 10);
}
#[test]
fn histogram_boundary_values_match_interval_labels() {
let heights = |vals: &[f64]| -> Vec<f32> {
let d = ChartData { 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()
};
// Labels are "[0, 7.5]" and "(7.5, 15.0]": 7.5 belongs to the first bar, so the counts are 2 and 1.
let h = heights(&[0.0, 7.5, 15.0]);
assert_eq!(h.len(), 2);
assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}");
let h = heights(&[-15.0, -7.5, 0.0]);
assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}");
// A value off the boundary still goes right: [0, 7.5] holds 1, (7.5, 15.0] holds 2.
let h = heights(&[0.0, 7.75, 15.0]);
assert!((h[1] / h[0] - 2.0).abs() < 0.01, "{h:?}");
// Five values, three bins [0,5] (5,10] (10,15]: counts 3, 1, 1.
let h = heights(&[0.0, 5.0, 5.0, 10.0, 15.0]);
assert_eq!(h.len(), 3);
assert!((h[0] / h[1] - 3.0).abs() < 0.01 && (h[1] - h[2]).abs() < 0.01, "{h:?}");
}
#[test]
fn union_series_references() {
let mut wb = Workbook::new();
@ -957,4 +989,27 @@ fn pie_points_follow_workbook_theme() {
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));
fn area_charts_draw_source_value_labels_only_when_enabled() {
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
let d = sample(kind);
let mut c = chart(kind);
c.data_labels = false;
let off = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
c.data_labels = true;
let on = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let count = |p: &[Prim], v: &str| texts(p).iter().filter(|t| **t == v).count();
// One label per point: 3 series x 6 categories.
assert_eq!(texts(&on).len(), texts(&off).len() + 18, "{kind:?}");
for v in ["$12,000", "$38,000", "$9,000", "$27,000"] {
assert_eq!(count(&on, v), count(&off, v) + 1, "{kind:?}: {v}");
}
// Stacked areas label the source values, not cumulative totals (12,000 + 38,000).
assert_eq!(count(&on, "$50,000"), count(&off, "$50,000"), "{kind:?}: cumulative total shown");
let polys = |p: &[Prim]| p.iter().filter(|p| matches!(p, Prim::Polygon { .. })).cloned().collect::<Vec<_>>();
assert_eq!(polys(&on), polys(&off));
// The first series' labels are drawn after every area, so the next area can't cover them.
let last_poly = on.iter().rposition(|p| matches!(p, Prim::Polygon { .. }));
let first_label = on.iter().position(|p| matches!(p, Prim::Text { text, .. } if text == "$12,000"));
assert!(first_label > last_poly, "{kind:?}: label drawn under a later area");
}
}

View File

@ -59,6 +59,14 @@ pub fn serial_from_unix_ms(ms: f64) -> f64 {
25569.0 + ms / 86_400_000.0
}
/// Converts a 1900-system serial (as the clock supplies it) to the serial of the same instant in `sys`.
pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
match sys {
DateSystem::D1900 => serial_1900,
DateSystem::D1904 => serial_1900 - 1462.0,
}
}
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
@ -95,6 +103,14 @@ pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 {
if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) }
}
/// Last serial that is a valid date in a date system: 9999-12-31 (2958465 in the 1900 system, 2957003 in the 1904 system).
pub fn max_serial(sys: DateSystem) -> i64 {
match sys {
DateSystem::D1900 => 2_958_465,
DateSystem::D1904 => 2_958_465 - 1462,
}
}
/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next
/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900
/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60.
@ -117,7 +133,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
DateSystem::D1904 => first - UNIX_1904_01_01,
};
let serial = first.checked_add(day)?.checked_sub(1)?;
if !(0..=2_958_465).contains(&serial) {
if !(0..=max_serial(sys)).contains(&serial) {
return None;
}
Some(serial as f64)
@ -125,7 +141,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
/// Date/time from a serial. `None` for negative or too-large serials.
pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) {
if !serial.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&serial) {
return None;
}
let mut day = serial.floor() as i64;
@ -197,6 +213,10 @@ mod tests {
assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0));
assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0));
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 31), Some(2957003.0));
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 32), None);
assert!(datetime_from_serial(DateSystem::D1904, 2957003.5).is_some());
assert!(datetime_from_serial(DateSystem::D1904, 2957004.0).is_none());
assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1));
assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29));
assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1));

View File

@ -252,10 +252,14 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
// "Mar 4, 2026" / "March 4 2026" / "4 March 2026"
let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect();
if words.len() == 3 {
// Two-digit years follow the same 2029 rule as the dashed forms above.
let expand = |w: &str, y: i64| if w.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) {
let y = expand(words[2], y);
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
}
if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) {
let y = expand(words[2], y);
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
}
}
@ -317,6 +321,16 @@ mod tests {
parse_input(s, DateSystem::D1900)
}
#[test]
fn month_name_short_year() {
for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
assert_eq!(p(s).value, Value::Number(46085.0), "{s}");
}
assert_eq!(p("Mar 4, 99").value, p("Mar 4, 1999").value);
assert_eq!(p("Mar 4, 1776").value, Value::text("Mar 4, 1776"));
assert_eq!(p("Feb 30, 26").value, Value::text("Feb 30, 26"));
}
#[test]
fn numbers() {
assert_eq!(p("42").value, Value::Number(42.0));

View File

@ -184,7 +184,7 @@ impl CfCache {
},
CfRule::TimePeriod { period, style } => match v.as_f64() {
Some(n) => {
let today = gridcraft_calc::now_serial().floor();
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let d = n.floor();
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
let week_start = today - wd;

View File

@ -198,27 +198,29 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let by_cols = str_param(p, "orientation") == Some("columns");
let case = bool_param(p, "matchCase").unwrap_or(false);
let keys: Vec<SortKey> = p
.get("keys")
.and_then(Json::as_array)
.map(|a| {
a.iter()
.filter_map(|k| {
let col = if by_cols {
k.get("row").and_then(Json::as_u64).map(|r| r as u32 - 1).or_else(|| col_param(k.get("column"), r.start.row))?
} else {
col_param(k.get("column"), r.start.col)?
};
Some(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
})
})
.collect()
})
.unwrap_or_default();
let mut keys: Vec<SortKey> = Vec::new();
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
let col = if by_cols {
let row = match k.get("row").and_then(Json::as_u64) {
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
Some(n) => {
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
}
None => None,
};
row.or_else(|| col_param(k.get("column"), r.start.row))
} else {
col_param(k.get("column"), r.start.col)
};
let Some(col) = col else { continue };
keys.push(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
});
}
if keys.is_empty() {
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
return ok();
@ -454,14 +456,24 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
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<u32> = 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}))
});
}
install_filter(s, p)
}
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let mut r = data_range(s, p)?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
@ -484,7 +496,7 @@ fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let af = match &sh.autofilter {
Some(a) => a.clone(),
None => {
toggle_filter(s, &json!({}))?;
install_filter(s, &json!({}))?;
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
}
};
@ -570,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<u32, (Vec<f64>, 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<usize, (Option<f64>, 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<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
let mut nums: Vec<f64> = (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::<f64>() / 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);
@ -604,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
@ -896,7 +913,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
}
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
pub(super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
@ -1046,6 +1063,18 @@ fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
}
}
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
if first == to {
return f.to_string();
}
let moved = formula_at(f, first, to);
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
let eq = if f.starts_with('=') { "=" } else { "" };
if has_relative { format!("{eq}{moved}") } else { f.to_string() }
}
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
let Some(a) = a else { return false };
match op {
@ -1278,6 +1307,24 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap();
s.execute("selection.set", sel.clone()).unwrap();
s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}");
assert!(sh.tables[0].filter_button, "{sel}");
assert!(!sh.is_row_hidden(1), "{sel}");
assert!(sh.is_row_hidden(2), "{sel}");
assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}");
}
}
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
@ -1300,6 +1347,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<u32> {
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::<Vec<_>>(), [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();
@ -1375,6 +1461,18 @@ mod tests {
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
for row in [0u64, 1 << 32] {
let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]});
let err = s.execute("data.sort", params).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}");
}
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);

View File

@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
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())
@ -1111,6 +1101,20 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
@ -1583,6 +1587,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
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();

View File

@ -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<Workbook>
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
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});
@ -743,6 +749,17 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(z) = v["zoom"].as_u64() {
s.execute("view.zoom", json!({"percent": z}))?;
}
if let Some(f) = v.get("freeze") {
let freeze = match (f[0].as_u64(), f[1].as_u64()) {
(Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()),
_ => None,
};
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = freeze;
Ok(())
})?;
}
} else if let Some(name) = str_param(p, "delete") {
if let Some(o) = views.as_object_mut() {
o.remove(name);
@ -812,7 +829,7 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = cx.sheet_mut(si)?;
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row {
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
return Err(EngineError::Other(
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
));
@ -938,7 +955,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
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}))
@ -977,7 +994,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
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);
}
@ -1052,8 +1069,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// 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<Json> {
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());
}
@ -1074,7 +1096,7 @@ fn background(s: &mut Session, p: &Json) -> Result<Json> {
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)
@ -1090,7 +1112,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
"outline" => "outside",
_ => "all",
};
let mut params = p.clone();
let mut params = object_params(p);
params["preset"] = json!(preset);
s.execute("home.borders", params)
}

View File

@ -75,7 +75,11 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
{
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<Json> {
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());
}

View File

@ -574,6 +574,16 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
let m = RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col));
if !m.is_single() {
sh.merges.push(m);
// As below: only the first value of each row survives.
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
for c in others {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
touched.push((sheet, c));
}
}
}
}
@ -1112,6 +1122,19 @@ fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn merge_across_keeps_the_first_value_of_each_row() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(A1:B2)"})).unwrap();
s.execute("home.mergeAcross", json!({"range": "A1:B2"})).unwrap();
let v = |s: &mut crate::Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "B1"), Json::Null);
assert_eq!(v(&mut s, "B2"), Json::Null);
assert_eq!(v(&mut s, "D1"), json!(4.0));
}
#[test]
fn decimal_steps() {
let v = gridcraft_core::Value::Number(1.5);

View File

@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
@ -240,7 +240,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name")
.map(str::to_string)
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
@ -349,6 +349,9 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"totals" => {
let v = on.unwrap_or(!t.totals_row);
if v != t.totals_row {
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
}
t.totals_row = v;
if v {
t.range.end.row += 1;
@ -448,14 +451,19 @@ fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a
/// structured reference, and the writer would rename the table on save.
fn valid_table_name(name: &str) -> bool {
name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
if name.is_empty()
|| name.contains(' ')
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
|| gridcraft_core::CellRef::parse(&name).is_some()
{
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) {
@ -470,6 +478,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
for i in 0..cx.wb.sheets.len() {
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
let shadowed = cx.wb.name(&old, i).is_some();
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
for (c, f) in keys {
let Some(e) = f.expr() else { continue };
@ -478,7 +488,7 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
st.table = name.clone();
gridcraft_formula::Expr::Struct(st)
}
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
o => o,
});
if let Some(cell) = sh.cells.get_mut(c) {
@ -1156,6 +1166,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
};
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);
@ -1167,12 +1178,30 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
} 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<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
@ -1420,20 +1449,20 @@ fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
}
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let n = sh.cond_formats.len();
// The count is taken again after each step: a delete shortens the list.
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
&& i < n
&& i < sh.cond_formats.len()
{
sh.cond_formats.remove(i);
}
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
&& i > 0
&& i < n
&& i < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i - 1);
}
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
&& i + 1 < n
&& i + 1 < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i + 1);
}
@ -1466,3 +1495,23 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"shape": id}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manage_rules_delete_then_move() {
let mut s = Session::new();
s.new_workbook();
for v in ["1", "2"] {
let rule = json!({"type": "cellIs", "operator": "greaterThan", "value": v, "preset": "lightRedFill"});
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
}
// Deleting first left two rules' worth of indices for the move: it panicked.
s.execute("home.manageRules", json!({"delete": 0, "moveUp": 1})).unwrap();
s.execute("home.manageRules", json!({"delete": 0, "moveDown": 0})).unwrap();
let rules = s.execute("home.manageRules", json!({})).unwrap();
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
}
}

View File

@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<PivotField> {
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<usize>) {
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<usize>) -> Result<Json> {
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<String> {
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<String> {
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::<Vec<_>>(),
"valuesOnRows": pt.values_on_rows,
"valuesPosition": pt.values_position,
})
}
@ -890,18 +948,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
}
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<Json> = 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<Json> = (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<Json> = 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<Json> = (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();
}
}

View File

@ -391,7 +391,7 @@ pub struct HfContext {
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
let now = gridcraft_calc::now_serial();
let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial());
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
(date, time)
@ -1765,6 +1765,14 @@ mod tests {
}
}
#[test]
fn header_footer_date_follows_the_workbook_date_system() {
let mut wb = Workbook::new();
let date_1900 = hf_date_time(&wb).0;
wb.date_system = gridcraft_core::DateSystem::D1904;
assert_eq!(hf_date_time(&wb).0, date_1900);
}
#[test]
fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() {
let mut s = s();
@ -2046,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);

View File

@ -249,10 +249,14 @@ fn cancel_copy_mode(s: &mut Session) {
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
if at.checked_add(count).is_none_or(|end| end > limit) {
let cmd = if axis == Axis::Rows { "home.insertRows" } else { "home.insertColumns" };
return Err(bad(cmd, "the inserted lines would extend past the end of the worksheet"));
}
check_lines_protection(s, p, axis, at, count, true)?;
let sheet = target_sheet(s, p)?;
// Refuse to push data off the sheet.
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
@ -290,6 +294,9 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
// Deleting past the last line only removes up to the end of the worksheet.
let count = count.min(limit.saturating_sub(at)).max(1);
check_lines_protection(s, p, axis, at, count, false)?;
let sheet = target_sheet(s, p)?;
let out = edit(s, |cx| {

File diff suppressed because it is too large Load Diff

View File

@ -64,7 +64,14 @@ fn known(s: &Session, w: &str) -> bool {
false
}
/// Longest word we generate suggestions for. Candidate text grows with the square of the word length, so longer
/// tokens (no real word is this long) get no suggestions instead of an unbounded allocation.
const MAX_SUGGEST_LEN: usize = 64;
fn edits1(w: &str) -> Vec<String> {
if w.chars().count() > MAX_SUGGEST_LEN {
return Vec::new();
}
let chars: Vec<char> = w.chars().collect();
let mut out = Vec::new();
let letters = "abcdefghijklmnopqrstuvwxyz";
@ -177,3 +184,16 @@ fn thesaurus(_: &mut Session, p: &Json) -> Result<Json> {
related.truncate(20);
Ok(json!(related))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits1_is_capped_for_very_long_words() {
assert_eq!(edits1("helo").len(), 54 * 4 + 25);
assert_eq!(edits1(&"a".repeat(MAX_SUGGEST_LEN)).len(), 54 * MAX_SUGGEST_LEN + 25);
assert!(edits1(&"a".repeat(MAX_SUGGEST_LEN + 1)).is_empty());
assert!(edits1(&"a".repeat(1_000_000)).is_empty());
}
}

View File

@ -6,6 +6,7 @@ use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
use crate::cmd::Ctx;
@ -65,21 +66,25 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
let detected = Pattern::detect(
&src_cells,
&custom,
cx.wb.date_system,
cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(),
);
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
} else {
Pattern::Copy
};
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
// own series, one step per repeat of the source.
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
} else {
Vec::new()
};
for (k, t) in targets.iter().enumerate() {
let step = k as i64 + 1;
let idx = k % src_cells.len().max(1);
let (sc, scell) = src_cells.get(if forward { idx } else { src_cells.len() - 1 - idx }).cloned().unwrap_or((src.start, None));
let src_i = if forward { idx } else { src_cells.len() - 1 - idx };
let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));
let mut cell = scell.clone().unwrap_or_default();
match mode {
FillMode::Formats => {
@ -96,7 +101,10 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
cell.value = Value::Empty;
}
} else if let Some(v) = pattern.value(step, forward, src_cells.len()) {
} else if let Some(v) = match per_cell.get(src_i) {
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
None => pattern.value(step, forward, src_cells.len()),
} {
cell.value = v;
}
cell.style = style;
@ -173,8 +181,14 @@ impl Pattern {
let last = nums.last().copied().unwrap_or(0.0);
let is_date = fmt.contains('d') || fmt.contains('y') || (fmt.contains('m') && !fmt.contains('h'));
if nums.len() == 1 {
// A single number copies; a single date increments by a day.
return if is_date { Pattern::Linear { last, first, step: 1.0 } } else { Pattern::Copy };
// A single number copies; a single date increments by a day, a single time by an hour.
return if is_date {
Pattern::Linear { last, first, step: 1.0 }
} else if NumberFormat::parse(fmt).kind() == FormatKind::Time {
Pattern::Linear { last, first, step: 1.0 / 24.0 }
} else {
Pattern::Copy
};
}
// Dates that differ by whole months.
if is_date && nums.len() >= 2 {
@ -260,8 +274,12 @@ impl Pattern {
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
let base = if forward { *last } else { *first };
let n = base + step * k * sign;
let n = n.abs();
Some(Value::text(format!("{prefix}{n:0width$}{suffix}", width = *width)))
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
let quarter = suffix.is_empty()
&& (1..=4).contains(&base)
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
}
Pattern::List { list, last, first, step, upper, lower } => {
let len = list.len() as i64;
@ -309,6 +327,23 @@ fn round15(x: f64) -> f64 {
if !p.is_finite() || p == 0.0 { x } else { (x * p).round() / p }
}
/// `1st Period` → `2nd Period`: an English ordinal ending is rewritten to suit the new number.
fn ordinal_suffix(n: i64, suffix: &str) -> String {
let (Some(end), Some(rest)) = (suffix.get(..2), suffix.get(2..)) else { return suffix.to_string() };
if !["st", "nd", "rd", "th"].contains(&end.to_ascii_lowercase().as_str()) || rest.starts_with(char::is_alphanumeric) {
return suffix.to_string();
}
let new = match (n % 10, n % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
let new = if end.chars().all(|c| c.is_ascii_uppercase()) { new.to_ascii_uppercase() } else { new.to_string() };
format!("{new}{rest}")
}
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
let chars: Vec<char> = t.chars().collect();
@ -338,6 +373,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
let style = sh.style_id(first_cell);
let mut v = start;
for i in 1..len {
let prev = v;
v = match kind {
"growth" => v * step,
"date" => {
@ -350,7 +386,10 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
let mut left = step.abs() as i64;
while left > 0 {
n += step.signum();
if datetime_from_serial(sys, n).is_some_and(|d| d.weekday != 0 && d.weekday != 6) {
let Some(day) = datetime_from_serial(sys, n) else {
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
};
if day.weekday != 0 && day.weekday != 6 {
left -= 1;
}
}
@ -361,8 +400,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
}
_ => v + step,
};
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
if let Some(s) = stop
&& ((step >= 0.0 && v > s) || (step < 0.0 && v < s))
&& ((v >= prev && v > s) || (v < prev && v < s))
{
break;
}
@ -579,4 +619,37 @@ mod tests {
assert_eq!(split_num("Q003x"), Some(("Q".into(), 3, "x".into(), 3)));
assert_eq!(split_num("abc"), None);
}
#[test]
fn times_quarters_ordinals_and_mixed_sources() {
let mut s = crate::Session::new();
s.new_workbook();
let mut fill = |values: serde_json::Value, source: &str, target: &str| {
s.execute("range.setValues", serde_json::json!({"range": source.get(..2).unwrap(), "values": values})).unwrap();
s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let r = RangeRef::parse(target).unwrap();
r.iter().map(|c| crate::display::cell_text(&s.doc().unwrap().wb, sh, c)).collect::<Vec<_>>()
};
assert_eq!(fill(serde_json::json!([["9:00"]]), "A1", "A1:A3"), ["9:00", "10:00", "11:00"]);
assert_eq!(fill(serde_json::json!([["Qtr3"]]), "B1", "B1:B3"), ["Qtr3", "Qtr4", "Qtr1"]);
assert_eq!(fill(serde_json::json!([["Quarter 4"]]), "C1", "C1:C2"), ["Quarter 4", "Quarter 1"]);
assert_eq!(fill(serde_json::json!([["1st Period"]]), "D1", "D1:D4"), ["1st Period", "2nd Period", "3rd Period", "4th Period"]);
assert_eq!(
fill(serde_json::json!([["Product 1"], ["On backorder"]]), "E1:E2", "E1:E6"),
["Product 1", "On backorder", "Product 2", "On backorder", "Product 3", "On backorder"]
);
}
#[test]
fn falling_growth_series_stops_at_its_stop_value() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "growth", "step": 0.5, "stop": 1})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
}
}

View File

@ -102,6 +102,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let import = match sniffed {
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
// An XLSX package is never read as ODS/XLSB just because of its name.
gridcraft_xlsx::Format::Xlsx => None,
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
};
if let Some(import) = import {
@ -109,7 +111,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
@ -153,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<Vec<u8>> {
// 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<Vec<u8>> {
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,7 +181,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
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::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),
}
}
@ -175,7 +189,18 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
/// 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<u8> {
gridcraft_xlsx::write_csv(sh, delimiter, |c| crate::display::value_text(wb, sh, c))
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.
if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General")
&& let gridcraft_core::Value::Number(number) = sh.value(c)
&& number.is_finite()
{
number.to_string()
} else {
crate::display::value_text(wb, sh, c)
}
})
}
fn esc(s: &str) -> String {
@ -285,9 +310,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<String> {
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
let mut out = String::new();
let _ = write!(
out,
@ -295,7 +323,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;
}
@ -318,7 +352,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
}
}
out.push_str("</table></body></html>");
out
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
@ -339,8 +373,12 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
// A write or rename that fails (full disk, the workbook locked by another app) leaves
// no temporary file beside the user's workbook.
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})
}
#[cfg(target_arch = "wasm32")]
@ -418,6 +456,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</td>") && html.contains(">far</td>"));
}
#[test]
fn macros_are_saved_only_into_xlsm() {
let mut wb = Workbook::new();
@ -434,6 +486,26 @@ mod tests {
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn failed_save_leaves_no_temporary_file() {
let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let blocked = dir.join("Book1.xlsx");
std::fs::create_dir(&blocked).unwrap();
assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err());
assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed");
// An ordinary save still works and leaves only the workbook.
let ok = dir.join("Book2.xlsx");
write_file(ok.to_str().unwrap(), b"workbook").unwrap();
assert_eq!(std::fs::read(&ok).unwrap(), b"workbook");
assert!(!dir.join("Book2.xlsx.sctmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
@ -568,6 +640,22 @@ mod tests {
assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}");
}
#[test]
fn delimited_exports_preserve_general_precision_and_explicit_number_formats() {
use gridcraft_core::Value;
use gridcraft_model::{Cell, NumFmt, StyleId};
let mut workbook = Workbook::new();
let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00"));
let sheet = workbook.sheet_mut(0).unwrap();
sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() });
sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() });
sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() });
for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] {
let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap();
assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n"));
}
}
#[test]
fn template_extension_saves_template_content_type() {
let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap();
@ -581,4 +669,19 @@ mod tests {
"{types}"
);
}
#[test]
fn content_wins_over_a_mismatched_extension() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0));
let package = save_bytes(&wb, "book.xlsx").unwrap();
for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] {
let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}");
}
for name in ["data.xlsx", "data.csv"] {
let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
}
}
}

View File

@ -108,6 +108,9 @@ pub struct DocState {
pub sheet_selections: Vec<Selection>,
/// 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<io::FileKind>,
}
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 {

View File

@ -560,6 +560,8 @@ struct Line {
values: bool,
/// (column, value, indent, number format)
labels: Vec<(usize, Value, u8, Option<String>)>,
/// The value field of the line when Σ Values sits in Rows (`None`: one per data column).
vi: Option<usize>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
@ -579,6 +581,10 @@ struct Ctx<'a> {
rows: Vec<&'a PivotField>,
row_items: Vec<FieldItems>,
row_fmts: Vec<String>,
/// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields).
vpos: Option<usize>,
/// Value field captions.
val_names: Vec<String>,
}
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<u32>, out: &mut Vec<Line>) {
/// (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<usize>) -> 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<Line>) {
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<u32>, out: &mut Vec<Line>) {
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<usize>, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
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<String>)],
out: &mut Vec<Line>,
) {
for (vi, name) in self.val_names.iter().enumerate() {
let mut labels: Vec<(usize, Value, u8, Option<String>)> = 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<usize>,
prefix: &mut Vec<u32>,
pending: &[(usize, Value, u8, Option<String>)],
out: &mut Vec<Line>,
) {
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<String>)> = 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<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
@ -745,6 +871,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let k = vals.len();
let empty = rows.is_empty() && cols.is_empty() && vals.is_empty();
// Σ Values in Rows (it exists with two or more value fields): its depth among the row fields
// found in the source.
let vpos: Option<usize> = (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<FieldItems> = rows.iter().map(|_| FieldItems::default()).collect();
@ -752,6 +886,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let mut aggs: AggMap = HashMap::new();
let mut row_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_leaves: HashSet<Vec<u32>> = HashSet::new();
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
@ -788,8 +923,22 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
}
}
// The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`).
let shown = cols
.iter()
.zip(col_items.iter())
.zip(cp.iter())
.position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it)))
.map_or(cp.len(), |d| d + 1);
if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec());
}
row_set.insert(rp);
col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) {
return Err("The PivotTable report will not fit on the sheet.".into());
}
if aggs.len() > 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<Out
collect_siblings(&row_tree, &mut Vec::new(), &mut siblings);
let level0: Vec<u32> = 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<String> = 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<Line> = 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<usize>| 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<ColSpec> = Vec::new();
@ -837,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
let mut data_cols: Vec<(usize, Option<usize>)> = 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<Out
}
let compact = pt.layout == PivotLayout::Compact;
let l = if rows.is_empty() && cols.is_empty() {
let l = if row_levels == 0 && cols.is_empty() {
0
} else if compact {
1
} else {
rows.len().max(1)
row_levels.max(1)
};
let mut width = l + data_cols.len();
if !shown_filters.is_empty() {
@ -903,14 +1063,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
let headers_on = pt.show_headers;
let row_header = |g: &mut Grid, r: usize| {
if rows.is_empty() || !headers_on {
if row_levels == 0 || !headers_on {
return;
}
if compact {
g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None);
} else {
for (i, f) in row_fields.iter().enumerate() {
g.set(r, i, Value::text(f.source_col.as_str()), CellKind::Header, 0, None);
// One column per row field, Σ Values ("Values") at its place.
let mut names: Vec<&str> = 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<Out
let r = g.push_row(RowKind::Header)?;
row_header(&mut g, r);
for (j, (_, vi)) in data_cols.iter().enumerate() {
if let Some(vi) = vi {
g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None);
match vi {
Some(vi) => 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<Out
let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() };
g.set(cap, l, Value::text(caption), CellKind::Header, 0, None);
}
let levels = cols.len() + usize::from(k > 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<Out
&& (k > 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<Out
continue;
}
let rp = line.path.as_slice();
for (j, (si, vi)) in data_cols.iter().enumerate() {
let (Some(spec), Some(vi)) = (specs.get(*si), vi) else { continue };
for (j, (si, col_vi)) in data_cols.iter().enumerate() {
let line_vi = col_vi.or(line.vi);
let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue };
let Some((_, vdef)) = vals.get(*vi) else { continue };
let cp = spec.path.as_slice();
let Some(raw) = get(rp, cp, *vi) else { continue };
@ -1428,3 +1599,17 @@ pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
p.last_range = None;
}
}
#[cfg(test)]
mod fit_tests {
use super::data_cols_exceed_sheet;
#[test]
fn column_budget_counts_value_fields_and_grand_total() {
assert!(!data_cols_exceed_sheet(64, true, 128));
assert!(data_cols_exceed_sheet(64, true, 256));
assert!(!data_cols_exceed_sheet(64, false, 256));
assert!(!data_cols_exceed_sheet(16_384, false, 0));
assert!(data_cols_exceed_sheet(16_385, false, 0));
}
}

View File

@ -43,6 +43,13 @@ impl Selection {
pub fn is_single_cell(&self) -> 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);

View File

@ -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<u32> = 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,21 @@ fn tables_and_structured_refs() {
assert_eq!(v(&s, "B4"), Value::Number(7.0));
}
#[test]
fn table_rename_keeps_a_defined_name_with_the_same_name() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "100"})).unwrap();
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Data"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Data", "refersTo": "=Sheet1!$D$1"})).unwrap();
s.execute("cell.set", json!({"cell": "E1", "input": "=Data"})).unwrap();
s.execute("cell.set", json!({"cell": "F1", "input": "=SUM(Data[Qty])"})).unwrap();
s.execute("table.rename", json!({"table": "Data", "name": "Later"})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "E1"})).unwrap()["formula"], "=Data");
assert_eq!(s.execute("cell.get", json!({"cell": "F1"})).unwrap()["formula"], "=SUM(Later[Qty])");
assert_eq!((v(&s, "E1"), v(&s, "F1")), (Value::Number(100.0), Value::Number(7.0)));
}
#[test]
fn slicer_filters_table() {
let mut s = s();
@ -1170,6 +1225,22 @@ fn insert_and_delete_cells_never_shift_part_of_a_table() {
assert!(e.to_string().contains("shift cells in a table"), "{e}");
}
#[test]
fn table_names_with_a_hyphen_are_rejected() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
let e = s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "A-B"})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{e}");
assert!(s.doc().unwrap().wb.table("A-B").is_none());
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Sales"})).unwrap();
let e = s.execute("table.rename", json!({"table": "Sales", "name": "A-B"})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{e}");
assert!(s.doc().unwrap().wb.table("Sales").is_some());
assert!(s.execute("table.rename", json!({"table": "Sales", "name": ".AB"})).is_err());
s.execute("table.rename", json!({"table": "Sales", "name": "A_B"})).unwrap();
assert!(s.doc().unwrap().wb.table("A_B").is_some());
}
#[test]
fn undo_and_redo_keep_the_watch_window_list() {
// Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so
@ -1519,6 +1590,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(&notes, "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(&notes).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();
@ -1617,6 +1747,17 @@ fn replacing_a_spilling_formula_clears_its_spill() {
assert_eq!((v(&s, "A2"), v(&s, "B1")), (Value::Empty, Value::Number(0.0)));
}
#[test]
fn clearing_a_spill_past_65536_cells_updates_a_late_dependent() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(65537)"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A65536"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=A65537"})).unwrap();
assert_eq!(v(&s, "C2"), Value::Number(65537.0));
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(0.0), Value::Number(0.0)));
}
#[test]
fn range_ending_in_index_recalculates() {
let mut s = s();
@ -1696,6 +1837,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();
@ -2505,6 +2663,15 @@ fn fill_series_month_clamps_to_month_end() {
assert_eq!(v(&s, "A4"), ymd(2026, 4, 15));
}
#[test]
fn fill_series_weekday_out_of_range_step_errors() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "45000"})).unwrap();
let r = s.execute("edit.fillSeries", json!({"range": "A1:A2", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1e12}));
assert!(r.is_err());
assert_eq!(v(&s, "A2"), Value::Empty);
}
#[test]
fn fill_series_month_leap_year() {
let s = series_date("2028-01-31", "A1:A3", "month");
@ -2549,6 +2716,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();
@ -2795,3 +3012,32 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn paste_special_validation_copies_the_rule() {
let mut s = s();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "E5"})).unwrap();
assert!(ok(&mut s, "E5", "-1"));
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
assert!(!ok(&mut s, "C3", "-1"));
assert!(ok(&mut s, "C3", "1"));
assert!(!ok(&mut s, "A1", "-1"));
assert_eq!(s.execute("cell.get", json!({"cell": "C3"})).unwrap()["text"], json!("7"));
}
#[test]
fn paste_special_validation_moves_relative_references() {
let mut s = s();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap();
s.execute("data.validation", json!({"range": "A1", "type": "custom", "formula1": "=A1>10"})).unwrap();
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1");
assert!(ok(&mut s, "C3", "20"));
}

View File

@ -0,0 +1,28 @@
use gridcraft_engine::Session;
use serde_json::json;
fn freeze(s: &Session) -> Option<(u32, u32)> {
s.doc().unwrap().wb.active().unwrap().freeze
}
#[test]
fn showing_a_custom_view_restores_its_frozen_panes() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
s.execute("view.customViews", json!({"save": "Frozen"})).unwrap();
s.execute("view.unfreezePanes", json!({})).unwrap();
assert_eq!(freeze(&s), None);
s.execute("view.customViews", json!({"show": "Frozen"})).unwrap();
assert_eq!(freeze(&s), Some((1, 1)));
}
#[test]
fn showing_an_unfrozen_custom_view_clears_current_panes() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.customViews", json!({"save": "Plain"})).unwrap();
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
s.execute("view.customViews", json!({"show": "Plain"})).unwrap();
assert_eq!(freeze(&s), None);
}

View File

@ -0,0 +1,47 @@
use gridcraft_engine::{EngineError, Session};
use serde_json::json;
fn new_session() -> Session {
let mut session = Session::new();
session.new_workbook();
session
}
#[test]
fn insert_with_an_out_of_range_count_is_a_normal_error() {
for (id, key, at) in [("home.insertRows", "rows", "3:3"), ("home.insertColumns", "cols", "C:C")] {
let mut session = new_session();
let err = session.execute(id, json!({ key: at, "count": u32::MAX })).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{id}: {err:?}");
}
}
#[test]
fn insert_after_a_formatted_line_with_an_out_of_range_count_is_a_normal_error() {
// A custom size on the preceding line and no cells: the used-range guard has nothing to check.
let mut session = new_session();
session.execute("home.rowHeight", json!({"rows": "2:2", "height": 30})).unwrap();
let err = session.execute("home.insertRows", json!({"rows": "3:3", "count": u32::MAX})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
let mut session = new_session();
session.execute("home.columnWidth", json!({"cols": "B:B", "width": 100})).unwrap();
let err = session.execute("home.insertColumns", json!({"cols": "C:C", "count": u32::MAX})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
}
#[test]
fn delete_with_an_out_of_range_count_does_not_panic() {
for (id, key, at) in [("home.deleteRows", "rows", "3:3"), ("home.deleteColumns", "cols", "C:C")] {
let mut session = new_session();
let res = session.execute(id, json!({ key: at, "count": u32::MAX }));
assert!(!matches!(res, Err(EngineError::Internal(..))), "{id}: {res:?}");
}
}
#[test]
fn insert_and_delete_with_a_small_count_still_work() {
let mut session = new_session();
session.execute("home.insertRows", json!({"rows": "1:1", "count": 3})).unwrap();
session.execute("home.deleteRows", json!({"rows": "1:1", "count": 3})).unwrap();
}

View File

@ -0,0 +1,24 @@
use gridcraft_core::{CellError, CellRef, Value};
use gridcraft_engine::Session;
use serde_json::json;
#[test]
fn cell_set_offset_extreme_arguments_return_ref() {
let mut session = Session::new();
session.new_workbook();
for formula in [
"=OFFSET(B2,1E300,0)",
"=OFFSET(B2,0,1E300)",
"=OFFSET(B2,0,0,1E300,1)",
"=OFFSET(B2,0,0,1,1E300)",
"=OFFSET(B2,-1E300,0)",
"=OFFSET(B2,0,-1E300)",
"=OFFSET(B2,0,0,-1E300,1)",
"=OFFSET(B2,0,0,1,-1E300)",
"=OFFSET(B2,1E300,0,-1E300,1)",
"=OFFSET(B2,0,1E300,1,-1E300)",
] {
session.execute("cell.set", json!({"cell": "D1", "input": formula})).unwrap();
assert_eq!(session.doc().unwrap().wb.sheet(0).unwrap().value(CellRef::new(0, 3)), Value::Error(CellError::Ref), "{formula}");
}
}

View File

@ -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}");
}
}

View File

@ -0,0 +1,29 @@
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"]]})).unwrap();
s.execute("insert.table", json!({"range":"A1:B2", "header":true, "name":"T"})).unwrap();
s
}
fn table_range(s: &Session) -> String {
s.doc().unwrap().wb.active().unwrap().tables[0].range.a1()
}
#[test]
fn resize_to_disjoint_range_is_rejected_and_keeps_table() {
let mut s = table();
assert!(s.execute("table.resize", json!({"table":"T", "range":"D1:E2"})).is_err());
assert_eq!(table_range(&s), "A1:B2");
assert_eq!(s.doc().unwrap().wb.active().unwrap().tables[0].columns[1].name, "Qty");
}
#[test]
fn resize_to_overlapping_range_still_works() {
let mut s = table();
s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap();
assert_eq!(table_range(&s), "B1:C2");
}

View File

@ -0,0 +1,34 @@
use gridcraft_core::{CellRef, MAX_ROWS};
use gridcraft_engine::Session;
use serde_json::json;
fn refused(range: &str) {
let mut s = Session::new();
s.new_workbook();
s.execute("insert.table", json!({"range": range, "header": true})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().tables[0].clone();
assert!(s.execute("table.totalRow", json!({"on": true, "table": &before.name})).is_err(), "{range}");
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.tables[0].range, before.range, "{range}");
assert_eq!(sh.tables[0].range.end.row, MAX_ROWS - 1, "{range}");
assert!(!sh.tables[0].totals_row, "{range}");
assert!(sh.value(CellRef::new(MAX_ROWS, 0)).is_empty(), "{range}");
}
#[test]
fn total_row_is_refused_on_a_table_ending_at_the_last_worksheet_row() {
refused("A1048575:B1048576");
refused("A1048576:B1048576");
}
#[test]
fn total_row_still_grows_an_ordinary_table() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Amount"], ["a", 2], ["b", 3]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
let t = &s.doc().unwrap().wb.active().unwrap().tables[0];
assert!(t.totals_row);
assert_eq!(t.range.end.row, 3);
}

View File

@ -50,8 +50,9 @@ fn significant_spaces(toks: Vec<Token>) -> Vec<Token> {
let mut out: Vec<Token> = Vec::with_capacity(toks.len());
for (i, t) in toks.iter().enumerate() {
if t.tok == Tok::Space {
let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_)));
let next_start = toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_)));
let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_) | Tok::Error(_)));
let next_start =
toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_) | Tok::Error(_)));
if prev_end && next_start {
out.push(t.clone());
}
@ -88,15 +89,19 @@ fn word_is_cell(w: &str) -> Option<Anchor> {
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 {
@ -357,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()))
}

View File

@ -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) {
@ -224,10 +234,20 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
}
},
},
Expr::Binary(op, a, b) => {
write_expr(s, a, r1c1);
s.push_str(op.symbol());
write_expr(s, b, r1c1);
Expr::Binary(..) => {
// Walk the left spine iteratively: a flat chain such as `1+1+1+...` parses into a
// tree one level deeper per operator, and recursing on it could exhaust the stack.
let mut rest: Vec<(&BinOp, &Expr)> = Vec::new();
let mut first = e;
while let Expr::Binary(op, a, b) = first {
rest.push((op, b));
first = a;
}
write_expr(s, first, r1c1);
for (op, right) in rest.into_iter().rev() {
s.push_str(op.symbol());
write_expr(s, right, r1c1);
}
}
Expr::Call(name, args) => {
s.push_str(name);

View File

@ -5,6 +5,22 @@ fn rt(src: &str) -> String {
print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")))
}
#[test]
fn intersection_with_deleted_operand_reparses() {
// Deleting the columns of an intersection operand prints `A1:B2 #REF!`; that text must parse back.
assert_eq!(rt("A1:B2 #REF!"), "A1:B2 #REF!");
assert_eq!(rt("#REF! A1:B2"), "#REF! A1:B2");
assert_eq!(parse("A1:B2 #REF!"), parse("A1:B2 (#REF!)").map(|e| strip_paren(&e)));
}
fn strip_paren(e: &Expr) -> Expr {
match e {
Expr::Binary(op, a, b) => Expr::Binary(*op, Box::new(strip_paren(a)), Box::new(strip_paren(b))),
Expr::Paren(x) => strip_paren(x),
other => other.clone(),
}
}
#[test]
fn roundtrips() {
for f in [
@ -48,6 +64,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");
}
@ -301,3 +319,25 @@ fn trim_references_adjust() {
let refs: Vec<String> = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect();
assert_eq!(refs, ["Sheet2!A1:B5"]);
}
#[test]
fn prints_long_flat_chain_without_deep_recursion() {
// Small stack: a recursive printer overflows long before 20000 levels.
let handle = std::thread::Builder::new()
.stack_size(256 * 1024)
.spawn(|| {
let mut e = Expr::Number(1.0);
for _ in 0..20000 {
e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0)));
}
let text = print(&e);
let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 });
// Dropping the deep tree recurses; leak it so only the printer is under test.
std::mem::forget(e);
(text, r1c1)
})
.expect("spawn");
let (text, r1c1) = handle.join().expect("printer must not overflow the stack");
assert_eq!(text, vec!["1"; 20001].join("+"));
assert_eq!(r1c1, text);
}

View File

@ -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<Criterion, CellError> {
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,

View File

@ -1,6 +1,6 @@
//! Date and time functions.
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd};
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd, serial_in_system};
use gridcraft_core::parse::parse_input;
use gridcraft_core::{CellError, DateSystem, Value};
@ -98,10 +98,10 @@ fn second(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
fn now(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(c.now_serial())
num_val(serial_in_system(c.date_system(), c.now_serial()))
}
fn today(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(c.now_serial().floor())
num_val(serial_in_system(c.date_system(), c.now_serial()).floor())
}
fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
@ -794,6 +794,11 @@ mod tests {
close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0);
let spec = crate::lookup("YEAR").unwrap();
close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0);
// The clock is 2026-10-07 12:00; in the 1904 system that is serial 44840.5.
close(crate::call(crate::lookup("NOW").unwrap(), &[], &mut ctx), 44840.5);
let today = crate::call(crate::lookup("TODAY").unwrap(), &[], &mut ctx);
close(today.clone(), 44840.0);
close(crate::call(spec, &[crate::Arg::val(today)], &mut ctx), 2026.0);
// No fictitious day here: 1904 is a real leap year and 1900 is before the epoch.
let spec = crate::lookup("DATE").unwrap();
close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0);

View File

@ -30,6 +30,11 @@ fn rows_of(a: &Array) -> Vec<Vec<Value>> {
(0..a.rows).map(|r| (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect()).collect()
}
/// `#NUM!` when an `h` x `w` output would exceed the function cell budget.
fn check_output_size(h: usize, w: usize) -> R<()> {
if h.saturating_mul(w) > MAX_CELLS { Err(CellError::Num) } else { Ok(()) }
}
/// Builds an array from rows (all rows padded to the widest with `pad`). Empty → `#CALC!`.
fn from_rows(rows: Vec<Vec<Value>>, pad: &Value) -> R<Value> {
let h = rows.len();
@ -37,9 +42,7 @@ fn from_rows(rows: Vec<Vec<Value>>, pad: &Value) -> R<Value> {
if h == 0 || w == 0 {
return Err(CellError::Calc);
}
if h.saturating_mul(w) > MAX_CELLS {
return Err(CellError::Num);
}
check_output_size(h, w)?;
let mut data = Vec::with_capacity(h * w);
for mut r in rows {
r.resize(w, pad.clone());
@ -321,16 +324,20 @@ fn vstack(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn hstack(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let mut arrays = Vec::new();
let mut h = 0usize;
let mut w = 0usize;
let mut total = 0usize;
for i in 0..args.len() {
let a = arr_arg(args, i)?;
h = h.max(a.rows);
w = w.saturating_add(a.cols);
total = total.saturating_add(a.data.len());
if total > MAX_CELLS {
return Err(CellError::Num);
}
arrays.push(a);
}
// The padded rectangle can be far larger than the inputs; refuse it before building any rows.
check_output_size(h, w)?;
let mut rows = vec![Vec::new(); h];
for a in &arrays {
for (r, row) in rows.iter_mut().enumerate() {
@ -785,6 +792,12 @@ fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
from_rows(out, &Value::Empty)
}
/// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and
/// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key.
fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) {
(header_rows.saturating_add(body_rows), col_keys.saturating_mul(value_cols).saturating_add(lead))
}
fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let rf = arr_arg(args, 0)?;
let cf = arr_arg(args, 1)?;
@ -801,9 +814,6 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let ckeys: Vec<Vec<Value>> = p.rows.iter().map(|&r| key_row(&cf, r)).collect();
let rk = distinct_keys(&rkeys, if row_sort.unsigned_abs() as usize <= rf.cols { row_sort } else { 0 });
let ck = distinct_keys(&ckeys, if col_sort.unsigned_abs() as usize <= cf.cols { col_sort } else { 0 });
if rk.len().saturating_add(2).saturating_mul((ck.len() + 1).saturating_mul(vals.cols).saturating_add(rf.cols)) > MAX_CELLS {
return Err(CellError::Num);
}
let mut cell: HashMap<(String, String), Vec<usize>> = HashMap::new();
for ((r, c), &i) in rkeys.iter().zip(ckeys.iter()).zip(p.rows.iter()) {
cell.entry((row_key(r), row_key(c))).or_default().push(i);
@ -832,6 +842,14 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
cols.insert(0, None);
}
let lead = rf.cols;
let value_row = vals.cols > 1 || p.show_headers;
let header_rows = cf.cols.saturating_add(usize::from(value_row));
let body_rows = rk.len().saturating_add(usize::from(row_total != 0));
// Reject before building any header or body row: every column-field header row is as wide as the whole grid.
let (height, width) = pivot_dims(header_rows, body_rows, cols.len(), vals.cols, lead);
if height.saturating_mul(width) > MAX_CELLS {
return Err(CellError::Num);
}
let mut out: Vec<Vec<Value>> = Vec::new();
// Column header rows: one per column field, plus value names when several value columns.
for level in 0..cf.cols {
@ -847,7 +865,7 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
out.push(line);
}
if vals.cols > 1 || p.show_headers {
if value_row {
let mut line: Vec<Value> = if p.show_headers { p.field_headers.iter().take(lead).cloned().collect() } else { vec![Value::Empty; lead] };
line.resize(lead, Value::Empty);
for _ in &cols {
@ -1132,6 +1150,17 @@ mod tests {
is_err(ev("TOROW", vec![av(arr(vec![vec![Value::Empty]])), n(1.0)]), CellError::Calc);
}
#[test]
fn output_size_checked_before_padding() {
assert_eq!(super::check_output_size(1_000_001, 10), Err(CellError::Num));
assert_eq!(super::check_output_size(usize::MAX, 2), Err(CellError::Num));
assert_eq!(super::check_output_size(1_000_000, 10), Ok(()));
// A 1M-row column beside a 9-wide row: ~1M input cells, but the padded result would be 10,000,010.
let tall = Value::from(gridcraft_core::Array::column(vec![nv(1.0); 1_000_001]));
let wide = row(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
is_err(ev("HSTACK", vec![av(tall), av(wide)]), CellError::Num);
}
#[test]
fn wrapping() {
let r = ev("WRAPROWS", vec![av(row(&[1.0, 2.0, 3.0, 4.0, 5.0])), n(2.0)]);
@ -1225,4 +1254,29 @@ mod tests {
]
);
}
#[test]
fn pivotby_size_check_counts_column_header_rows() {
// One row, n column fields and n value columns: n header rows + value-name row + body row + total row,
// each 1 + 2n wide. The size checked up front matches the grid PIVOTBY builds.
let dims = |k: u32, totals: bool| {
let fields: Vec<f64> = (0..k).map(f64::from).collect();
let mut args = vec![av(row(&[1.0])), av(row(&fields)), av(row(&fields)), t("SUM")];
if !totals {
args.extend([empty(), n(0.0), empty(), n(0.0)]);
}
let r = rows_of(&ev("PIVOTBY", args));
(r.len(), r.first().map_or(0, Vec::len))
};
assert_eq!(dims(2, true), super::pivot_dims(3, 2, 2, 2, 1));
assert_eq!(dims(10, true), (13, 21));
assert_eq!(dims(10, true), super::pivot_dims(11, 2, 2, 10, 1));
assert_eq!(dims(10, false), (12, 11));
assert_eq!(dims(10, false), super::pivot_dims(11, 1, 1, 10, 1));
// n = 2300: the 2300 header rows alone are 10.58M cells, so the check must count them before any is built.
let (h, w) = super::pivot_dims(2301, 2, 2, 2300, 1);
assert!(2300 * w > crate::util::MAX_CELLS && h * w > crate::util::MAX_CELLS);
let wide: Vec<f64> = (0..2300).map(f64::from).collect();
is_err(ev("PIVOTBY", vec![av(row(&[1.0])), av(row(&wide)), av(row(&wide)), t("SUM")]), CellError::Num);
}
}

View File

@ -197,7 +197,8 @@ fn registry() -> &'static (Vec<FnSpec>, 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 {

View File

@ -75,7 +75,15 @@ fn round_dir(x: f64, digits: i32, up: bool) -> f64 {
}
let s = clean(scaled);
let r = if up { s.ceil() } else { s.floor() };
let out = if neg { r * f } else { clean(r / f) };
// Toward zero, r == 0 with an infinite scale would be 0 * inf = NaN (#NUM!); the result is simply
// zero. Away from zero the true result (10^309 or more) is not a number, so ROUNDUP stays #NUM!.
let out = if !neg {
clean(r / f)
} else if r == 0.0 && !up {
0.0
} else {
r * f
};
if x < 0.0 { -out } else { out }
}
@ -634,16 +642,18 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
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) {
@ -950,6 +960,9 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R<Ve
if rest.is_empty() || rest.len() % 2 != 0 {
return Err(CellError::Value);
}
// A text criterion over 255 characters is `#VALUE!` only when there is a single criteria
// pair. With two or more pairs Excel matches its first 255 characters instead.
let single_pair = rest.len() == 2;
let mut mask = vec![true; rows.saturating_mul(cols)];
for pair in rest.chunks(2) {
let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) };
@ -957,7 +970,8 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R<Ve
if r.rows != rows || r.cols != cols {
return Err(CellError::Value);
}
let c = Criterion::parse(&crit.value.scalar());
let scalar = crit.value.scalar();
let c = if single_pair { Criterion::parse_arg(&scalar)? } else { Criterion::parse_arg_truncated(&scalar) };
for (m, v) in mask.iter_mut().zip(r.iter()) {
if *m && !c.matches(v) {
*m = false;
@ -1017,7 +1031,7 @@ fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
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)
}
@ -1026,7 +1040,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
/// the evaluator does that resizing (it has the references), so cells line up one to one here.
fn if_numbers(a: &[Arg]) -> R<Vec<f64>> {
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 {
@ -1373,6 +1387,13 @@ mod tests {
close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0);
close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719);
close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0);
for digits in [-308.0, -309.0, -400.0] {
for x in [1234.0, -1234.0, 0.5] {
close(ev2("ROUNDDOWN", x, digits), 0.0);
close(ev2("TRUNC", x, digits), 0.0);
}
}
assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num)));
close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3);
close(ev2("TRUNC", 8.987, 2.0), 8.98);
close(ev1("TRUNC", -8.9), -8.0);
@ -1553,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)]]);
@ -1643,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);
}
}

View File

@ -79,10 +79,12 @@ fn mode_sngl(v: &[f64]) -> R<f64> {
}
/// 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<Vec<f64>> {
let mut out = Vec::new();
for a in args {
@ -98,9 +100,8 @@ fn mode_numbers(args: &[Arg]) -> R<Vec<f64>> {
}
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)
@ -461,7 +462,7 @@ fn prob(a: &[Arg], _: C) -> R<Value> {
let (xs, ps) = arg_pairs(a, 0, 1)?;
let lo = num(a, 2)?;
let hi = if has(a, 3) { num(a, 3)? } else { lo };
if ps.iter().any(|&p| !(0.0..=1.0).contains(&p)) {
if ps.iter().any(|&p| p <= 0.0 || p > 1.0) {
return Err(CellError::Num);
}
let total: f64 = ps.iter().sum();
@ -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<crate::Arg>, Result<f64, CellError>)> = 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]
@ -2472,7 +2499,10 @@ mod tests {
let p = c(&[0.2, 0.3, 0.1, 0.4]);
close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1);
close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8);
is_err(ev("PROB", vec![x, c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num);
is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num);
// Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits.
is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num);
is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num);
}
#[test]

View File

@ -5,7 +5,8 @@ use gridcraft_core::{CellError, Value, number_to_text};
use crate::criteria::wildcard_find;
use crate::util::{
A, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, text_val,
A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text,
text_val,
};
use crate::{Arg, Ctx, FnSpec, VAR};
@ -214,7 +215,7 @@ fn find_impl(a: &[Arg], wild: bool) -> R<Value> {
let hay = text(a, 1)?;
let start = opt_num(a, 2, 1.0)?;
let hc = chars(&hay);
if (start.is_nan() || start < 1.0) || start > hc.len() as f64 + 1.0 {
if (start.is_nan() || start < 1.0) || start > hc.len().max(1) as f64 {
return Err(CellError::Value);
}
let start = start as usize - 1;
@ -610,6 +611,9 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
let ncols = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
let nrows = rows.len();
if nrows.saturating_mul(ncols) > MAX_CELLS {
return Err(CellError::Num);
}
let mut data = Vec::with_capacity(nrows * ncols);
for r in rows {
let n = r.len();
@ -1032,6 +1036,10 @@ mod tests {
close(ev("SEARCH", vec![t("~*"), t("a*b")]), 2.0);
is_err(ev("SEARCH", vec![t("z"), t("abc")]), CellError::Value);
is_err(ev("SEARCH", vec![t("a"), t("abc"), n(5.0)]), CellError::Value);
for f in ["FIND", "SEARCH"] {
close(ev(f, vec![t(""), t("abc"), n(3.0)]), 3.0);
is_err(ev(f, vec![t(""), t("abc"), n(4.0)]), CellError::Value);
}
assert_eq!(ev("EXACT", vec![t("a"), t("A")]), Value::Bool(false));
assert_eq!(ev("EXACT", vec![t("a"), t("a")]), Value::Bool(true));
}
@ -1133,6 +1141,13 @@ mod tests {
assert_eq!(rows_of(&v)[1], vec![tv("c"), tv("-")]);
}
#[test]
fn split_rejects_oversized_padded_grid() {
// One row of 8000 columns followed by 8000 one-cell rows: the text is under the cell-text limit, the padded grid is not.
let text = format!("{}{}", "a,".repeat(8000), "b;".repeat(8000));
is_err(ev("TEXTSPLIT", vec![t(&text), t(","), t(";")]), CellError::Num);
}
#[test]
fn to_text() {
is_text(ev("VALUETOTEXT", vec![t("a\"b")]), "a\"b");

View File

@ -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));
@ -517,3 +520,29 @@ fn text_to_columns_destination_follows_its_sheet() {
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "D0"}));
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "Sheet1!D1"}));
}
#[test]
fn invalid_borders_are_invalid_params() {
let mut s = server();
let bad = |s: &mut Server, id: u64, borders: Value| {
let v = rpc(s, id, "tools/call", json!({"name": "format_range", "arguments": {"range": "A1", "borders": borders}}));
assert_eq!(err_code(&v), -32602, "{v}");
};
bad(&mut s, 1, json!(123));
bad(&mut s, 2, json!("bogus"));
bad(&mut s, 3, json!({"preset": "bogus"}));
bad(&mut s, 4, json!({"style": "thick"}));
ok(&mut s, "format_range", json!({"range": "A1", "borders": "all"}));
ok(&mut s, "format_range", json!({"range": "B1", "borders": {"preset": "all", "style": "thick", "color": "#123456"}}));
}
#[test]
fn invalid_sort_keys_are_invalid_params() {
let mut s = server();
ok(&mut s, "write_range", json!({"range": "A1", "values": [["b"], ["c"], ["a"]]}));
for (id, keys) in [(1, json!([123])), (2, json!([{"order": "desc"}])), (3, json!([{"column": "A", "order": "sideways"}]))] {
let v = rpc(&mut s, id, "tools/call", json!({"name": "sort_range", "arguments": {"range": "A1:A3", "keys": keys}}));
assert_eq!(err_code(&v), -32602, "{v}");
}
ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]}));
}

View File

@ -180,7 +180,7 @@ pub fn tool_definitions() -> Vec<Value> {
"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,
@ -391,8 +391,41 @@ fn type_matches(t: &str, v: &Value) -> bool {
}
}
/// Validates `args` against the tool's schema: unknown tool, missing required properties and
/// top-level type mismatches are JSON-RPC "invalid params" errors.
/// Checks a value against a (sub)schema: `type`, `enum`, `oneOf`, array `items`, and for objects the required and known properties.
fn schema_matches(schema: &Value, v: &Value) -> bool {
if let Some(t) = schema["type"].as_str()
&& !type_matches(t, v)
{
return false;
}
if let Some(allowed) = schema["enum"].as_array()
&& !allowed.contains(v)
{
return false;
}
if let Some(alts) = schema["oneOf"].as_array()
&& !alts.iter().any(|alt| schema_matches(alt, v))
{
return false;
}
if let Some(xs) = v.as_array()
&& !xs.iter().all(|x| schema_matches(&schema["items"], x))
{
return false;
}
if let Some(o) = v.as_object() {
if schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str).any(|r| o.get(r).is_none_or(Value::is_null)) {
return false;
}
if let Some(props) = schema["properties"].as_object() {
return o.iter().all(|(k, x)| props.get(k).is_some_and(|ps| x.is_null() || schema_matches(ps, x)));
}
}
true
}
/// Validates `args` against the tool's schema: unknown tool, missing required properties,
/// top-level type mismatches and values matching none of a `oneOf` are JSON-RPC "invalid params" errors.
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
let defs = tool_definitions();
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
@ -421,6 +454,9 @@ pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
{
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
}
if (p["oneOf"].is_array() || p["items"]["oneOf"].is_array()) && !schema_matches(p, v) {
return Err(format!("{name}: argument `{k}` does not match the schema: {}", p["description"]));
}
}
Ok(())
}

View File

@ -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);

View File

@ -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<String>,
/// 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<RangeRef>,
/// Calculated fields, usable in the Values area by name.
pub calculated_fields: Vec<PivotCalcField>,
/// 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<usize>,
}
/// 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,
}
}
}

View File

@ -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,

View File

@ -261,7 +261,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)

View File

@ -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()))

View File

@ -1,7 +1,7 @@
//! Rendering a value with a parsed section.
use gridcraft_core::DateSystem;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial};
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial};
use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit};
use crate::parse::{DatePart, Section, Tok};
@ -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 {
@ -58,7 +60,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
/// Renders a date/time. `None` when the serial is outside the supported range.
pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out> {
if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) {
if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) {
return None;
}
let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3);
@ -231,7 +233,9 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range<usize>) -> Vec<usize> {
/// 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<String> = vec![String::new(); n];
let mut before: Vec<String> = vec![String::new(); n + 1];
let mut lits: Vec<Option<String>> = 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
}

View File

@ -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");
@ -247,6 +270,11 @@ fn dates_and_times() {
assert_eq!(f("m/d/yyyy", 60.0), "2/29/1900");
assert_eq!(f("m/d/yyyy", 0.0), "1/0/1900");
assert_eq!(format_value(&Value::Number(0.0), &NumberFormat::parse("m/d/yyyy"), DateSystem::D1904).text, "1/1/1904");
let fmt = NumberFormat::parse("m/d/yyyy");
assert_eq!(format_value(&Value::Number(2957003.5), &fmt, DateSystem::D1904).text, "12/31/9999");
assert_eq!(format_value(&Value::Number(2957004.0), &fmt, DateSystem::D1904).text, "########");
assert_eq!(format_value(&Value::Number(2958465.0), &fmt, DateSystem::D1900).text, "12/31/9999");
assert_eq!(format_value(&Value::Number(2958466.0), &fmt, DateSystem::D1900).text, "########");
assert_eq!(f("m/d/yyyy", -1.0), "########");
assert_eq!(f("m/d/yyyy", 3e6), "########");
assert_eq!(f("[$-409]m/d/yyyy", 46302.0), "10/7/2026");
@ -339,7 +367,11 @@ fn text_fn() {
assert_eq!(text_function(&Value::Empty, "0.00", S), Ok("0.00".into()));
assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into()));
assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value));
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"),

View File

@ -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<String> = 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"));
}

View File

@ -9,6 +9,7 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
gridcraft-engine = { workspace = true }
gridcraft-chart = { workspace = true }
egui = { workspace = true }

View File

@ -570,9 +570,15 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(250.0);
if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) {
app.run_or_alert("file.print", params.clone());
out.close = true;
if let Some(discover) = &app.services.printers {
crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned());
}
let can_print = app.services.print_native.is_none() || app.printing.ready();
if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked()
&& app.commit_edit(0, 0, false, false)
{
app.print_now(params.clone());
out.close = app.services.print_native.is_none();
}
ui.add_space(16.0);
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };

View File

@ -184,6 +184,16 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
(
"ui.printSettings",
"Print Settings",
"{printer?: discovered name, copies?: 1..999, duplex?: printerDefault|off|longEdge|shortEdge|null, grayscale?: bool, refresh?: bool} → {printer,copies,duplex,grayscale,printers,ready,busy,discovering,status,detail}; {} inspects without submitting",
),
(
"ui.printSubmit",
"Submit Print Job",
"existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog",
),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
@ -232,7 +242,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
} else if field == "search" {
d.search = s("value").unwrap_or("").to_string();
} else {
d.values.insert(field.to_string(), p.get("value").cloned().unwrap_or(Json::Null));
d.set_value(field, p.get("value").cloned().unwrap_or(Json::Null));
}
ok(json!(d.values))
}
@ -494,4 +504,29 @@ mod tests {
assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty());
assert_eq!(filter_commands(sample(), None).len(), 3);
}
#[test]
fn print_command_discovery_documents_settings_and_submission() {
let mut session = gridcraft_engine::Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, crate::Services::default());
let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else {
panic!("command discovery must return immediately");
};
assert_eq!(reply["ok"], true);
let commands = reply["result"].as_array().unwrap();
for (id, expected_docs) in [
("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]),
("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]),
] {
let command = commands.iter().find(|command| command["id"] == id).unwrap();
assert_eq!(command["ui"], true);
let docs = command["params"].as_str().unwrap();
for expected in expected_docs {
assert!(docs.contains(expected), "{id}: missing {expected}");
}
}
assert!(!app.printing.busy(), "discovery must not submit a job");
assert!(app.backstage.is_none(), "discovery must not open settings");
}
}

View File

@ -47,6 +47,17 @@ impl Dialog {
&self.name
}
/// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom` stores.
pub(crate) fn set_value(&mut self, key: &str, value: Json) {
if self.name == "zoom"
&& key == "preset"
&& let Some(p) = value.as_str().and_then(|s| s.parse::<f64>().ok())
{
self.values.insert("percent".into(), json!(p));
}
self.values.insert(key.into(), value);
}
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
@ -370,20 +381,26 @@ impl Dialog {
],
json!({"header": "", "footer": "&CPage &P of &N"}),
),
"pageSetup" => Dialog::form(
"pageSetup",
"Page Setup",
"pageLayout.margins",
vec![
Number { key: "left", label: "Left margin (in):" },
Number { key: "right", label: "Right margin (in):" },
Number { key: "top", label: "Top margin (in):" },
Number { key: "bottom", label: "Bottom margin (in):" },
Number { key: "header", label: "Header (in):" },
Number { key: "footer", label: "Footer (in):" },
],
json!({"left": 0.7, "right": 0.7, "top": 0.75, "bottom": 0.75, "header": 0.3, "footer": 0.3}),
),
"pageSetup" => {
// Opens on the active sheet's saved margins, so OK without edits rewrites them unchanged.
let m = app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or([0.7, 0.7, 0.75, 0.75, 0.3, 0.3]);
// Round off f32 noise so the form shows 0.1, not 0.10000000149.
let [left, right, top, bottom, header, footer] = m.map(|v| (f64::from(v) * 1e4).round() / 1e4);
Dialog::form(
"pageSetup",
"Page Setup",
"pageLayout.margins",
vec![
Number { key: "left", label: "Left margin (in):" },
Number { key: "right", label: "Right margin (in):" },
Number { key: "top", label: "Top margin (in):" },
Number { key: "bottom", label: "Bottom margin (in):" },
Number { key: "header", label: "Header (in):" },
Number { key: "footer", label: "Footer (in):" },
],
json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer}),
)
}
"textToColumns" => Dialog::form(
"textToColumns",
"Convert Text to Columns",
@ -575,7 +592,7 @@ impl Dialog {
},
Text { key: "errorMessage", label: "Error message:" },
],
json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}),
validation_defaults(app),
),
"cfQuick" => {
let ty = p("type").as_str().unwrap_or("cellIs").to_string();
@ -807,6 +824,36 @@ impl Dialog {
}
}
/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without
/// edits keeps it; a plain list rule when the cell has none.
fn validation_defaults(app: &SheetApp) -> Json {
let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"});
let Some(doc) = app.session.active() else { return v };
let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else {
return v;
};
// The enums serialize as their variant names; the command takes them in camelCase.
let camel = |x: Json| {
let name = x.as_str().unwrap_or_default();
let mut chars = name.chars();
chars.next().map(|c| c.to_lowercase().chain(chars).collect::<String>()).unwrap_or_default()
};
v["type"] = json!(camel(json!(dv.kind)));
v["operator"] = json!(camel(json!(dv.op)));
v["errorStyle"] = json!(camel(json!(dv.error_style)));
v["formula1"] = json!(dv.f1);
if let Some(f2) = &dv.f2 {
v["formula2"] = json!(f2);
}
v["ignoreBlank"] = json!(dv.allow_blank);
v["dropdown"] = json!(dv.in_cell_dropdown);
v["inputTitle"] = json!(dv.input_title);
v["inputMessage"] = json!(dv.input_message);
v["errorTitle"] = json!(dv.error_title);
v["errorMessage"] = json!(dv.error_message);
v
}
fn abs(r: &gridcraft_engine::core::RangeRef) -> String {
let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1);
if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) }
@ -1288,7 +1335,7 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
for (val, text) in options {
if ui.radio(cur == val, text).clicked() {
d.values.insert(key.into(), json!(val));
d.set_value(key, json!(val));
}
}
});
@ -2352,9 +2399,13 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
let method = text(d, "method");
ui.horizontal(|ui| {
ui.label("Select a Solving Method:");
let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" };
let label = |m: &str| match m {
"simplex" => "Simplex LP",
"evolutionary" => "Evolutionary",
_ => "GRG Nonlinear",
};
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
for m in ["grg", "simplex"] {
for m in ["grg", "simplex", "evolutionary"] {
if ui.selectable_label(method == m, label(m)).clicked() {
d.values.insert("method".into(), json!(m));
}
@ -2362,10 +2413,10 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
});
});
ui.label(
egui::RichText::new(if method == "simplex" {
"Simplex LP: for linear models; integer and binary constraints by branch and bound."
} else {
"GRG Nonlinear: a local optimum of a smooth model, from the current values."
egui::RichText::new(match method.as_str() {
"simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.",
"evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.",
_ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.",
})
.small(),
);
@ -2397,6 +2448,18 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &
d.values.insert("restore".into(), json!(true));
}
}
// Reports describe the solution whether it is kept or not.
let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty());
if solved {
ui.add_space(4.0);
ui.label("Reports:");
for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] {
let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false);
if ui.checkbox(&mut on, label).changed() {
d.values.insert(key.into(), json!(on));
}
}
}
let mut confirm = false;
ok_cancel(ui, &mut confirm, open);
if confirm {
@ -2404,6 +2467,16 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &
if changed && restore {
app.run_or_alert("edit.undo", json!({}));
}
let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")]
.into_iter()
.filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true))
.map(|(_, kind)| kind)
.collect();
if solved && !reports.is_empty() {
let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null);
let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")});
app.run_or_alert("data.solverReport", params);
}
*open = false;
}
}
@ -2460,3 +2533,36 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_validation_opens_on_the_rule_of_the_active_cell() {
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.run("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "5"})).unwrap();
let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap();
assert_eq!(d.values["type"], "whole");
assert_eq!(d.values["operator"], "greater");
assert_eq!(d.values["formula1"], "5");
// OK without edits keeps the rule: 6 is accepted, 4 is rejected.
app.run(d.command.unwrap(), build_params(&d)).unwrap();
let doc = app.session.active().unwrap();
let at = gridcraft_engine::core::CellRef::new(0, 0);
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "6").is_none());
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some());
}
#[test]
fn data_validation_without_a_rule_opens_on_a_blank_list() {
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap();
assert_eq!(d.values["type"], "list");
assert!(d.values.get("formula1").is_none());
}
}

View File

@ -9,6 +9,14 @@ pub(crate) fn canonical(
sheet: usize,
cell: gridcraft_engine::core::CellRef,
) -> Result<String, ParseError> {
// 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

View File

@ -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<String>,
pub drag_select: bool,
pub rect: Option<Rect>,
pub cells_rect: Option<Rect>,
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<Pos2>,
pub marching_phase: f32,
/// The selection outline on its way to a new selection.
@ -84,7 +88,6 @@ pub struct GridState {
pub last_border: String,
pub last_fill: String,
pub last_font_color: String,
pub system_clipboard: Option<String>,
pub trace: Option<serde_json::Value>,
pub renaming_tab: Option<usize>,
pub rename_text: String,
@ -533,10 +536,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);
@ -1373,6 +1377,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<Rect> {
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(
@ -1415,7 +1439,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);
@ -1743,6 +1769,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).
@ -1770,6 +1819,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
@ -1790,6 +1842,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 });
@ -1808,7 +1863,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.
@ -2046,25 +2101,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
@ -2097,6 +2133,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)),
@ -2371,6 +2417,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),
@ -2480,20 +2527,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<RangeRef> {
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
{
@ -2547,7 +2594,9 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
c.1 = all_new;
}
}
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
// Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only
// worked over the values themselves (#432).
egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| {
for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) {
ui.checkbox(on, v.as_str());
}
@ -2651,6 +2700,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
"edit.copy" | "edit.cut" => {
app.copy_to_clipboard(ui.ctx(), id);
}
"edit.paste" => app.paste_from_clipboard(),
other => app.run_or_alert(other, json!({})),
}
}
@ -2674,7 +2724,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()
@ -2715,8 +2775,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())
@ -2893,6 +2954,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
"edit.copy" | "edit.cut" => {
app.copy_to_clipboard(ui.ctx(), id);
}
"edit.paste" => app.paste_from_clipboard(),
other => app.run_or_alert(other, json!({})),
}
}
@ -3053,6 +3115,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));
@ -3238,3 +3326,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());
}
}

View File

@ -16,6 +16,7 @@ mod fr;
mod ko;
mod pt;
mod ru;
mod sr;
mod zh;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -33,10 +34,11 @@ pub enum Language {
De,
Cs,
Fr,
Sr,
}
impl Language {
pub const ALL: [Self; 10] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr];
pub const ALL: [Self; 11] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr, Self::Sr];
/// The language's own name, shown in the switcher.
pub fn name(self) -> &'static str {
@ -51,6 +53,7 @@ impl Language {
Self::De => "Deutsch",
Self::Cs => "Čeština",
Self::Fr => "Français",
Self::Sr => "Srpski",
}
}
@ -67,6 +70,7 @@ impl Language {
Self::De => "de",
Self::Cs => "cs",
Self::Fr => "fr",
Self::Sr => "sr",
}
}
@ -86,6 +90,7 @@ impl Language {
"de" => Some(Self::De),
"cs" => Some(Self::Cs),
"fr" => Some(Self::Fr),
"sr" => Some(Self::Sr),
_ => None,
}
}
@ -123,6 +128,7 @@ impl Language {
Self::De => de::translate(text),
Self::Cs => cs::translate(text),
Self::Fr => fr::translate(text),
Self::Sr => sr::translate(text),
};
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
}
@ -587,6 +593,7 @@ mod tests {
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}");
}
assert_eq!(Language::Ja.tr("Data"), "データ");
assert_eq!(Language::Zh.tr("Data"), "数据");
@ -616,6 +623,10 @@ mod tests {
assert_eq!(Language::Ja.tr("Orientation"), "方向");
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace");
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu");
assert_eq!(Language::Sr.tr("Data"), "Podaci");
assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija");
assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija");
assert_eq!(Language::parse("xx"), None);
}
@ -654,6 +665,9 @@ mod tests {
assert_eq!(Language::from_tag("fr-FR"), Some(Language::Fr));
assert_eq!(Language::from_tag("fr_CA"), Some(Language::Fr));
assert_eq!(Language::parse("fr"), Some(Language::Fr));
assert_eq!(Language::from_tag("sr-Latn-RS"), Some(Language::Sr));
assert_eq!(Language::from_tag("sr-RS"), Some(Language::Sr));
assert_eq!(Language::parse("sr"), Some(Language::Sr));
}
#[test]

View File

@ -391,6 +391,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,
})
}

View File

@ -2,6 +2,8 @@
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Different First Page" => "Première page différente",
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
"Paste" => "Coller",
"Paste Values" => "Coller les valeurs",
"Paste Formulas" => "Coller les formules",
@ -9,32 +11,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Transpose" => "Transposer",
"Paste Link" => "Coller la liaison",
"Paste Special…" => "Collage spécial…",
"Insert Copied Cells" => "Insérer les cellules coupées",
"Insert Copied Cells…" => "Coller en insérant",
"Insert Cut Cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
"Insert Paste" => "Taille de l’interface",
"Insert Copied Cells" => "Insérer les cellules copiées",
"Insert Copied Cells…" => "Insérer les cellules copiées…",
"Insert Cut Cells" => "Insérer les cellules coupées",
"Insert Paste" => "Insérer en collant",
"Cut (⌘X)" => "Couper (⌘X)",
"Copy (⌘C)" => "Copier (⌘C)",
"Format Painter (double-click to keep it on)" => "Reproduire la mise en forme (double-clic pour la conserver)",
"Bold (⌘B)" => "Gras (⌘B)",
"Italic (⌘I)" => "Italique (⌘I)",
"Underline (⌘U)" => "Souligné (⌘U)",
"Double Underline" => "Double souligné",
"Double Underline" => "Double soulignement",
"Strikethrough" => "Barré",
"Underline" => "Souligné",
"Increase Font Size" => "Augmenter la taille de la police",
"Decrease Font Size" => "Réduire la taille de la police",
"Wrap Text" => "Renvoyer à la ligne",
"Increase Font Size" => "Augmenter la taille de police",
"Decrease Font Size" => "Réduire la taille de police",
"Wrap Text" => "Renvoyer à la ligne automatiquement",
"Merge & Center" => "Fusionner et centrer",
"Merge Cells" => "Fusionner les cellules",
"Merge Across" => "Fusionner horizontalement",
"Unmerge Cells" => "Dissocier les cellules",
"Unmerge Cells" => "Annuler la fusion des cellules",
"Fill Color" => "Couleur de remplissage",
"Font Color" => "Couleur de police",
"Borders" => "Bordures",
"Top\nAlign" => "Aligner en haut",
"Middle\nAlign" => "Aligner au centre",
"Bottom\nAlign" => "Aligner en bas",
"Top\nAlign" => "Aligner en\nhaut",
"Middle\nAlign" => "Aligner au\nmilieu",
"Bottom\nAlign" => "Aligner en\nbas",
"Left" => "Gauche",
"Right" => "Droite",
"Center" => "Centré",
@ -50,17 +52,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Rotate Text Down" => "Pivoter le texte vers le bas",
"Accounting Number Format" => "Format de nombre Comptabilité",
"Percent Style" => "Style de pourcentage",
"Comma Style" => "Style de virgule",
"Comma Style" => "Style de milliers",
"Increase Decimal" => "Ajouter une décimale",
"Decrease Decimal" => "Retirer une décimale",
"More Accounting Formats…" => "Autres formats comptables…",
"Conditional\nFormatting" => "Mise en forme conditionnelle",
"Conditional\nFormatting" => "Mise en forme\nconditionnelle",
"Conditional Formatting" => "Mise en forme conditionnelle",
"Format\nas Table" => "Mettre sous forme de tableau",
"Cell\nStyles" => "Styles de cellules",
"Format\nas Table" => "Mettre sous forme\nde tableau",
"Cell\nStyles" => "Styles de\ncellules",
"Format Cells…" => "Format de cellules…",
"Format Cell Alignment…" => "Format d’alignement de cellule…",
"Format\nPane" => "Volet Format",
"Format Cell Alignment…" => "Format d'alignement de cellule…",
"Format\nPane" => "Volet\nFormat",
"Format" => "Format",
"Insert" => "Insertion",
"Insert Cells…" => "Insérer des cellules…",
@ -88,58 +90,58 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Hyperlinks" => "Effacer les liens hypertexte",
"AutoSum" => "Somme automatique",
"AutoSum (⌘⇧T)" => "Somme automatique (⌘⇧T)",
"Flash\nFill" => "Remplissage instantané",
"Flash\nFill" => "Remplissage\ninstantané",
"Flash Fill" => "Remplissage instantané",
"Fill" => "Remplir",
"Clear Print Area" => "Effacer la zone d’impression",
"Find &\nSelect" => "Rechercher et sélectionner",
"Clear Print Area" => "Effacer la zone d'impression",
"Find &\nSelect" => "Rechercher et\nsélectionner",
"Find…" => "Rechercher…",
"Replace…" => "Remplacer…",
"Go To…" => "Atteindre…",
"Go To Special…" => "Atteindre spécial…",
"PivotTable" => "Tableau croisé dynamique",
"Recommended\nCharts" => "Graphiques recommandés",
"Recommended\nCharts" => "Graphiques\nrecommandés",
"Table" => "Tableau",
"Pictures" => "Images",
"Shapes" => "Formes",
"Icons" => "Icônes",
"Text\nBox" => "Zone de texte",
"Text\nBox" => "Zone de\ntexte",
"Comment" => "Commentaire",
"New\nComment" => "Nouveau commentaire",
"New\nComment" => "Nouveau\ncommentaire",
"New Note" => "Nouvelle note",
"Show/Hide Note" => "Afficher/masquer la note",
"Header &\nFooter" => "En-tête et pied de page",
"Text to\nColumns" => "Convertir",
"Header &\nFooter" => "En-tête et\npied de page",
"Text to\nColumns" => "Convertir le texte\nen colonnes",
"Link" => "Lien",
"Symbol" => "Symbole",
"Sparklines" => "Sparklines",
"Checkbox" => "Cliquez pour ajouter des données",
"Insert checkboxes in the selected cells" => "Mots-clés",
"Sparklines" => "Courbes de tendance",
"Checkbox" => "Case à cocher",
"Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
"Header & Footer" => "En-tête et pied de page",
"Draw with ink" => "Dessiner avec l’encre",
"Draw with ink" => "Dessiner à l'encre",
"Eraser" => "Gomme",
"Ink to\nShape" => "Encre vers forme",
"Select\nObjects" => "Sélection d’objets",
"Ink to\nShape" => "Encre en\nforme",
"Select\nObjects" => "Sélectionner les\nobjets",
"Themes" => "Thèmes",
"Margins" => "Marges",
"Size" => "Taille",
"Print\nArea" => "Zone d’impression",
"Set Print Area" => "Définir la zone d’impression",
"Print\nArea" => "Zone\nd'impression",
"Set Print Area" => "Définir la zone d'impression",
"Breaks" => "Sauts",
"Insert Page Break" => "Insérer un saut de page",
"Remove Page Break" => "Supprimer le saut de page",
"Reset All Page Breaks" => "Réinitialiser tous les sauts de page",
"Print\nTitles" => "Titres à imprimer",
"Sheet\nRight-to-Left" => "Afficher la barre de formule",
"Sheet Right-to-Left" => "Feuilles",
"Narrow" => "Étroites",
"Wide" => "Larges",
"Normal" => "Normales",
"Print\nTitles" => "Titres à\nimprimer",
"Sheet\nRight-to-Left" => "Feuille de\ndroite à gauche",
"Sheet Right-to-Left" => "Feuille de droite à gauche",
"Narrow" => "Étroit",
"Wide" => "Large",
"Normal" => "Normal",
"Portrait" => "Portrait",
"Landscape" => "Paysage",
"Custom Margins…" => "Marges personnalisées…",
"Insert\nFunction" => "Insérer une fonction",
"Name\nManager" => "Gestionnaire de noms",
"Insert\nFunction" => "Insérer une\nfonction",
"Name\nManager" => "Gestionnaire\nde noms",
"Define Name" => "Définir un nom",
"Use in Formula" => "Utiliser dans la formule",
"Create from Selection" => "Créer à partir de la sélection",
@ -150,90 +152,92 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Error Checking" => "Vérification des erreurs",
"Evaluate Formula" => "Évaluation de formule",
"Watch Window" => "Fenêtre Espion",
"Calculation\nOptions" => "Options de calcul",
"Calculation\nOptions" => "Options de\ncalcul",
"Calculate Now" => "Calculer maintenant",
"Calculate Sheet" => "Calculer la feuille",
"Automatic" => "Automatique",
"Automatic Except for Data Tables" => "Automatique sauf pour les tables de données",
"Manual" => "Manuel",
"Sort &\nFilter" => "Trier et filtrer",
"Sort &\nFilter" => "Trier et\nfiltrer",
"Sort" => "Trier",
"Sort A to Z" => "Trier de A à Z",
"Sort Z to A" => "Trier de Z à A",
"Custom Sort…" => "Tri personnalisé…",
"Filter" => "Filtrer",
"Reapply" => "Réappliquer",
"Get Data\n(Text/CSV)" => "Obtenir des données (Texte/CSV)",
"Remove\nDuplicates" => "Supprimer les doublons",
"Data\nValidation" => "Validation des données",
"Get Data\n(Text/CSV)" => "Obtenir des données\n(Texte/CSV)",
"Remove\nDuplicates" => "Supprimer les\ndoublons",
"Data\nValidation" => "Validation des\ndonnées",
"Data Validation" => "Validation des données",
"Data Validation…" => "Validation des données…",
"Circle Invalid Data" => "Cerner les données non valides",
"Clear Validation" => "Effacer la validation",
"Group" => "Grouper",
"Ungroup" => "Dissocier",
"Show Detail" => "Titre",
"Hide Detail" => "Insérer les cellules copiées",
"Show Detail" => "Afficher le détail",
"Hide Detail" => "Masquer le détail",
"Subtotal" => "Sous-total",
"Data\nAnalysis" => "Description",
"Data Analysis" => "Analyse\nde données",
"Solver" => "Page web (.html)",
"What-If\nAnalysis" => "Analyse scénarios",
"Data\nAnalysis" => "Analyse de\ndonnées",
"Data Analysis" => "Analyse de données",
"Solver" => "Solveur",
"What-If\nAnalysis" => "Analyse\nde scénarios",
"Data Table…" => "Table de données…",
"Scenario Manager…" => "Gestionnaire de scénarios…",
"Goal Seek…" => "Valeur cible…",
"Forecast\nSheet" => "Aller au pied de page",
"Forecast\nSheet" => "Feuille de\nprévision",
"Spelling" => "Orthographe",
"Check\nAccessibility" => "Vérifier l’accessibilité",
"Threaded Comments" => "Commentaires threadés",
"Show\nComments" => "Afficher les commentaires",
"Previous" => "Imprimer les feuilles actives",
"Next" => "Aucun classeur récent pour le moment.",
"Previous Comment" => "Imprimer tout le classeur",
"Next Comment" => "Aucun classeur n’est ouvert.",
"Show All Notes" => "Objet",
"Convert to Comments" => "Créer un PDF",
"Protect\nWorkbook" => "Protéger le classeur",
"Allow Edit\nRanges" => "Animations",
"Allow Edit Ranges" => "Autoriser la\nmodification des plages",
"Unprotect\nSheet" => "Ôter la protection de la feuille",
"Check\nAccessibility" => "Vérifier\nl'accessibilité",
"Threaded Comments" => "Commentaires en fil de discussion",
"Show\nComments" => "Afficher les\ncommentaires",
"Previous" => "Précédent",
"Next" => "Suivant",
"Previous Comment" => "Commentaire précédent",
"Next Comment" => "Commentaire suivant",
"Show All Notes" => "Afficher toutes les notes",
"Convert to Comments" => "Convertir en commentaires",
"Protect\nWorkbook" => "Protéger le\nclasseur",
"Allow Edit\nRanges" => "Autoriser la modification\ndes plages",
"Allow Edit Ranges" => "Autoriser la modification des plages",
"Unprotect\nSheet" => "Ôter la protection\nde la feuille",
"Protect Sheet…" => "Protéger la feuille…",
"Comments" => "Commentaires",
"Notes" => "Notes",
"Page Break\nPreview" => "Aperçu des sauts de page",
"Page\nLayout" => "Mise en page",
"Page Break\nPreview" => "Aperçu des sauts\nde page",
"Page\nLayout" => "Mise en\npage",
"Formula Bar" => "Barre de formule",
"Gridlines" => "Quadrillage",
"Headings" => "En-têtes",
"Zoom" => "Zoom",
"100%" => "100%",
"Zoom to\nSelection" => "Zoom sur la sélection",
"Freeze\nPanes" => "Figer les volets",
"100%" => "100 %",
"Zoom to\nSelection" => "Zoom sur la\nsélection",
"Freeze\nPanes" => "Figer les\nvolets",
"Freeze Panes" => "Figer les volets",
"Freeze Top Row" => "Figer la ligne supérieure",
"Freeze First Column" => "Figer la première colonne",
"Unfreeze Panes" => "Libérer les volets",
"Dark Mode" => "Mode sombre",
"Display theme" => "Thème d’affichage",
"Animations" => "Auteur",
"Glide the selection outline to the cell you move to instead of jumping there" => "Aller à l’en-tête",
"Display theme" => "Thème d'affichage",
"Animations" => "Animations",
"Glide the selection outline to the cell you move to instead of jumping there" => {
"Faire glisser le contour de la sélection jusqu'à la cellule de destination au lieu d'y sauter directement"
}
"System" => "Système",
"Light" => "Clair",
"Dark" => "Sombre",
"Interface language" => "Langue de l’interface",
"Interface size" => "Emplacement",
"Auto" => "Retour",
"Drag to freeze columns" => "Faites glisser pour figer les lignes",
"Drag to freeze rows" => "Activer le calcul itératif",
"Command\nPalette" => "Palette de commandes",
"Agent\nControl" => "Contrôle de l’agent",
"Action\nJournal" => "Journal des actions",
"About\nGridCraft" => "À propos de GridCraft",
"Interface language" => "Langue de l'interface",
"Interface size" => "Taille de l'interface",
"Auto" => "Automatique",
"Drag to freeze columns" => "Faites glisser pour figer les colonnes",
"Drag to freeze rows" => "Faites glisser pour figer les lignes",
"Command\nPalette" => "Palette de\ncommandes",
"Agent\nControl" => "Contrôle de\nl'agent",
"Action\nJournal" => "Journal des\nactions",
"About\nGridCraft" => "À propos de\nGridCraft",
"Share" => "Partager",
"Add Chart\nElement" => "Ajouter un élément de graphique",
"Change\nChart Type" => "Modifier le type de graphique",
"Switch\nRow/Column" => "Permuter ligne/colonne",
"Select\nData" => "Nom de la\nfeuille",
"Add Chart\nElement" => "Ajouter un élément\nde graphique",
"Change\nChart Type" => "Modifier le type\nde graphique",
"Switch\nRow/Column" => "Permuter\nligne/colonne",
"Select\nData" => "Sélectionner les\ndonnées",
"Data Labels: None" => "Étiquettes de données : Aucune",
"Data Labels: Show" => "Étiquettes de données : Afficher",
"Legend: None" => "Légende : Aucune",
@ -242,10 +246,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Legend: Right" => "Légende : Droite",
"Gridlines: None" => "Quadrillage : Aucun",
"Gridlines: Show" => "Quadrillage : Afficher",
"Chart\nTitle" => "Titre du graphique",
"Delete\nChart" => "Supprimer le graphique",
"Convert\nto Range" => "Convertir en plage",
"Table\nStyles" => "Styles de tableau",
"Chart\nTitle" => "Titre du\ngraphique",
"Delete\nChart" => "Supprimer le\ngraphique",
"Convert\nto Range" => "Convertir\nen plage",
"Table\nStyles" => "Styles de\ntableau",
"Top 10 Items" => "10 premiers éléments",
"Top 10%" => "10 % les plus élevés",
"Bottom 10 Items" => "10 derniers éléments",
@ -257,7 +261,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Constants" => "Constantes",
"Data Bars" => "Barres de données",
"Color Scales" => "Échelles de couleurs",
"Icon Sets" => "Jeux d’icônes",
"Icon Sets" => "Jeux d'icônes",
"Home" => "Accueil",
"Draw" => "Dessin",
"Page Layout" => "Mise en page",
@ -291,96 +295,104 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Autres formats numériques…",
"Financial" => "Financières",
"Logical" => "Logiques",
"Date &\nTime" => "Date et heure",
"Lookup &\nReference" => "Recherche et référence",
"Math &\nTrig" => "Maths et trigonométrie",
"More\nFunctions" => "Autres fonctions",
"Sheet Options|View" => "Affichage",
"Date &\nTime" => "Date et\nheure",
"Lookup &\nReference" => "Recherche et\nréférence",
"Math &\nTrig" => "Maths et\ntrigonométrie",
"More\nFunctions" => "Autres\nfonctions",
"Sheet Options|View" => "Options de la feuille|Affichage",
"Print" => "Impression",
"Click to add data" => "Fermer",
"Add header" => "Autoriser la modification des plages",
"Add footer" => "Ajouter un en-tête",
"Click to add data" => "Cliquez pour ajouter des données",
"Add header" => "Ajouter un en-tête",
"Add footer" => "Ajouter un pied de page",
"(none)" => "(aucun)",
"Header" => "Informations",
"Footer" => "Feuille de\nprévision",
"Page\nNumber" => "Page précédente",
"Number\nof Pages" => "Général",
"Current\nDate" => "Heure\nactuelle",
"Current\nTime" => "Analyse de données",
"File\nPath" => "Ajuster la feuille sur une seule page",
"File\nName" => "Chemin\ndu fichier",
"Sheet\nName" => "Afficher le détail",
"Go to\nHeader" => "Masquer le détail",
"Go to\nFooter" => "En-tête",
"Page Number" => "Numéro\nde page",
"Number of Pages" => "Options",
"Current Date" => "Heure actuelle",
"Current Time" => "Date\nactuelle",
"File Path" => "Nom du\nfichier",
"File Name" => "Chemin du fichier",
"Sheet Name" => "Feuille de\ndroite à gauche",
"Go to Header" => "Aller à\nl’en-tête",
"Go to Footer" => "Aller au\npied de page",
"Ruler" => "Enregistrer sous",
"File" => "Nom du fichier",
"Back" => "Classeur vide",
"New" => "Page suivante",
"Open" => "Enregistrement",
"Info" => "Insérer les cellules copiées…",
"Save" => "Type de fichier",
"Save As" => "Enregistrer la feuille sous forme de document PDF en conservant sa mise en page.",
"Export" => "Fichier",
"Close" => "Société",
"Options" => "PDF (*.pdf)",
"Blank workbook" => "Parcourir…",
"Recent" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"No recent workbooks yet." => "Rien à imprimer.",
"More workbooks" => "Commentaire suivant",
"Browse…" => "CSV (séparé par des virgules)",
"Not saved yet" => "Nombre\nde pages",
"Location" => "Nouveau",
"Sheets" => "Solveur",
"Protect Workbook" => "Enregistrer",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "Afficher toutes les notes",
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "Suivant",
"Properties" => "Règle",
"Header" => "En-tête",
"Footer" => "Pied de page",
"Page\nNumber" => "Numéro de\npage",
"Number\nof Pages" => "Nombre\nde pages",
"Current\nDate" => "Date\nactuelle",
"Current\nTime" => "Heure\nactuelle",
"File\nPath" => "Chemin\ndu fichier",
"File\nName" => "Nom du\nfichier",
"Sheet\nName" => "Nom de la\nfeuille",
"Go to\nHeader" => "Aller à\nl'en-tête",
"Go to\nFooter" => "Aller au\npied de page",
"Page Number" => "Numéro de page",
"Number of Pages" => "Nombre de pages",
"Current Date" => "Date actuelle",
"Current Time" => "Heure actuelle",
"File Path" => "Chemin du fichier",
"File Name" => "Nom du fichier",
"Sheet Name" => "Nom de la feuille",
"Go to Header" => "Aller à l'en-tête",
"Go to Footer" => "Aller au pied de page",
"Ruler" => "Règle",
"File" => "Fichier",
"Back" => "Retour",
"New" => "Nouveau",
"Open" => "Ouvrir",
"Info" => "Informations",
"Save" => "Enregistrer",
"Save As" => "Enregistrer sous",
"Export" => "Exporter",
"Close" => "Fermer",
"Options" => "Options",
"Blank workbook" => "Classeur vide",
"Recent" => "Récent",
"No recent workbooks yet." => "Aucun classeur récent pour le moment.",
"More workbooks" => "Plus de classeurs",
"Browse…" => "Parcourir…",
"Not saved yet" => "Pas encore enregistré",
"Location" => "Emplacement",
"Sheets" => "Feuilles",
"Protect Workbook" => "Protéger le classeur",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"La structure de la feuille est verrouillée : les feuilles ne peuvent pas être ajoutées, déplacées, renommées ou supprimées."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Verrouiller la structure des feuilles pour qu'elles ne puissent pas être ajoutées, déplacées, renommées ou supprimées."
}
"Properties" => "Propriétés",
"Title" => "Titre",
"Subject" => "Calcul du classeur",
"Author" => "Automatique",
"Company" => "Convertir en commentaires",
"Keywords" => "Plus de classeurs",
"Description" => "Télécharger",
"No workbook is open." => "Nombre de pages",
"Save as type" => "Enregistrer le classeur sous forme de page web.",
"Excel Workbook (*.xlsx)" => "Exporter",
"CSV UTF-8 (Comma delimited) (*.csv)" => "Les paramètres de calcul sont enregistrés avec le classeur.",
"Subject" => "Sujet",
"Author" => "Auteur",
"Company" => "Société",
"Keywords" => "Mots-clés",
"Description" => "Description",
"No workbook is open." => "Aucun classeur n'est ouvert.",
"Save as type" => "Type de fichier",
"Excel Workbook (*.xlsx)" => "Classeur Excel (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (séparateur point-virgule) (*.csv)",
"Web Page (*.html)" => "Page web (*.html)",
"PDF (*.pdf)" => "Page {page} sur {pages}",
"Download" => "Faites glisser pour figer les colonnes",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => "Ajouter un pied de page",
"Save the workbook under another name or in another folder." => "Feuille de droite à gauche",
"Print Active Sheets" => "Protéger le classeur",
"Print Entire Workbook" => "Récents",
"Fit Sheet on One Page" => "Pied de page",
"Page Setup…" => "Précédent",
"Page {page} of {pages}" => "Commentaire précédent",
"Nothing to print." => "Ouvrir",
"Previous page" => "Propriétés",
"Next page" => "Pas encore enregistré",
"Create PDF" => "Date actuelle",
"Save the sheet as a PDF document that keeps its page layout." => "Sélectionner\nles données",
"Change File Type" => "Case à cocher",
"CSV (Comma delimited)" => "CSV UTF-8 (séparé par des virgules) (*.csv)",
"Save the active sheet as comma-separated text." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.",
"PDF (*.pdf)" => "PDF (*.pdf)",
"Download" => "Télécharger",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Un fichier CSV, une page web ou un PDF est une copie : le classeur conserve son propre fichier."
}
"Save the workbook under another name or in another folder." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.",
"Print Active Sheets" => "Imprimer les feuilles actives",
"Print Entire Workbook" => "Imprimer tout le classeur",
"Fit Sheet on One Page" => "Ajuster la feuille sur une page",
"Page Setup…" => "Mise en page…",
"Page {page} of {pages}" => "Page {page} sur {pages}",
"Nothing to print." => "Rien à imprimer.",
"Previous page" => "Page précédente",
"Next page" => "Page suivante",
"Create PDF" => "Créer un PDF",
"Save the sheet as a PDF document that keeps its page layout." => {
"Enregistrer la feuille en tant que document PDF qui conserve sa mise en page."
}
"Change File Type" => "Changer le type de fichier",
"CSV (Comma delimited)" => "CSV (séparateur point-virgule)",
"Save the active sheet as comma-separated text." => "Enregistrer la feuille active sous forme de texte séparé par des points-virgules.",
"Web Page (.html)" => "Page web (.html)",
"Save the workbook as a web page." => "Nom de la feuille",
"Options|General" => "Numéro de page",
"Options|Save" => "Mise en page…",
"Show formula bar" => "Page web (*.html)",
"Save .xlsx workbooks automatically after every change" => "Enregistrer la feuille active sous forme de texte séparé par des virgules.",
"Save the workbook as a web page." => "Enregistrer le classeur en tant que page web.",
"Options|General" => "Options|Général",
"Options|Save" => "Options|Enregistrer",
"Show formula bar" => "Afficher la barre de formule",
"Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"Workbook calculation" => "Calcul du classeur",
"Enable iterative calculation" => "Classeur Excel (*.xlsx)",
"Calculation settings are saved with the workbook." => "Modifier le type de fichier",
"Enable iterative calculation" => "Activer le calcul itératif",
"Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.",
_ => return None,
})
}

View File

@ -394,6 +394,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,
})
}

View File

@ -0,0 +1,396 @@
//! Serbian (Latin script, ekavian) translations for the interface strings in i18n.rs.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Nalepi",
"Paste Values" => "Nalepi vrednosti",
"Paste Formulas" => "Nalepi formule",
"Paste Formatting" => "Nalepi oblikovanje",
"Transpose" => "Transponuj",
"Paste Link" => "Nalepi vezu",
"Paste Special…" => "Specijalno lepljenje…",
"Insert Copied Cells" => "Umetni kopirane ćelije",
"Insert Copied Cells…" => "Umetni kopirane ćelije…",
"Insert Cut Cells" => "Umetni isečene ćelije",
"Insert Paste" => "Umetni lepljenje",
"Cut (⌘X)" => "Iseci (⌘X)",
"Copy (⌘C)" => "Kopiraj (⌘C)",
"Format Painter (double-click to keep it on)" => "Kopiraj oblikovanje (dupli klik da ostane uključeno)",
"Bold (⌘B)" => "Podebljano (⌘B)",
"Italic (⌘I)" => "Kurziv (⌘I)",
"Underline (⌘U)" => "Podvučeno (⌘U)",
"Double Underline" => "Dvostruko podvlačenje",
"Strikethrough" => "Precrtano",
"Underline" => "Podvučeno",
"Increase Font Size" => "Povećaj veličinu fonta",
"Decrease Font Size" => "Smanji veličinu fonta",
"Wrap Text" => "Prelomi tekst",
"Merge & Center" => "Spoji i centriraj",
"Merge Cells" => "Spoji ćelije",
"Merge Across" => "Spoji po redovima",
"Unmerge Cells" => "Razdvoji ćelije",
"Fill Color" => "Boja popune",
"Font Color" => "Boja fonta",
"Borders" => "Ivice",
"Top\nAlign" => "Gornje\nporavnanje",
"Middle\nAlign" => "Srednje\nporavnanje",
"Bottom\nAlign" => "Donje\nporavnanje",
"Left" => "Levo",
"Right" => "Desno",
"Center" => "Centrirano",
"Horizontal" => "Horizontalno",
"Vertical Text" => "Vertikalni tekst",
"Decrease Indent" => "Smanji uvlačenje",
"Increase Indent" => "Povećaj uvlačenje",
"Orientation" => "Orijentacija",
"Page Layout|Orientation" => "Orijentacija",
"Angle Counterclockwise" => "Ugao u levo",
"Angle Clockwise" => "Ugao u desno",
"Rotate Text Up" => "Rotiraj tekst nagore",
"Rotate Text Down" => "Rotiraj tekst nadole",
"Accounting Number Format" => "Računovodstveni format",
"Percent Style" => "Procentualni stil",
"Comma Style" => "Stil sa zapetama",
"Increase Decimal" => "Povećaj decimale",
"Decrease Decimal" => "Smanji decimale",
"More Accounting Formats…" => "Još računovodstvenih formata…",
"Conditional\nFormatting" => "Uslovno\noblikovanje",
"Conditional Formatting" => "Uslovno oblikovanje",
"Format\nas Table" => "Oblikuj\nkao tabelu",
"Cell\nStyles" => "Stilovi\nćelija",
"Format Cells…" => "Oblikovanje ćelija…",
"Format Cell Alignment…" => "Oblikovanje poravnanja ćelije…",
"Format\nPane" => "Okno za\noblikovanje",
"Format" => "Oblikovanje",
"Insert" => "Umetni",
"Insert Cells…" => "Umetni ćelije…",
"Delete" => "Obriši",
"Delete Cells…" => "Obriši ćelije…",
"Insert Sheet Rows" => "Umetni redove lista",
"Insert Sheet Columns" => "Umetni kolone lista",
"Delete Sheet Rows" => "Obriši redove lista",
"Delete Sheet Columns" => "Obriši kolone lista",
"Row Height…" => "Visina reda…",
"AutoFit Row Height" => "Automatsko uklapanje visine reda",
"Column Width…" => "Širina kolone…",
"AutoFit Column Width" => "Automatsko uklapanje širine kolone",
"Default Width…" => "Podrazumevana širina…",
"Hide Rows" => "Sakrij redove",
"Unhide Rows" => "Otkrij redove",
"Hide Columns" => "Sakrij kolone",
"Unhide Columns" => "Otkrij kolone",
"Lock Cell" => "Zaključaj ćeliju",
"Clear" => "Obriši",
"Clear All" => "Obriši sve",
"Clear Formats" => "Obriši oblikovanja",
"Clear Contents" => "Obriši sadržaj",
"Clear Comments and Notes" => "Obriši komentare i beleške",
"Clear Hyperlinks" => "Obriši hiperveze",
"AutoSum" => "Automatski zbir",
"AutoSum (⌘⇧T)" => "Automatski zbir (⌘⇧T)",
"Flash\nFill" => "Brzo\npopunjavanje",
"Flash Fill" => "Brzo popunjavanje",
"Fill" => "Popuni",
"Clear Print Area" => "Obriši oblast štampe",
"Find &\nSelect" => "Pronađi i\nizaberi",
"Find…" => "Pronađi…",
"Replace…" => "Zameni…",
"Go To…" => "Idi na…",
"Go To Special…" => "Idi na posebno…",
"PivotTable" => "Izvedena tabela",
"Recommended\nCharts" => "Preporučeni\ngrafikoni",
"Table" => "Tabela",
"Pictures" => "Slike",
"Shapes" => "Oblici",
"Icons" => "Ikone",
"Text\nBox" => "Tekstualni\nokvir",
"Comment" => "Komentar",
"New\nComment" => "Novi\nkomentar",
"New Note" => "Nova beleška",
"Show/Hide Note" => "Prikaži/sakrij belešku",
"Header &\nFooter" => "Zaglavlje i\npodnožje",
"Text to\nColumns" => "Tekst u\nkolone",
"Link" => "Veza",
"Symbol" => "Simbol",
"Sparklines" => "Mini grafikoni",
"Checkbox" => "Polje za potvrdu",
"Insert checkboxes in the selected cells" => "Umetni polja za potvrdu u izabrane ćelije",
"Header & Footer" => "Zaglavlje i podnožje",
"Draw with ink" => "Crtaj mastilom",
"Eraser" => "Gumica",
"Ink to\nShape" => "Mastilo u\noblik",
"Select\nObjects" => "Izaberi\nobjekte",
"Themes" => "Teme",
"Margins" => "Margine",
"Size" => "Veličina",
"Print\nArea" => "Oblast\nštampe",
"Set Print Area" => "Postavi oblast štampe",
"Breaks" => "Prelomi",
"Insert Page Break" => "Umetni prelom stranice",
"Remove Page Break" => "Ukloni prelom stranice",
"Reset All Page Breaks" => "Resetuj sve prelome stranica",
"Print\nTitles" => "Naslovi za\nštampu",
"Sheet\nRight-to-Left" => "List\nzdesna nalevo",
"Sheet Right-to-Left" => "List zdesna nalevo",
"Narrow" => "Usko",
"Wide" => "Široko",
"Normal" => "Normalno",
"Portrait" => "Uspravno",
"Landscape" => "Položeno",
"Custom Margins…" => "Prilagođene margine…",
"Insert\nFunction" => "Umetni\nfunkciju",
"Name\nManager" => "Upravljač\nimenima",
"Define Name" => "Definiši ime",
"Use in Formula" => "Koristi u formuli",
"Create from Selection" => "Napravi iz selekcije",
"Trace Precedents" => "Prati prethodnike",
"Trace Dependents" => "Prati zavisne ćelije",
"Remove Arrows" => "Ukloni strelice",
"Show Formulas" => "Prikaži formule",
"Error Checking" => "Provera grešaka",
"Evaluate Formula" => "Proceni formulu",
"Watch Window" => "Prozor za praćenje",
"Calculation\nOptions" => "Opcije\nizračunavanja",
"Calculate Now" => "Izračunaj sada",
"Calculate Sheet" => "Izračunaj list",
"Automatic" => "Automatski",
"Automatic Except for Data Tables" => "Automatski osim za tabele podataka",
"Manual" => "Ručno",
"Sort &\nFilter" => "Sortiraj i\nfiltriraj",
"Sort" => "Sortiraj",
"Sort A to Z" => "Sortiraj od A do Š",
"Sort Z to A" => "Sortiraj od Š do A",
"Custom Sort…" => "Prilagođeno sortiranje…",
"Filter" => "Filter",
"Reapply" => "Ponovo primeni",
"Get Data\n(Text/CSV)" => "Preuzmi podatke\n(Tekst/CSV)",
"Remove\nDuplicates" => "Ukloni\nduplikate",
"Data\nValidation" => "Provera\npodataka",
"Data Validation" => "Provera podataka",
"Data Validation…" => "Provera podataka…",
"Circle Invalid Data" => "Zaokruži nevažeće podatke",
"Clear Validation" => "Ukloni proveru",
"Group" => "Grupiši",
"Ungroup" => "Razgrupiši",
"Show Detail" => "Prikaži detalje",
"Hide Detail" => "Sakrij detalje",
"Subtotal" => "Međuzbir",
"Data\nAnalysis" => "Analiza\npodataka",
"Data Analysis" => "Analiza podataka",
"Solver" => "Rešavač",
"What-If\nAnalysis" => "Analiza\nšta-ako",
"Data Table…" => "Tabela podataka…",
"Scenario Manager…" => "Upravljač scenarijima…",
"Goal Seek…" => "Traženje cilja…",
"Forecast\nSheet" => "Prognozni\nlist",
"Spelling" => "Pravopis",
"Check\nAccessibility" => "Provera\npristupačnosti",
"Threaded Comments" => "Komentari u nizu",
"Show\nComments" => "Prikaži\nkomentare",
"Previous" => "Prethodni",
"Next" => "Sledeći",
"Previous Comment" => "Prethodni komentar",
"Next Comment" => "Sledeći komentar",
"Show All Notes" => "Prikaži sve beleške",
"Convert to Comments" => "Pretvori u komentare",
"Protect\nWorkbook" => "Zaštiti\nradnu svesku",
"Allow Edit\nRanges" => "Dozvoli uređivanje\nopsega",
"Allow Edit Ranges" => "Dozvoli uređivanje opsega",
"Unprotect\nSheet" => "Ukloni zaštitu\nlista",
"Protect Sheet…" => "Zaštiti list…",
"Comments" => "Komentari",
"Notes" => "Beleške",
"Page Break\nPreview" => "Pregled preloma\nstranica",
"Page\nLayout" => "Izgled\nstranice",
"Formula Bar" => "Traka formule",
"Gridlines" => "Linije mreže",
"Headings" => "Zaglavlja",
"Zoom" => "Uvećanje",
"100%" => "100%",
"Zoom to\nSelection" => "Uvećaj na\nselekciju",
"Freeze\nPanes" => "Zamrzni\nokna",
"Freeze Panes" => "Zamrzni okna",
"Freeze Top Row" => "Zamrzni gornji red",
"Freeze First Column" => "Zamrzni prvu kolonu",
"Unfreeze Panes" => "Odmrzni okna",
"Dark Mode" => "Tamni režim",
"Display theme" => "Tema prikaza",
"Animations" => "Animacije",
"Glide the selection outline to the cell you move to instead of jumping there" => {
"Kliziti obris selekcije do ćelije na koju se pomeraš umesto skoka"
}
"System" => "Sistem",
"Light" => "Svetla",
"Dark" => "Tamna",
"Interface language" => "Jezik interfejsa",
"Interface size" => "Veličina interfejsa",
"Auto" => "Automatski",
"Drag to freeze columns" => "Prevuci da zamrzneš kolone",
"Drag to freeze rows" => "Prevuci da zamrzneš redove",
"Command\nPalette" => "Paleta\nkomandi",
"Agent\nControl" => "Kontrola\nagenta",
"Action\nJournal" => "Dnevnik\nradnji",
"About\nGridCraft" => "O programu\nGridCraft",
"Share" => "Deli",
"Add Chart\nElement" => "Dodaj element\ngrafikona",
"Change\nChart Type" => "Promeni\ntip grafikona",
"Switch\nRow/Column" => "Zameni\nred/kolonu",
"Select\nData" => "Izaberi\npodatke",
"Data Labels: None" => "Oznake podataka: Ništa",
"Data Labels: Show" => "Oznake podataka: Prikaži",
"Legend: None" => "Legenda: Ništa",
"Legend: Bottom" => "Legenda: Dole",
"Legend: Top" => "Legenda: Gore",
"Legend: Right" => "Legenda: Desno",
"Gridlines: None" => "Linije mreže: Ništa",
"Gridlines: Show" => "Linije mreže: Prikaži",
"Chart\nTitle" => "Naslov\ngrafikona",
"Delete\nChart" => "Obriši\ngrafikon",
"Convert\nto Range" => "Pretvori\nu opseg",
"Table\nStyles" => "Stilovi\ntabele",
"Top 10 Items" => "10 najboljih stavki",
"Top 10%" => "Gornjih 10%",
"Bottom 10 Items" => "10 najgorih stavki",
"Bottom 10%" => "Donjih 10%",
"Above Average" => "Iznad proseka",
"Below Average" => "Ispod proseka",
"Clear Rules from Entire Sheet" => "Obriši pravila sa celog lista",
"Clear Rules from Selected Cells" => "Obriši pravila iz izabranih ćelija",
"Constants" => "Konstante",
"Data Bars" => "Trake podataka",
"Color Scales" => "Skale boja",
"Icon Sets" => "Skupovi ikona",
"Home" => "Početna",
"Draw" => "Crtanje",
"Page Layout" => "Izgled stranice",
"Formulas" => "Formule",
"Data" => "Podaci",
"Review" => "Pregled",
"View" => "Prikaz",
"Automate" => "Automatizacija",
"Table Design" => "Dizajn tabele",
"Chart Design" => "Dizajn grafikona",
"Ready" => "Spremno",
"Enter" => "Unos",
"Edit" => "Uređivanje",
"Point" => "Pokaži",
"Calculate" => "Izračunavanje",
"Select destination and press Enter or choose Paste" => "Izaberi odredište i pritisni Enter ili izaberi Nalepi",
"General" => "Opšte",
"Number" => "Broj",
"Currency" => "Valuta",
"Accounting" => "Računovodstveno",
"Date" => "Datum",
"Time" => "Vreme",
"Percentage" => "Procenat",
"Fraction" => "Razlomak",
"Scientific" => "Naučno",
"Text" => "Tekst",
"Special" => "Posebno",
"Custom" => "Prilagođeno",
"Short Date" => "Kratak datum",
"Long Date" => "Dugačak datum",
"More Number Formats…" => "Još formata brojeva…",
"Financial" => "Finansijsko",
"Logical" => "Logičko",
"Date &\nTime" => "Datum i\nvreme",
"Lookup &\nReference" => "Pretraga i\nreference",
"Math &\nTrig" => "Matematika i\ntrigonometrija",
"More\nFunctions" => "Još\nfunkcija",
"Sheet Options|View" => "Opcije lista|Prikaz",
"Print" => "Štampaj",
"Click to add data" => "Kliknite da dodate podatke",
"Add header" => "Dodaj zaglavlje",
"Add footer" => "Dodaj podnožje",
"(none)" => "(ništa)",
"Header" => "Zaglavlje",
"Footer" => "Podnožje",
"Page\nNumber" => "Broj\nstranice",
"Number\nof Pages" => "Broj\nstranica",
"Current\nDate" => "Trenutni\ndatum",
"Current\nTime" => "Trenutno\nvreme",
"File\nPath" => "Putanja\nfajla",
"File\nName" => "Ime\nfajla",
"Sheet\nName" => "Ime\nlista",
"Go to\nHeader" => "Idi na\nzaglavlje",
"Go to\nFooter" => "Idi na\npodnožje",
"Page Number" => "Broj stranice",
"Number of Pages" => "Broj stranica",
"Current Date" => "Trenutni datum",
"Current Time" => "Trenutno vreme",
"File Path" => "Putanja fajla",
"File Name" => "Ime fajla",
"Sheet Name" => "Ime lista",
"Go to Header" => "Idi na zaglavlje",
"Different First Page" => "Drugačija prva stranica",
"Different Odd & Even Pages" => "Drugačije neparne i parne stranice",
"Go to Footer" => "Idi na podnožje",
"Ruler" => "Lenjir",
"File" => "Datoteka",
"Back" => "Nazad",
"New" => "Novo",
"Open" => "Otvori",
"Info" => "Informacije",
"Save" => "Sačuvaj",
"Save As" => "Sačuvaj kao",
"Export" => "Izvezi",
"Close" => "Zatvori",
"Options" => "Opcije",
"Blank workbook" => "Prazna radna sveska",
"Recent" => "Nedavno",
"No recent workbooks yet." => "Još nema nedavnih radnih svezaka.",
"More workbooks" => "Više radnih svezaka",
"Browse…" => "Pregledaj…",
"Not saved yet" => "Još nije sačuvano",
"Location" => "Lokacija",
"Sheets" => "Listovi",
"Protect Workbook" => "Zaštiti radnu svesku",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"Struktura lista je zaključana: listovi se ne mogu dodavati, premeštati, preimenovati ni brisati."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Zaključaj strukturu listova tako da se ne mogu dodavati, premeštati, preimenovati ni brisati."
}
"Properties" => "Svojstva",
"Title" => "Naslov",
"Subject" => "Predmet",
"Author" => "Autor",
"Company" => "Kompanija",
"Keywords" => "Ključne reči",
"Description" => "Opis",
"No workbook is open." => "Nijedna radna sveska nije otvorena.",
"Save as type" => "Sačuvaj kao tip",
"Excel Workbook (*.xlsx)" => "Excel radna sveska (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (odvojeno zapetama) (*.csv)",
"Web Page (*.html)" => "Veb stranica (*.html)",
"PDF (*.pdf)" => "PDF (*.pdf)",
"Download" => "Preuzmi",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"CSV fajl, veb stranica ili PDF su kopija: radna sveska zadržava svoj fajl."
}
"Save the workbook under another name or in another folder." => "Sačuvaj radnu svesku pod drugim imenom ili u drugoj fascikli.",
"Print Active Sheets" => "Štampaj aktivne listove",
"Print Entire Workbook" => "Štampaj celu radnu svesku",
"Fit Sheet on One Page" => "Uklopi list na jednu stranicu",
"Page Setup…" => "Podešavanje stranice…",
"Page {page} of {pages}" => "Stranica {page} od {pages}",
"Nothing to print." => "Nema ničega za štampu.",
"Previous page" => "Prethodna stranica",
"Next page" => "Sledeća stranica",
"Create PDF" => "Napravi PDF",
"Save the sheet as a PDF document that keeps its page layout." => "Sačuvaj list kao PDF dokument koji zadržava izgled stranice.",
"Change File Type" => "Promeni tip fajla",
"CSV (Comma delimited)" => "CSV (odvojeno zapetama)",
"Save the active sheet as comma-separated text." => "Sačuvaj aktivni list kao tekst odvojen zapetama.",
"Web Page (.html)" => "Veb stranica (.html)",
"Save the workbook as a web page." => "Sačuvaj radnu svesku kao veb stranicu.",
"Options|General" => "Opcije|Opšte",
"Options|Save" => "Opcije|Sačuvaj",
"Show formula bar" => "Prikaži traku formule",
"Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene",
"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.",
_ => return None,
})
}

View File

@ -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);

View File

@ -179,12 +179,14 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic
}
Shortcut::Dialog(name, params) => app.open_dialog(name, params),
Shortcut::Date | Shortcut::Time => {
let now = gridcraft_engine::calc::now_serial();
let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") };
let text = app
.session
.active()
.map(|d| gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text)
.map(|d| {
let now = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial());
let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") };
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text
})
.unwrap_or_default();
edit_with(app, text);
}
@ -212,6 +214,7 @@ 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()),
@ -243,4 +246,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;
}

View File

@ -200,7 +200,7 @@ mod tests {
#[test]
fn item_letter_is_one_level_deep() {
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
assert_eq!(item_letter("HV", "edit.pasteSpecial"), Some("HVP"));
assert_eq!(item_letter("HV", "edit.paste"), Some("HVP"));
assert_eq!(item_letter("H", "dialog:pasteSpecial"), None, "the item badge waits for its group letter");
assert_eq!(item_letter("HO", "home.autofitColumnWidth"), Some("HOI"));
}

View File

@ -147,7 +147,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("HUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Min; AutoSum: Min (variant TBD)
("HUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Max; AutoSum: Max (variant TBD)
("HV", Entry::Continue), // Excel: Paste; group level:
("HVP", Entry::Finish(Action::Command("edit.pasteSpecial"))), // Excel: Paste
("HVP", Entry::Finish(Action::Command("edit.paste"))), // Excel: Paste
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut

View File

@ -958,7 +958,7 @@
{
"seq": "HVP",
"kind": "command",
"target": "edit.pasteSpecial",
"target": "edit.paste",
"excel": "Paste",
"note": ""
},

View File

@ -26,6 +26,7 @@ pub mod outline;
pub mod panes;
pub mod paper_view;
pub mod pivot_pane;
pub mod printing;
pub mod ribbon;
pub mod selection_glide;
pub mod tabs;
@ -159,6 +160,10 @@ pub struct Notice {
/// Host services the platform shell provides (file pickers, opening URLs).
#[derive(Default)]
pub struct Services {
/// Desktop: discover configured printers on a worker.
pub printers: Option<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Native picture picker (PNG/JPEG), separate from workbook opening.
@ -174,6 +179,10 @@ pub struct Services {
/// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from
/// the host pastes the plain text alone.
pub paste_html: Option<Box<dyn FnMut() -> Option<String>>>,
/// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V
/// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes
/// GridCraft's own clipboard.
pub paste_text: Option<Box<dyn FnMut() -> Option<String>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
@ -210,6 +219,8 @@ pub struct SheetApp {
/// Dismissible info bar under the ribbon (open-time notices).
pub notice: Option<Notice>,
pub services: Services,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
/// Synthetic input from agents, one batch per frame (drags need several frames).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
@ -239,6 +250,14 @@ pub struct SheetApp {
pub(crate) shots: control::Shots,
/// Chart selected on the sheet (id).
pub selected_chart: Option<u32>,
/// The system clipboard's text when GridCraft last copied without publishing to it (a
/// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this
/// text, GridCraft's copy is the newer one and Paste uses it.
clipboard_text_at_copy: Option<String>,
/// The system clipboard's text right after GridCraft's last cut (the published text, or the
/// text noted for an unpublished cut); `None` after a copy, or once the clipboard is seen
/// holding other text. Once the cut has been pasted, Paste leaves this text alone.
cut_text: Option<String>,
pub started: f64,
}
@ -256,6 +275,8 @@ impl SheetApp {
toast: None,
notice: None,
services,
printing: Default::default(),
print_feedback_seen: None,
synthetic: Default::default(),
control_rx: None,
grid: grid::GridState {
@ -278,6 +299,8 @@ impl SheetApp {
keytips: keytips::KeyTips::default(),
shots: control::Shots::default(),
selected_chart: None,
clipboard_text_at_copy: None,
cut_text: None,
started: now_ms(),
}
}
@ -313,6 +336,17 @@ impl SheetApp {
/// Runs an engine command (or a UI command) and handles UI requests it makes.
pub fn run(&mut self, id: &str, params: Json) -> Result<Json, String> {
let r = self.run_command(id, params);
// This copy leaves the system clipboard alone: note what it holds, so Paste can tell
// that GridCraft's copy is newer while that text is still there.
if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() {
self.clipboard_text_at_copy = self.system_clipboard_text();
self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None };
}
r
}
fn run_command(&mut self, id: &str, params: Json) -> Result<Json, String> {
if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") {
self.store_view_state();
}
@ -344,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
{
@ -356,11 +397,14 @@ impl SheetApp {
/// their existing internal clipboard behavior and do not write to the host clipboard.
pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) {
// Only ask the engine for HTML when the host can publish it.
let result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) {
let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) {
Ok(result) => result,
Err(e) => return self.alert(e),
};
let Some(text) = result.get("text").and_then(Json::as_str) else { return };
// The system clipboard gets this copy, so its text decides what Paste pastes.
self.clipboard_text_at_copy = None;
self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n"));
if let Some(html) = result.get("html").and_then(Json::as_str)
&& let Some(copy_html) = &mut self.services.copy_html
{
@ -372,9 +416,21 @@ impl SheetApp {
ctx.copy_text(text.to_string());
}
/// Paste text that came from the system clipboard, with the clipboard's HTML flavour when the
/// host can read it.
/// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste),
/// with the clipboard's HTML flavour when the host can read it.
///
/// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text
/// that came with it is not pasted again over the moved block; GridCraft's own (empty)
/// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the
/// clipboard has moved on, so the cut's text is forgotten and pastes normally later.
pub fn paste_external(&mut self, text: String) {
if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) {
if self.session.clipboard.is_none() {
return self.run_or_alert("edit.paste", json!({}));
}
} else {
self.cut_text = None;
}
let html = self.services.paste_html.as_mut().and_then(|read| read());
let params = match html {
Some(html) => json!({"text": text, "html": html}),
@ -383,6 +439,34 @@ impl SheetApp {
self.run_or_alert("edit.paste", params);
}
/// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no
/// text there, no host clipboard (web), or the text that was there when GridCraft last copied
/// without publishing, GridCraft's own clipboard is pasted.
pub fn paste_from_clipboard(&mut self) {
match self.system_clipboard_text() {
Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text),
_ => self.run_or_alert("edit.paste", json!({})),
}
}
/// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text.
/// `None` when it holds no text or the host can't read it.
fn system_clipboard_text(&mut self) -> Option<String> {
self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n"))
}
/// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the
/// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's).
fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool {
fn chars(t: &str) -> impl Iterator<Item = char> + '_ {
t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r')
}
match (a, b) {
(Some(a), Some(b)) => chars(a).eq(chars(b)),
_ => false,
}
}
/// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download.
fn deliver_pdf(&self, result: &Json) {
if result.get("path").is_some() {
@ -404,15 +488,47 @@ impl SheetApp {
}
pub fn run_or_alert(&mut self, id: &str, params: Json) {
// Printing from the UI also hands the PDF to the host's print flow (web).
// A menu/shortcut opens the settings first; the Print page's button submits explicitly.
if id == "file.print" {
if self.services.print_native.is_some() {
self.open_backstage(backstage::Page::Print);
return;
}
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") {
@ -422,29 +538,39 @@ impl SheetApp {
}
}
/// Runs `file.print`, builds the PDF and (where the host provides the hook — the web
/// build) hands the bytes straight to the browser's print flow; errors show the usual
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert`
/// (which still produces the PDF — just without a UI to act on the result yet).
/// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the
/// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible
/// on the Print page. The engine command still returns PDF bytes without submitting.
pub fn print_now(&mut self, params: Json) {
match self.run("file.print", params) {
Ok(r) => {
let Some(b64) = r.get("base64").and_then(Json::as_str) else { return };
let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else {
self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into()));
return;
};
if let Some(print) = &self.services.print {
print(&bytes);
}
}
Err(e) => self.alert(e),
if let Err(error) = self.submit_print(params) {
self.alert(error);
}
}
fn submit_print(&mut self, params: Json) -> Result<Json, String> {
if self.services.print_native.is_some() && !self.printing.ready() {
return Err("Select an available printer in File > Print before submitting.".into());
}
let pdf = self.run("file.print", params)?;
let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?;
let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?;
let len = bytes.len();
if let Some(submit) = &self.services.print_native {
self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?;
self.print_feedback_seen = None;
} else if let Some(print) = &self.services.print {
print(&bytes);
} else {
return Err("Printing is unavailable here; export a PDF instead.".into());
}
Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()}))
}
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
let r = match id {
"ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()),
"ui.printSubmit" => self.submit_print(p.clone()),
"ui.ribbonTab" => {
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
Ok(Json::Null)
@ -834,6 +960,26 @@ impl SheetApp {
self.grid.ensure_visible = true;
}
/// Terminal feedback also reaches the normal window when printing starts through control.
pub fn poll_printing(&mut self, ctx: &egui::Context) {
self.printing.poll(ctx);
if self.printing.message == self.print_feedback_seen {
return;
}
self.print_feedback_seen = self.printing.message.clone();
let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) =
self.printing.message.clone()
else {
return;
};
let text = printing::feedback(&message, |label| self.ui.language.tr(label).to_owned());
if matches!(message, printing::Feedback::Submitted(_)) {
self.toast = Some((text, now_ms()));
} else {
self.alert(text);
}
}
/// Per-frame logic (control channel, screenshots). Call before `ui`.
pub fn logic(&mut self, ctx: &egui::Context) {
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
@ -939,6 +1085,7 @@ impl SheetApp {
/// Lays out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
self.poll_printing(&ctx);
if !self.fonts_ready || self.fonts_rebind {
ctx.request_repaint();
return;
@ -1035,6 +1182,8 @@ impl SheetApp {
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
self.ui.ribbon_collapsed = false;
}
keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id),
keytips::Action::Command("edit.paste") => self.paste_from_clipboard(),
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
// Reserved sequence: fires silently while its command is missing. When the

View File

@ -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();

View File

@ -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));
});

View File

@ -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<String> {
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")] {

View File

@ -0,0 +1,98 @@
//! Localized print-page wording around the shared, backend-independent controls.
use craft_ui::printing::Labels;
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit};
pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) {
let labels = Labels {
printer: &translate("Printer"),
select_printer: &translate("Select a printer"),
refresh: &translate("Refresh printers"),
copies: &translate("Copies"),
duplex: &translate("Two-sided printing"),
printer_default: &translate("Printer default"),
one_sided: &translate("One-sided"),
long_edge: &translate("Two-sided: long edge"),
short_edge: &translate("Two-sided: short edge"),
grayscale: &translate("Grayscale"),
};
state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels);
if let Some(message) = &state.message {
ui.label(feedback(message, translate));
}
}
/// Read print settings/status, or validate an explicit update before changing anything.
/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog.
pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result<serde_json::Value, String> {
use serde_json::{Value, json};
let object = params.as_object().ok_or("print settings must be an object")?;
if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) {
return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into());
}
if state.busy() && !object.is_empty() {
return Err("wait for the active print submission before changing settings".into());
}
let mut next = state.settings.clone();
if let Some(value) = object.get("printer") {
let printer = value.as_str().ok_or("printer must be a name from the discovered list")?;
if !state.printers.iter().any(|available| available.name == printer) {
return Err("printer must be a name from the discovered list".into());
}
next.printer = printer.to_owned();
}
if let Some(value) = object.get("copies") {
let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?;
next.copies = copies as u32;
}
if let Some(value) = object.get("duplex") {
next.duplex = match value.as_str() {
Some("printerDefault") => None,
Some("off") => Some(Duplex::Off),
Some("longEdge") => Some(Duplex::LongEdge),
Some("shortEdge") => Some(Duplex::ShortEdge),
None if value.is_null() => None,
_ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()),
};
}
if let Some(value) = object.get("grayscale") {
next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?;
}
let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false);
if refresh {
let discover = discover.ok_or("native printer discovery is unavailable here")?;
if !state.refresh(discover) {
return Err("printer discovery or submission is already active".into());
}
}
state.settings = next;
let (status, detail) = match state.message.as_ref() {
None => ("idle", ""),
Some(Feedback::FindingPrinters) => ("findingPrinters", ""),
Some(Feedback::NoPrinters) => ("noPrinters", ""),
Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""),
Some(Feedback::Submitting) => ("submitting", ""),
Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()),
Some(Feedback::Failed(error)) => ("failed", error.as_str()),
Some(Feedback::WorkerStopped) => ("workerStopped", ""),
};
Ok(json!({
"printer": state.settings.printer, "copies": state.settings.copies,
"duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" },
"grayscale": state.settings.grayscale,
"printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::<Vec<Value>>(),
"busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail,
}))
}
pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String {
match message {
Feedback::FindingPrinters => translate("Finding printers…"),
Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."),
Feedback::DiscoveryFailed => translate("Printer discovery failed."),
Feedback::Submitting => translate("Submitting print job…"),
Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."),
Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt),
Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error),
Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."),
}
}

View File

@ -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")]
{
@ -343,6 +346,12 @@ fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[
}
}
/// The `on` value for a ribbon checkbox command. A click has already flipped the checkbox bool, so
/// it is sent as is; a key tip leaves the bool untouched, so the command gets the opposite.
fn checkbox_on(clicked: bool, shown: bool) -> bool {
shown == clicked
}
/// Consume a one-shot keytip request for control `id` (set by `Action::Control`).
fn invoke(app: &mut SheetApp, id: &str) -> bool {
if app.ui.keytip_invoke == Some(id) {
@ -367,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),
@ -378,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,
@ -483,6 +492,8 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json:
if response.clicked() {
if let Some(d) = id.strip_prefix("dialog:") {
app.open_dialog(d, p.clone());
} else if *id == "edit.paste" {
app.paste_from_clipboard();
} else {
act(app, id, p.clone());
}
@ -495,17 +506,14 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
let st = active_style(app);
// Clipboard
let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true);
paste.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Paste"));
for sequence in
keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"])
{
keytip(app, ui, sequence, paste.rect.center_bottom());
}
if paste.clicked() {
if let Some(text) = app.grid.system_clipboard.take() {
app.paste_external(text);
} else {
act(app, "edit.paste", json!({}));
}
app.paste_from_clipboard();
}
let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E"));
// Keep a keyboard-opened menu anchored to the button when a pointer click
@ -1426,7 +1434,13 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false);
control_keytip(app, ui, &__k, "insert.recommendedCharts");
if __k.clicked() {
act(app, "insert.recommendedCharts", json!({}));
match app.run("insert.recommendedCharts", json!({})) {
// The new chart is selected, as after inserting any other object (see insert.chart
// in dialogs.rs) — otherwise the prior cell/range selection is left in place, and
// Del right after inserting deletes the source data instead of the chart.
Ok(r) => app.selected_chart = r.get("chart").and_then(serde_json::Value::as_u64).and_then(|id| u32::try_from(id).ok()),
Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))),
}
}
ui.vertical(|ui| {
ui.horizontal(|ui| {
@ -1698,13 +1712,13 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
let resp = ui.checkbox(&mut v, screen);
control_keytip(app, ui, &resp, "view.gridlines");
if resp.changed() || invoke(app, "view.gridlines") {
act(app, "view.gridlines", json!({"on": !v}));
act(app, "view.gridlines", json!({"on": checkbox_on(resp.changed(), v)}));
}
let mut p = sh.2;
let resp = ui.checkbox(&mut p, print);
control_keytip(app, ui, &resp, "pageLayout.printGridlines");
if resp.changed() || invoke(app, "pageLayout.printGridlines") {
act(app, "pageLayout.printGridlines", json!({"on": !p}));
act(app, "pageLayout.printGridlines", json!({"on": checkbox_on(resp.changed(), p)}));
}
});
ui.vertical(|ui| {
@ -1713,13 +1727,13 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
let resp = ui.checkbox(&mut v, screen);
control_keytip(app, ui, &resp, "view.headings");
if resp.changed() || invoke(app, "view.headings") {
act(app, "view.headings", json!({"on": !v}));
act(app, "view.headings", json!({"on": checkbox_on(resp.changed(), v)}));
}
let mut p = sh.3;
let resp = ui.checkbox(&mut p, print);
control_keytip(app, ui, &resp, "pageLayout.printHeadings");
if resp.changed() || invoke(app, "pageLayout.printHeadings") {
act(app, "pageLayout.printHeadings", json!({"on": !p}));
act(app, "pageLayout.printHeadings", json!({"on": checkbox_on(resp.changed(), p)}));
}
});
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
@ -2022,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() {
@ -2118,7 +2129,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar"));
control_keytip(app, ui, &resp, "view.formulaBar");
if resp.changed() || invoke(app, "view.formulaBar") {
act(app, "view.formulaBar", json!({"on": !fb}));
act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)}));
}
let mut g = s.0;
let resp = ui.checkbox(&mut g, lang.tr("Gridlines"));
@ -2447,11 +2458,13 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
}
Key::Semicolon => {
// Insert today's date.
let today = gridcraft_engine::calc::now_serial().floor();
let text = app
.session
.active()
.map(|d| gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text)
.map(|d| {
let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor();
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text
})
.unwrap_or_default();
app.begin_edit(Some(text), false);
None
@ -2482,3 +2495,18 @@ mod title_bar_tests {
assert_eq!(MAC_CONTENT_LEFT - lights_end, margin);
}
}
#[cfg(test)]
mod checkbox_on_tests {
use super::*;
#[test]
fn click_sends_the_new_checkbox_value_and_key_tip_the_opposite() {
// Click: the checkbox bool already holds the new choice.
assert!(!checkbox_on(true, false));
assert!(checkbox_on(true, true));
// Key tip: the bool still holds the current setting, so toggle it.
assert!(checkbox_on(false, false));
assert!(!checkbox_on(false, true));
}
}

View File

@ -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<Sheet>]) -> 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::<usize>()
{
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)> =
@ -106,15 +137,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 +355,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 +365,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())]);
}
}

View File

@ -3,9 +3,12 @@
use std::cell::RefCell;
use std::rc::Rc;
use egui::{Event, OutputCommand};
use egui::accesskit::Role;
use egui::{Event, Modifiers, OutputCommand, PointerButton};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::{Services, SheetApp};
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::{Services, SheetApp, grid};
use serde_json::json;
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
@ -116,3 +119,345 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() {
assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections."));
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays");
}
#[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
// lock), so the frame never returned. Run the clicks on a worker and fail on a timeout instead of
// hanging the test run if that comes back.
let (done, finished) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
let cell = value_at;
let mut h = harness(Services::default());
h.state_mut().run("edit.copy", json!({"range": "A1:B2"})).unwrap();
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
// No host clipboard here (as on the web): GridCraft's own copy is pasted.
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(3);
assert_eq!(cell(&h, "D4"), Some(Value::from("Name")));
assert_eq!(cell(&h, "E5"), Some(Value::number(42.0)));
// The secondary click opens the paste options menu; its items paste too.
h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click_secondary();
h.run_steps(3);
h.get_by_label("Paste Values").click();
h.run_steps(3);
assert_eq!(cell(&h, "G1"), Some(Value::from("Name")));
assert_eq!(cell(&h, "H2"), Some(Value::number(42.0)));
let _ = done.send(());
});
match finished.recv_timeout(std::time::Duration::from_secs(60)) {
Ok(()) => assert!(worker.join().is_ok()),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
if let Err(panic) = worker.join() {
std::panic::resume_unwind(panic);
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("clicking Paste froze the UI thread"),
}
}
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn value_at(h: &Harness, a1: &str) -> Option<Value> {
let at = gridcraft_engine::core::CellRef::parse(a1).unwrap();
h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(at)))
}
/// A host clipboard holding `text`, and how many times the app read it.
fn system_clipboard(text: &str) -> (Rc<RefCell<(String, usize)>>, Services) {
let clipboard = Rc::new(RefCell::new((text.to_string(), 0)));
let read = clipboard.clone();
let services = Services {
paste_text: Some(Box::new(move || {
let mut clipboard = read.borrow_mut();
clipboard.1 += 1;
Some(clipboard.0.clone())
})),
..Default::default()
};
(clipboard, services)
}
#[test]
fn ribbon_paste_pastes_the_system_clipboard_like_cmd_v() {
// Text copied in another app: the Paste button pastes it, as Cmd/Ctrl+V does.
let (clipboard, services) = system_clipboard("x\t1\r\ny\t2\r\n");
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(2);
assert_eq!(clipboard.borrow().1, 1, "one clipboard read per click");
assert_eq!(value_at(&h, "D4"), Some(Value::from("x")));
assert_eq!(value_at(&h, "E4"), Some(Value::number(1.0)));
assert_eq!(value_at(&h, "D5"), Some(Value::from("y")));
assert_eq!(value_at(&h, "E5"), Some(Value::number(2.0)));
assert!(h.state().message.is_none(), "{:?}", h.state().message);
// The clipboard text is GridCraft's own copy: the copy is pasted with its formulas.
h.state_mut().run("cell.set", json!({"cell": "A3", "input": "=B2*2"})).unwrap();
let copied = h.state_mut().run("edit.copy", json!({"range": "A3"})).unwrap();
clipboard.borrow_mut().0 = copied["text"].as_str().unwrap().to_string();
h.state_mut().run("selection.set", json!({"cell": "B9"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(2);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.cell(CellRef::parse("B9").unwrap()).unwrap().formula.as_ref().unwrap().text, "C8*2");
// Nothing on the system clipboard (an image, say): GridCraft's own copy is pasted.
clipboard.borrow_mut().0.clear();
h.state_mut().run("selection.set", json!({"cell": "B12"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(2);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.cell(CellRef::parse("B12").unwrap()).unwrap().formula.as_ref().unwrap().text, "C11*2");
}
#[test]
fn paste_menu_items_paste_the_system_clipboard() {
let (clipboard, services) = system_clipboard("from menu");
let mut h = harness(services);
let ribbon = h.get_by_role_and_label(Role::Button, "Paste").rect();
let click_menu_paste = |h: &Harness| h.get_all_by_role_and_label(Role::Button, "Paste").find(|n| n.rect() != ribbon).unwrap().click();
// Home › Paste options menu › Paste.
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click_secondary();
h.run_steps(2);
click_menu_paste(&h);
h.run_steps(2);
assert_eq!(value_at(&h, "D4"), Some(Value::from("from menu")));
// The cell's context menu › Paste.
clipboard.borrow_mut().0 = "from cell menu".into();
h.state_mut().run("selection.set", json!({"cell": "F6"})).unwrap();
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 = geo.cell_rect(sh, CellRef::parse("F6").unwrap()).center();
h.input_mut().events.push(Event::PointerMoved(pos));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
assert!(h.state().grid.context_menu.is_some());
click_menu_paste(&h);
h.run_steps(2);
assert_eq!(value_at(&h, "F6"), Some(Value::from("from cell menu")));
assert_eq!(clipboard.borrow().1, 2);
}
fn press(h: &mut Harness, key: egui::Key) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
if pressed && key != egui::Key::F10 {
h.input_mut().events.push(Event::Text(key.name().to_ascii_lowercase()));
}
}
h.run_steps(2);
}
#[test]
fn keytip_copy_and_cut_reach_the_system_clipboard_and_ribbon_paste_pastes_them() {
// Home › Copy / Cut by keytip (Alt or F10, H, C / X) is the ribbon button: the host clipboard
// gets the copy, and Paste pastes it rather than older text from another app.
for (letter, cut) in [(egui::Key::C, false), (egui::Key::X, true)] {
let (clipboard, mut services) = system_clipboard("stale text from earlier");
let host = clipboard.clone();
services.copy_html = Some(Box::new(move |_, text| {
host.borrow_mut().0 = text.to_string();
Ok(())
}));
let mut h = harness(services);
for key in [egui::Key::F10, egui::Key::H, letter] {
press(&mut h, key);
}
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.cut), Some(cut), "{letter:?}");
assert_eq!(clipboard.borrow().0, "Name\tScore\nAda\t42\n", "{letter:?} publishes to the system clipboard");
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(2);
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "{letter:?}");
assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)), "{letter:?}");
}
}
#[test]
fn ribbon_paste_prefers_a_programmatic_copy_until_other_text_is_copied() {
// A control-channel or MCP copy leaves the system clipboard alone. Its older text must not
// win over the copy; text copied in another app afterwards must.
let (clipboard, services) = system_clipboard("stale text from earlier");
let mut h = harness(services);
h.state_mut().run("edit.copy", json!({"range": "A1:B2"})).unwrap();
assert_eq!(clipboard.borrow().0, "stale text from earlier", "the system clipboard is untouched");
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(2);
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)));
clipboard.borrow_mut().0 = "copied elsewhere\t7".into();
h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap();
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(2);
assert_eq!(value_at(&h, "G1"), Some(Value::from("copied elsewhere")));
assert_eq!(value_at(&h, "H1"), Some(Value::number(7.0)));
}
fn has_formula(h: &Harness, a1: &str) -> bool {
let at = CellRef::parse(a1).unwrap();
h.state().session.active().and_then(|d| d.wb.active().and_then(|s| s.cell(at)).map(|c| c.formula.is_some())).unwrap_or(false)
}
/// Close the "Nothing to paste." alert so the ribbon takes clicks again.
fn dismiss(h: &mut Harness) {
h.state_mut().message = None;
h.state_mut().toast = None;
h.run_steps(2);
}
fn click_paste(h: &mut Harness) {
h.get_by_role_and_label(Role::Button, "Paste").click();
h.run_steps(2);
}
#[test]
fn a_pasted_ribbon_cut_is_not_pasted_again_from_the_system_clipboard() {
// Excel pastes a cut once. After the move, the cut's text is still on the system clipboard:
// a second Paste must not write it over the moved block (formulas would become values).
let (clipboard, mut services) = system_clipboard("stale text from earlier");
let host = clipboard.clone();
services.copy_html = Some(Box::new(move |_, text| {
host.borrow_mut().0 = text.to_string();
Ok(())
}));
let mut h = harness(services);
h.state_mut().run("cell.set", json!({"cell": "B3", "input": "=B2*2"})).unwrap();
h.state_mut().run("selection.set", json!({"range": "A1:B3"})).unwrap();
h.run_steps(2);
for key in [egui::Key::F10, egui::Key::H, egui::Key::X] {
press(&mut h, key);
}
assert_eq!(clipboard.borrow().0, "Name\tScore\nAda\t42\n\t84\n", "the cut is published");
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
click_paste(&mut h);
assert_eq!(value_at(&h, "E6"), Some(Value::number(84.0)));
assert!(has_formula(&h, "E6"), "the cut moves the formula");
click_paste(&mut h);
assert!(has_formula(&h, "E6"), "a second Paste leaves the moved formula alone");
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
assert!(h.state().toast.is_some() || h.state().message.is_some(), "Paste reports there is nothing to paste");
// Text copied in another app afterwards still pastes.
dismiss(&mut h);
clipboard.borrow_mut().0 = "copied elsewhere\t7".into();
h.state_mut().run("selection.set", json!({"cell": "H1"})).unwrap();
click_paste(&mut h);
assert_eq!(value_at(&h, "H1"), Some(Value::from("copied elsewhere")));
assert_eq!(value_at(&h, "I1"), Some(Value::number(7.0)));
}
#[test]
fn a_pasted_programmatic_cut_does_not_paste_the_older_system_clipboard_text() {
// A control-channel or MCP cut leaves the system clipboard alone; once the cut has been
// pasted, the text that was there before it must not land on the moved data.
let (clipboard, services) = system_clipboard("stale");
let mut h = harness(services);
h.state_mut().run("edit.cut", json!({"range": "A1:B2"})).unwrap();
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
click_paste(&mut h);
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
click_paste(&mut h);
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "the moved data stays");
assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)));
dismiss(&mut h);
clipboard.borrow_mut().0 = "copied elsewhere".into();
click_paste(&mut h);
assert_eq!(value_at(&h, "D4"), Some(Value::from("copied elsewhere")), "newer external text pastes");
}
#[test]
fn the_paste_keytip_pastes_the_system_clipboard_like_the_paste_button() {
// Alt/F10, H, V, P is Home › Paste: it pastes text copied in another app.
let (_clipboard, services) = system_clipboard("external\t7");
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
for key in [egui::Key::F10, egui::Key::H, egui::Key::V, egui::Key::P] {
press(&mut h, key);
}
assert_eq!(value_at(&h, "D4"), Some(Value::from("external")));
assert_eq!(value_at(&h, "E4"), Some(Value::number(7.0)));
assert!(h.state().toast.is_none() && h.state().message.is_none());
}
#[test]
fn cmd_v_after_a_pasted_cut_does_not_paste_the_cut_text_again() {
// Cmd/Ctrl+V hands the app the system clipboard's text, which still holds the cut after the
// move. Excel pastes a cut once: the second Cmd/Ctrl+V must leave the moved formulas alone.
let mut h = harness(Services::default());
h.state_mut().run("cell.set", json!({"cell": "B3", "input": "=B2*2"})).unwrap();
h.state_mut().run("selection.set", json!({"range": "A1:B3"})).unwrap();
h.input_mut().events.push(Event::Cut);
h.step();
let text = h
.output()
.platform_output
.commands
.iter()
.find_map(|c| match c {
OutputCommand::CopyText(text) => Some(text.clone()),
_ => None,
})
.expect("the cut is published");
h.run_steps(2);
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
h.input_mut().events.push(Event::Paste(text.clone()));
h.run_steps(2);
assert!(has_formula(&h, "E6"), "the cut moves the formula");
// The host may hand the text back with CRLF line endings.
h.input_mut().events.push(Event::Paste(text.replace('\n', "\r\n")));
h.run_steps(2);
assert!(has_formula(&h, "E6"), "a second Cmd/Ctrl+V leaves the moved formula alone");
assert_eq!(value_at(&h, "E6"), Some(Value::number(84.0)));
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
assert!(h.state().toast.is_some() || h.state().message.is_some(), "Paste reports there is nothing to paste");
}
#[test]
fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() {
// Once other text has been pasted, the clipboard has moved on from the cut: the same text
// copied again later (from another app, or a cell editor) is a new copy and pastes.
let mut h = harness(Services::default());
h.state_mut().run("selection.set", json!({"cell": "A1"})).unwrap();
h.input_mut().events.push(Event::Cut);
h.step();
let text = h
.output()
.platform_output
.commands
.iter()
.find_map(|c| match c {
OutputCommand::CopyText(text) => Some(text.clone()),
_ => None,
})
.expect("the cut is published");
h.run_steps(2);
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
h.input_mut().events.push(Event::Paste(text.clone()));
h.run_steps(2);
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "the cut moves A1");
h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap();
h.input_mut().events.push(Event::Paste("other".into()));
h.run_steps(2);
assert_eq!(value_at(&h, "G1"), Some(Value::from("other")));
h.state_mut().run("selection.set", json!({"cell": "H1"})).unwrap();
h.input_mut().events.push(Event::Paste(text.replace('\n', "\r\n")));
h.run_steps(2);
assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes");
}

View File

@ -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);
}

View File

@ -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<Mutex<Vec<String>>>, egui_kittest::Harness<'static, SheetApp>) {
let asked = Arc::new(Mutex::new(Vec::<String>::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());
}

View File

@ -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);
}

View File

@ -0,0 +1,61 @@
//! The AutoFilter value list scrolls with the pointer anywhere over it, not only over the values
//! themselves (issue #432): the list takes the menu's full width, so its scroll bar and wheel area do too.
use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, TouchPhase, pos2, vec2};
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>;
/// A1:A81 holds a header and 80 distinct values, with the AutoFilter's menu open on column A.
fn filter_menu_on_a_long_list() -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut values = vec![vec![json!("Code")]];
values.extend((1..=80).map(|i| vec![json!(format!("ITEM-{i:03}"))]));
session.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap();
session.execute("data.filter", json!({"range": "A1:A81"})).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);
let button = {
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);
grid::filter_button_rect(&geo, sh, CellRef::parse("A1").unwrap()).center()
};
h.input_mut().events.push(Event::PointerMoved(button));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: button, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(4);
assert!(h.state().grid.filter_menu.is_some(), "the filter menu opens");
h
}
#[test]
fn filter_list_scrolls_with_the_pointer_right_of_the_values() {
let mut h = filter_menu_on_a_long_list();
let first = h.get_by_label("ITEM-001").rect();
// The menu is 230 points wide; the value labels end well before this point.
let pointer = pos2(first.left() + 200.0, first.center().y);
h.input_mut().events.push(Event::PointerMoved(pointer));
h.run_steps(2);
h.input_mut().events.push(Event::MouseWheel {
unit: MouseWheelUnit::Line,
delta: vec2(0.0, -3.0),
phase: TouchPhase::Move,
modifiers: Modifiers::NONE,
});
h.run_steps(20);
let now = h.query_by_label("ITEM-001").map(|n| n.rect().top());
assert!(now.is_none_or(|y| y < first.top() - 20.0), "the wheel scrolls the list: ITEM-001 was at {}, now {now:?}", first.top());
}

View File

@ -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\")");
}

View File

@ -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"));
}

View File

@ -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();
@ -460,6 +498,33 @@ fn solver_dialog_loads_the_saved_model_and_results_can_restore() {
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
#[test]
fn solver_results_write_the_chosen_reports() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}),
)
.unwrap();
let mut h = harness(s);
// Restore Original Values with the Answer and Limits reports: the cells go back, the reports stay.
h.state_mut().open_dialog("solverResults", r);
for key in ["restore", "answerReport", "limitsReport"] {
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true));
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let names: Vec<String> = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Answer Report 1", "Limits Report 1", "Sheet1"]);
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
#[test]
fn about_renders_every_tab() {
let mut h = harness(blank());

View File

@ -1062,7 +1062,7 @@ fn keytip_hv_() {
fn keytip_hvp_() {
let mut h = harness(blank());
sequence(&mut h, "HVP", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "HVP must invoke edit.pasteSpecial and surface its missing-params error");
assert!(h.state().message.is_some() || h.state().toast.is_some(), "HVP must invoke edit.paste and surface its missing-params error");
}
#[test]

View File

@ -0,0 +1,127 @@
//! Native print onboarding preserves engine PDF generation and submits the selected settings.
use gridcraft_engine::Session;
use gridcraft_ui_egui::printing::{Duplex, Feedback, Printer};
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
use std::{cell::RefCell, rc::Rc};
#[test]
fn native_print_uses_selected_settings_and_reports_spool_errors() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings| {
observed.borrow_mut().push((bytes, settings));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Err("printer is offline".into())).unwrap();
receiver
}));
app.run_or_alert("file.print", json!({}));
assert!(app.backstage.is_some(), "a shortcut opens settings without submitting");
let pdf = app.run("file.print", json!({"sheets":"active"})).unwrap();
assert!(pdf["base64"].as_str().is_some(), "the engine still returns PDF bytes without submitting");
assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool");
app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false });
let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap();
assert_eq!(configured["ready"], true);
assert_eq!(configured["printers"][0]["name"], "Chosen queue");
app.run("ui.printSubmit", json!({"sheets":"active"})).unwrap();
assert!(app.printing.busy());
let jobs = jobs.borrow();
assert_eq!(jobs.len(), 1);
assert!(jobs[0].0.starts_with(b"%PDF"));
assert_eq!(jobs[0].1.printer, "Chosen queue");
assert_eq!(jobs[0].1.copies, 3);
assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge));
assert!(jobs[0].1.grayscale);
app.poll_printing(&egui::Context::default());
assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into())));
assert!(app.message.as_ref().unwrap().1.contains("printer is offline"));
}
#[test]
fn print_settings_are_visible_in_a_narrow_window() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap();
app.services.printers = Some(Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap();
receiver
}));
app.services.print_native = Some(Box::new(|_, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
}));
app.run("ui.backstage", json!({"page":"print"})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.run_steps(5);
assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled());
assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled());
harness.get_by_label("Grayscale").click();
harness.run_steps(2);
assert!(harness.state().printing.settings.grayscale);
assert!(harness.state().printing.ready());
}
#[test]
fn invalid_print_settings_are_rejected_without_partial_changes() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
let before = app.run("ui.printSettings", json!({})).unwrap();
for params in [
json!({"printer":"Missing"}),
json!({"copies":0}),
json!({"copies":1000}),
json!({"copies":1.5}),
json!({"copies":-1}),
json!({"copies":"3"}),
json!({"duplex":"maybe"}),
json!({"grayscale":"yes"}),
json!({"refresh":"yes"}),
json!({"copies":3,"duplex":"invalid"}),
json!([]),
] {
assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}");
assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}");
}
}
#[test]
fn repeated_fast_jobs_each_surface_an_identical_receipt() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
app.services.print_native = Some(Box::new(|_, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("same receipt".into())).unwrap();
receiver
}));
for _ in 0..2 {
app.toast = None;
app.run("ui.printSubmit", json!({})).unwrap();
// Completion may be consumed by the shared widget before the app's logic pass.
app.printing.poll(&egui::Context::default());
app.poll_printing(&egui::Context::default());
assert!(app.toast.as_ref().unwrap().0.contains("same receipt"));
app.toast = None;
app.poll_printing(&egui::Context::default());
assert!(app.toast.is_none());
}
}

View File

@ -0,0 +1,42 @@
//! Page Setup opens on the active sheet's saved margins, so OK without edits keeps them (issue #524).
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::{Value, json};
fn margins(app: &SheetApp) -> [f32; 6] {
app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or_default()
}
fn app_with_custom_margins() -> SheetApp {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.run("pageLayout.margins", json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2})).unwrap();
assert_eq!(margins(&app), [2.0, 2.5, 3.0, 0.25, 0.1, 0.2]);
app
}
#[test]
fn page_setup_ok_without_edits_keeps_custom_margins() {
// Custom Margins / File > Page Setup open it bare; Print Titles opens it on the Sheet tab.
for params in [json!({}), json!({"tab": "Sheet"})] {
let mut app = app_with_custom_margins();
app.open_dialog("pageSetup", params);
let d = app.dialog.take().unwrap();
let shown = json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2});
assert_eq!(Value::Object(d.values.clone()), shown);
app.run("pageLayout.margins", Value::Object(d.values)).unwrap();
assert_eq!(margins(&app), [2.0, 2.5, 3.0, 0.25, 0.1, 0.2]);
}
}
#[test]
fn page_setup_one_edited_field_keeps_the_other_margins() {
let mut app = app_with_custom_margins();
app.open_dialog("pageSetup", json!({}));
let mut d = app.dialog.take().unwrap();
d.values.insert("left".into(), json!(1.5));
app.run("pageLayout.margins", Value::Object(d.values)).unwrap();
assert_eq!(margins(&app), [1.5, 2.5, 3.0, 0.25, 0.1, 0.2]);
}

Some files were not shown because too many files have changed in this diff Show More