commit
588e289901
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "", "", ""]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(¬es, "a,b\r\n").unwrap();
|
||||
s.execute("file.open", json!({"path": notes})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "c"})).unwrap();
|
||||
assert!(s.execute("file.save", json!({})).is_err());
|
||||
assert_eq!(std::fs::read_to_string(¬es).unwrap(), "a,b\r\n");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_formulas_are_relative_and_see_the_new_value() {
|
||||
let mut s = s();
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"}]}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@
|
|||
{
|
||||
"seq": "HVP",
|
||||
"kind": "command",
|
||||
"target": "edit.pasteSpecial",
|
||||
"target": "edit.paste",
|
||||
"excel": "Paste",
|
||||
"note": ""
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")] {
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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\")");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue