mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 15:48:41 +00:00
Merge main into #2152
# Conflicts: # ROADMAP.md # docs/architecture.md # docs/target-app-parity.md # docs/ui-parity.md
This commit is contained in:
commit
67fa9f7b4e
253 files changed
+27555
-2378
No files matched your search
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
Generated
+16
-16
@@ -1496,7 +1496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-print",
|
||||
@@ -1518,7 +1518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1530,7 +1530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1553,7 +1553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
@@ -1563,7 +1563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -1584,7 +1584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -1593,7 +1593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -1601,7 +1601,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -1610,7 +1610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1622,18 +1622,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1655,7 +1655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1672,7 +1672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -122,7 +122,7 @@ cd apps/gridcraft-web && trunk build --release # → dist/web (serve it with
|
||||
cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sample=sales opens a sample)
|
||||
```
|
||||
|
||||
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
|
||||
You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the
|
||||
browser's file picker (dropping files works too) and Save downloads the workbook.
|
||||
|
||||
### Ribbon access keys
|
||||
|
||||
+47
-4
@@ -21,6 +21,7 @@ This page is the summary. The detail is in:
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 275 / 291 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
|
||||
@@ -93,7 +94,9 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po
|
||||
and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German
|
||||
added 10-11), the
|
||||
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
|
||||
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only.
|
||||
(#38). Japanese (10-17) and Brazilian Portuguese also translate the dialog titles and labels,
|
||||
message boxes, Backstage/File-view items, template names and native menus: 943 strings each, ~43%.
|
||||
The remaining dialogs, menus and messages are English; formulas and number formats are en-US only.
|
||||
Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | Status | UI strings |
|
||||
@@ -104,14 +107,14 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari
|
||||
| Hindi | hi | none | 0% |
|
||||
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
|
||||
| French | fr | none | 0% |
|
||||
| Portuguese | pt-BR | menus only | ~12% |
|
||||
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | menus only | ~12% |
|
||||
| Japanese | ja | menus, File view, dialogs, messages | ~43% |
|
||||
| German | de | menus only | ~12% |
|
||||
| Korean | ko | menus only | ~12% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Other shipped languages: 2 (Russian and German, menus only, ~12%).
|
||||
Other shipped languages: 3 (Russian and German, menus only, ~12%; Dutch, menus, File view and dialog titles, ~24%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -132,6 +135,30 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
|
||||
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
|
||||
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
|
||||
| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) |
|
||||
| 2026-10-19 | Linux: "Use system setting" for the theme keeps following the desktop when the XDG Desktop Portal stops answering: each read gives up after 2 s and the next poll reconnects, where one unanswered call stalled the watcher for the rest of the session |
|
||||
| 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB |
|
||||
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
|
||||
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
|
||||
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
|
||||
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
|
||||
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
|
||||
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
|
||||
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
|
||||
| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip |
|
||||
| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148)
|
||||
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
|
||||
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
@@ -194,6 +221,22 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
|
||||
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
|
||||
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
|
||||
| 2026-10-19 | trivial | Progress log: desktop theme portal read timeout |
|
||||
| 2026-10-16 | minor | Progress log: undo steps share picture and macro bytes |
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
|
||||
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
|
||||
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
|
||||
| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) |
|
||||
| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row |
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-21 | minor | Macro engine and `macro.*` commands; catalog 274 / 290 → 275 / 291 (View › Macros › Record Macro); progress log row |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
|
||||
+115
-36
@@ -139,9 +139,10 @@ impl RoundTrip {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FileReport {
|
||||
pub path: String,
|
||||
/// GridCraft couldn't open the file: its message.
|
||||
/// Opening the file or discovering the requested inputs failed: its message.
|
||||
pub error: Option<String>,
|
||||
pub warnings: usize,
|
||||
/// Import warnings, in the order the importer produced them.
|
||||
pub warnings: Vec<String>,
|
||||
pub calc: CalcReport,
|
||||
pub round_trip: RoundTrip,
|
||||
}
|
||||
@@ -204,67 +205,112 @@ pub fn main(args: &[String]) -> ExitCode {
|
||||
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
|
||||
}
|
||||
}
|
||||
let files = collect_inputs(&a.positional);
|
||||
let reports: Vec<FileReport> = files
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let path = p.to_string_lossy().into_owned();
|
||||
match gridcraft_engine::io::read_file(&path) {
|
||||
Ok(bytes) => check_bytes(&path, &bytes, &opts),
|
||||
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
|
||||
reports.extend(files.iter().map(|p| {
|
||||
let path = p.to_string_lossy().into_owned();
|
||||
match gridcraft_engine::io::read_file(&path) {
|
||||
Ok(bytes) => check_bytes(&path, &bytes, &opts),
|
||||
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
|
||||
}
|
||||
}));
|
||||
if a.has("json") {
|
||||
outln!("{}", pretty(&report_json(&reports, &opts)));
|
||||
} else {
|
||||
print_human(&reports, &opts);
|
||||
}
|
||||
if reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
|
||||
if !files.is_empty() && reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Discovery {
|
||||
files: Vec<PathBuf>,
|
||||
failures: Vec<FileReport>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn fail(&mut self, path: &Path, error: String) {
|
||||
self.failures.push(FileReport { path: path.to_string_lossy().into_owned(), error: Some(error), ..Default::default() });
|
||||
}
|
||||
|
||||
/// False means the file budget is exhausted: the remaining scan is incomplete.
|
||||
fn add_file(&mut self, path: PathBuf) -> bool {
|
||||
if self.files.len() >= MAX_FILES {
|
||||
self.fail(&path, format!("file limit of {MAX_FILES} reached; this and remaining inputs were not checked"));
|
||||
return false;
|
||||
}
|
||||
self.files.push(path);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Files named on the command line are checked whatever their extension; directories contribute
|
||||
/// their `.xlsx`/`.xlsm` files, recursively, in name order. Hidden files and Excel lock files
|
||||
/// (`~$…`) are skipped.
|
||||
fn collect_inputs(inputs: &[String]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
for i in inputs {
|
||||
let p = PathBuf::from(i);
|
||||
if p.is_dir() {
|
||||
walk(&p, 0, &mut out);
|
||||
} else {
|
||||
// A missing path is reported as a file that failed to open.
|
||||
out.push(p);
|
||||
}
|
||||
if out.len() >= MAX_FILES {
|
||||
out.truncate(MAX_FILES);
|
||||
if !walk(&p, 0, &mut out) {
|
||||
break;
|
||||
}
|
||||
} else if !out.add_file(p) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if out.files.is_empty()
|
||||
&& out.failures.is_empty()
|
||||
&& let Some(input) = inputs.first()
|
||||
{
|
||||
out.fail(Path::new(input), "no workbook files were found in the requested inputs".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth > MAX_DEPTH || out.len() >= MAX_FILES {
|
||||
return;
|
||||
/// Returns false only when the global file budget stops traversal.
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
||||
return true;
|
||||
}
|
||||
let rd = match std::fs::read_dir(dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) => {
|
||||
out.fail(dir, format!("cannot read directory: {e}"));
|
||||
return true;
|
||||
}
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
for entry in rd {
|
||||
match entry {
|
||||
Ok(entry) => entries.push(entry.path()),
|
||||
Err(e) => out.fail(dir, format!("cannot read directory entry: {e}")),
|
||||
}
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
entries.sort();
|
||||
for p in entries {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if name.starts_with('.') || name.starts_with("~$") {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
walk(&p, depth + 1, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
|
||||
out.push(p);
|
||||
}
|
||||
if out.len() >= MAX_FILES {
|
||||
return;
|
||||
let metadata = match p.metadata() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
out.fail(&p, format!("cannot inspect input: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !walk(&p, depth + 1, out) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
&& !out.add_file(p)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks one file's bytes. Never fails: problems are recorded in the report. A panic escaping
|
||||
@@ -287,7 +333,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
||||
return report;
|
||||
}
|
||||
};
|
||||
report.warnings = warnings.len();
|
||||
report.warnings = warnings;
|
||||
let mut fresh = cached.clone();
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut fresh);
|
||||
@@ -1270,6 +1316,30 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn discovery_reports_a_directory_read_error() {
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||
std::fs::write(&path, "not a directory").unwrap();
|
||||
let mut discovery = Discovery::default();
|
||||
assert!(walk(&path, 0, &mut discovery));
|
||||
assert!(discovery.files.is_empty());
|
||||
assert_eq!(discovery.failures.len(), 1);
|
||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||
assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_reports_omissions_only_after_the_file_limit() {
|
||||
let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
|
||||
assert!(discovery.add_file(PathBuf::from("last.xlsx")));
|
||||
assert!(discovery.failures.is_empty());
|
||||
assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
|
||||
assert_eq!(discovery.files.len(), MAX_FILES);
|
||||
assert_eq!(discovery.failures[0].path, "omitted.xlsx");
|
||||
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
|
||||
}
|
||||
|
||||
fn at(a1: &str) -> CellRef {
|
||||
CellRef::parse(a1).unwrap()
|
||||
}
|
||||
@@ -1600,6 +1670,15 @@ mod tests {
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_lists_warning_messages() {
|
||||
let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
|
||||
let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
|
||||
let v = report_json(&[with, without], &Options::default());
|
||||
assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
|
||||
assert_eq!(v["files"][1]["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unopenable_files_are_reported() {
|
||||
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
|
||||
|
||||
@@ -36,7 +36,7 @@ use serde_json::{Value, json};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
gridcraft-cli info <file> [--json] sheets, used ranges, tables, charts, names
|
||||
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html by extension
|
||||
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html/pdf by extension
|
||||
gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
|
||||
gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
|
||||
gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
|
||||
@@ -271,11 +271,17 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let input = a.pos(0, "<in>")?;
|
||||
let out = a.pos(1, "<out>")?;
|
||||
a.no_extra(2)?;
|
||||
if gridcraft_engine::io::FileKind::from_path(out).is_none() {
|
||||
return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)"));
|
||||
let pdf = out.to_ascii_lowercase().ends_with(".pdf");
|
||||
if !pdf && gridcraft_engine::io::FileKind::from_path(out).is_none() {
|
||||
return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json, .html or .pdf)"));
|
||||
}
|
||||
let mut s = open(input)?;
|
||||
activate_sheet(&mut s, a.opt("sheet"))?;
|
||||
if pdf {
|
||||
let result = exec(&mut s, "file.exportPdf", json!({"path": out}))?;
|
||||
eprintln!("wrote {out} ({} bytes, {} pages)", result["bytes"], result["pages"]);
|
||||
return Ok(());
|
||||
}
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?;
|
||||
gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?;
|
||||
@@ -583,4 +589,21 @@ mod tests {
|
||||
assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err());
|
||||
assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_xlsx_to_pdf_keeps_links() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-cli-pdf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let input = dir.join("links.xlsx");
|
||||
let output = dir.join("links.pdf");
|
||||
let mut s = blank();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org"})).unwrap();
|
||||
let bytes = gridcraft_engine::io::save_file_bytes(&s.doc().unwrap().wb, input.to_str().unwrap()).unwrap();
|
||||
gridcraft_engine::io::write_file(input.to_str().unwrap(), &bytes).unwrap();
|
||||
convert(&[input.to_string_lossy().into_owned(), output.to_string_lossy().into_owned()]).unwrap();
|
||||
let pdf = std::fs::read(&output).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
assert!(pdf.windows(b"/URI (https://example.org)".len()).any(|w| w == b"/URI (https://example.org)"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -174,3 +174,35 @@ fn check_exit_codes_and_json() {
|
||||
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
|
||||
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_an_empty_scan() {
|
||||
let dir = tmpdir("check-empty");
|
||||
std::fs::write(dir.join("notes.txt"), "not a workbook").unwrap();
|
||||
std::fs::write(dir.join(".hidden.xlsx"), "ignored").unwrap();
|
||||
std::fs::write(dir.join("~$locked.xlsx"), "ignored").unwrap();
|
||||
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(report["files"][0]["error"].as_str().unwrap().contains("no workbook files"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
|
||||
let dir = tmpdir("check-depth");
|
||||
let book = dir.join("readable.xlsx");
|
||||
ok(&["run", "--out", book.to_str().unwrap(), "--quiet"]);
|
||||
let mut deep = dir.clone();
|
||||
for _ in 0..34 {
|
||||
deep.push("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
std::fs::write(deep.join("skipped.xlsx"), "not reached").unwrap();
|
||||
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(report["totals"]["opened"], 1);
|
||||
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -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", "MessageEvent", "UrlSearchParams"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
|
||||
@@ -11,11 +11,14 @@
|
||||
//! 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.
|
||||
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
|
||||
//! `localStorage` (`prefs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod prefs;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Preferences between visits (#1562): what the desktop app keeps in `ui.json` (theme, interface
|
||||
//! size, language, ribbon state…) and `prefs.json` (editing options, user name…), kept in the
|
||||
//! browser's `localStorage` under [`UI_KEY`] and [`PREFS_KEY`]. Restored at startup; written back
|
||||
//! whenever they change, since a reload or a closed tab runs no exit code.
|
||||
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
/// The `localStorage` key for the desktop's `ui.json`.
|
||||
pub const UI_KEY: &str = "gridcraft.ui";
|
||||
/// The `localStorage` key for the desktop's `prefs.json`.
|
||||
pub const PREFS_KEY: &str = "gridcraft.prefs";
|
||||
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
|
||||
const MAX_STORED: usize = 1 << 20;
|
||||
|
||||
/// Apply preferences stored on an earlier visit, as the desktop app reads `ui.json` and
|
||||
/// `prefs.json`. Missing, oversized or unreadable values leave what the app starts with.
|
||||
pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
|
||||
let readable = |s: &&str| s.len() <= MAX_STORED;
|
||||
if let Some(s) = ui.filter(readable)
|
||||
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(s)
|
||||
&& let Ok(state) = serde_json::from_value::<gridcraft_ui_egui::UiState>(json.clone())
|
||||
{
|
||||
// No usable saved language: keep the browser's, as the desktop keeps the system's.
|
||||
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
|
||||
app.ui = state;
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Some(s) = prefs.filter(readable)
|
||||
&& let Ok(p) = serde_json::from_str(s)
|
||||
{
|
||||
app.session.prefs = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks what was last written, so the store is written only when a preference changed.
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
ui: Option<String>,
|
||||
prefs: Option<String>,
|
||||
/// The user name to store in place of the session's (see [`Self::keep_user_name`]).
|
||||
user_name: Option<String>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(ui: Option<String>, prefs: Option<String>) -> Self {
|
||||
Self { ui, prefs, user_name: None }
|
||||
}
|
||||
|
||||
/// Stores `name` as the user name whatever the session's becomes: under `?host=parent` the
|
||||
/// framing page names the author for this visit only, and must not replace the user's own.
|
||||
pub fn keep_user_name(&mut self, name: String) {
|
||||
self.user_name = Some(name);
|
||||
}
|
||||
|
||||
/// The `(key, JSON)` pairs to store because they differ from what was last written.
|
||||
pub fn changed(&mut self, app: &SheetApp) -> Vec<(&'static str, String)> {
|
||||
let mut out = Vec::new();
|
||||
let prefs = match &self.user_name {
|
||||
Some(name) => serde_json::to_string(&gridcraft_engine::Prefs { user_name: name.clone(), ..app.session.prefs.clone() }),
|
||||
None => serde_json::to_string(&app.session.prefs),
|
||||
};
|
||||
for (key, last, json) in [(UI_KEY, &mut self.ui, serde_json::to_string(&app.ui)), (PREFS_KEY, &mut self.prefs, prefs)] {
|
||||
match json {
|
||||
Ok(json) if last.as_deref() != Some(json.as_str()) => {
|
||||
*last = Some(json.clone());
|
||||
out.push((key, json));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("preferences: cannot serialize {key}: {e}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::Services;
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> SheetApp {
|
||||
let mut app = SheetApp::new(Session::new(), Services::default());
|
||||
// As `web::start` sets it up before restoring.
|
||||
app.ui.system_theme = true;
|
||||
app.ui.language = Language::De;
|
||||
app
|
||||
}
|
||||
|
||||
fn stored(writes: &[(&'static str, String)], key: &str) -> Option<String> {
|
||||
writes.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
/// #1562: dark mode chosen on one visit is there after a reload, with the editing options.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
let mut first = app();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
assert_eq!(sync.changed(&first).len(), 2, "first frame stores the preferences");
|
||||
assert!(sync.changed(&first).is_empty(), "unchanged preferences are not rewritten");
|
||||
first.run("view.theme", json!({"mode": "dark"})).unwrap();
|
||||
first.session.prefs.enter_direction = "right".into();
|
||||
let writes = sync.changed(&first);
|
||||
let (ui, prefs) = (stored(&writes, UI_KEY), stored(&writes, PREFS_KEY));
|
||||
assert!(ui.is_some() && prefs.is_some(), "both changes are stored: {writes:?}");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, ui.as_deref(), prefs.as_deref());
|
||||
assert!(reloaded.ui.dark && !reloaded.ui.system_theme, "dark mode is back");
|
||||
assert_eq!(reloaded.session.prefs.enter_direction, "right");
|
||||
assert!(PrefsSync::new(ui, prefs).changed(&reloaded).is_empty(), "restoring doesn't write back");
|
||||
}
|
||||
|
||||
/// `?host=parent`: the author the framing page names is not stored over the user's own name.
|
||||
#[test]
|
||||
fn host_author_is_not_stored() {
|
||||
let mut first = app();
|
||||
first.session.prefs.user_name = "Ada".into();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
let stored_prefs = stored(&sync.changed(&first), PREFS_KEY);
|
||||
|
||||
let mut hosted = app();
|
||||
restore(&mut hosted, None, stored_prefs.as_deref());
|
||||
let mut sync = PrefsSync::new(None, stored_prefs);
|
||||
sync.keep_user_name(hosted.session.prefs.user_name.clone());
|
||||
hosted.session.prefs.user_name = "Host Author".into();
|
||||
hosted.session.prefs.r1c1 = true;
|
||||
let writes = sync.changed(&hosted);
|
||||
let prefs = stored(&writes, PREFS_KEY).expect("other preferences are still stored");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, None, Some(&prefs));
|
||||
assert_eq!(reloaded.session.prefs.user_name, "Ada");
|
||||
assert!(reloaded.session.prefs.r1c1);
|
||||
}
|
||||
|
||||
/// A stored `ui` without a language keeps the browser's, as the desktop keeps the system's.
|
||||
#[test]
|
||||
fn missing_language_keeps_the_browsers() {
|
||||
let mut a = app();
|
||||
restore(&mut a, Some(r#"{"dark":true}"#), None);
|
||||
assert!(a.ui.dark);
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
restore(&mut a, Some(r#"{"language":"fr"}"#), None);
|
||||
assert_eq!(a.ui.language, Language::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_storage_keeps_defaults() {
|
||||
let big = format!("{{\"dark\":true,\"recent\":[\"{}\"]}}", "x".repeat(MAX_STORED));
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"dark\":42}"), Some(big.as_str())] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk, junk);
|
||||
assert!(a.ui.system_theme && !a.ui.dark, "{junk:?}");
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
assert_eq!(a.session.prefs.enter_direction, "down");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,17 @@ pub fn start() {
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_options.wgpu_setup {
|
||||
let default_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
|
||||
|
||||
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
let mut descriptor = default_descriptor(adapter);
|
||||
|
||||
descriptor.required_limits = descriptor.required_limits.or_worse_values_from(&adapter.limits());
|
||||
|
||||
descriptor
|
||||
});
|
||||
}
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
@@ -58,14 +69,20 @@ pub fn start() {
|
||||
) {
|
||||
app.ui.language = browser;
|
||||
}
|
||||
// Preferences from an earlier visit (#1562), as the desktop app reads `ui.json`
|
||||
// and `prefs.json`; a saved language replaces the browser's.
|
||||
let (stored_ui, stored_prefs) = (stored(crate::prefs::UI_KEY), stored(crate::prefs::PREFS_KEY));
|
||||
crate::prefs::restore(&mut app, stored_ui.as_deref(), stored_prefs.as_deref());
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored_ui, stored_prefs);
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
|
||||
if let Some(bridge) = &app_bridge {
|
||||
prefs.keep_user_name(app.session.prefs.user_name.clone());
|
||||
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 }))
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -93,6 +110,16 @@ struct WebShell {
|
||||
app: SheetApp,
|
||||
inbox: Inbox,
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
prefs: crate::prefs::PrefsSync,
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
/// A value stored on an earlier visit, if the browser allows storage.
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
local_storage()?.get_item(key).ok()?
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -113,6 +140,20 @@ impl eframe::App for WebShell {
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// Store changed preferences straight away: a reload or closed tab runs no exit code (#1562).
|
||||
let changed = self.prefs.changed(&self.app);
|
||||
if !changed.is_empty() {
|
||||
match local_storage() {
|
||||
Some(store) => {
|
||||
for (key, json) in changed {
|
||||
if let Err(e) = store.set_item(key, &json) {
|
||||
log::warn!("preferences: cannot save {key}: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => log::warn!("preferences: browser storage is unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -224,15 +265,20 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
|
||||
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
|
||||
iframe.style().set_property("display", "none").map_err(js)?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/pdf");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
|
||||
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
|
||||
iframe.style().set_property("display", "none").map_err(js)?;
|
||||
document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?;
|
||||
if let Err(e) = body.append_child(&iframe) {
|
||||
cleanup_print(&iframe, &url);
|
||||
return Err(js(e));
|
||||
}
|
||||
|
||||
// `onload` fires once the PDF has actually rendered inside the iframe;
|
||||
// printing before that would show a blank page. Set the handler BEFORE
|
||||
@@ -240,22 +286,44 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> {
|
||||
let iframe_for_load = iframe.clone();
|
||||
let url_for_cleanup = url.clone();
|
||||
let on_load = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let Some(content_window) = iframe_for_load.content_window() else { return };
|
||||
let _ = content_window.print();
|
||||
// `afterprint` fires once the print dialog (or print preview) closes, whether the user
|
||||
// printed or cancelled — the right moment to release the blob URL and drop the iframe.
|
||||
let iframe_for_cleanup = iframe_for_load.clone();
|
||||
let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
web_sys::Url::revoke_object_url(&url_for_cleanup).ok();
|
||||
iframe_for_cleanup.remove();
|
||||
});
|
||||
let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref());
|
||||
iframe_for_load.set_onload(None);
|
||||
if let Err(e) = print_loaded_pdf(&iframe_for_load, &url_for_cleanup) {
|
||||
cleanup_print(&iframe_for_load, &url_for_cleanup);
|
||||
log::error!("print failed: {e:?}");
|
||||
}
|
||||
});
|
||||
iframe.set_onload(Some(on_load.unchecked_ref()));
|
||||
iframe.set_src(&url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_print(iframe: &web_sys::HtmlIFrameElement, url: &str) {
|
||||
// Both operations are idempotent: print can fail after dispatching `afterprint`.
|
||||
web_sys::Url::revoke_object_url(url).ok();
|
||||
iframe.remove();
|
||||
}
|
||||
|
||||
fn print_loaded_pdf(iframe: &web_sys::HtmlIFrameElement, url: &str) -> Result<(), JsValue> {
|
||||
let content_window = iframe.content_window().ok_or_else(|| JsValue::from_str("no print content window"))?;
|
||||
let iframe_for_cleanup = iframe.clone();
|
||||
let url_for_cleanup = url.to_owned();
|
||||
let on_after_print = wasm_bindgen::closure::Closure::once(move || {
|
||||
cleanup_print(&iframe_for_cleanup, &url_for_cleanup);
|
||||
});
|
||||
// Listen before `print()`, which can dispatch `afterprint` before it returns.
|
||||
// A one-shot listener also releases the callback when the dialog closes or is cancelled.
|
||||
let options = web_sys::AddEventListenerOptions::new();
|
||||
options.set_once(true);
|
||||
let callback = on_after_print.as_ref().unchecked_ref();
|
||||
content_window.add_event_listener_with_callback_and_add_event_listener_options("afterprint", callback, &options)?;
|
||||
if let Err(e) = content_window.print() {
|
||||
let _ = content_window.remove_event_listener_with_callback("afterprint", callback);
|
||||
return Err(e);
|
||||
}
|
||||
let _ = on_after_print.into_js_value();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
@@ -274,7 +342,6 @@ fn mime_for(name: &str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Trigger a browser save dialog or download of `bytes` named after `name`.
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -119,14 +119,23 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
let state = std::sync::Arc::clone(&req.state);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
// Wake the UI thread: egui only runs frames when something happens.
|
||||
ctx.request_repaint();
|
||||
let mut reply = rx
|
||||
.recv_timeout(Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
|
||||
let mut reply = rx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| {
|
||||
// Give up on the request as well as on its reply. While it is still
|
||||
// queued this keeps it from running late -- the client was told it had
|
||||
// failed, and an agent that retried after the error ran it twice (#1914);
|
||||
// if the app had already started it, its outcome is unknown instead.
|
||||
if ControlRequest::abandon(&state) {
|
||||
json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timed out waiting for the UI, which had already started the request, so it may still finish; its outcome is unknown"})
|
||||
}
|
||||
});
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
|
||||
@@ -60,11 +60,36 @@ sync_github_name = true
|
||||
[people.jsymes21-byte]
|
||||
real_name = "James Symes"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Shnrqpdr]
|
||||
sync_github_name = true
|
||||
|
||||
[people.yeniufuklar-com]
|
||||
real_name = "Ufuk Efe"
|
||||
display_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pourie-he]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Stefanfra]
|
||||
real_name = "Stefan Fransen"
|
||||
sync_github_name = true
|
||||
|
||||
[people.allenyllee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Harrison-Booth]
|
||||
real_name = "Harrison Booth"
|
||||
sync_github_name = false
|
||||
|
||||
[people.bilalmughal]
|
||||
sync_github_name = true
|
||||
+160
-52
@@ -13,10 +13,14 @@ use gridcraft_model::Workbook;
|
||||
|
||||
/// Largest array a reference may expand to.
|
||||
pub const MAX_CELLS: u64 = 16_000_000;
|
||||
/// References up to this many cells are read at their full size; larger ones (`A:A`, ranges
|
||||
/// reserved for data to come such as `$A$2:$A$50000`) stop at the sheet's used range, so that
|
||||
/// formulas over them stay fast.
|
||||
/// References up to this many cells (one whole row) are always read at their full size.
|
||||
const FULL_SIZE_CELLS: u64 = 16_384;
|
||||
/// Bounded references up to this many cells (one column's worth) are read at their full size too, as
|
||||
/// Excel does, so their shape lines up with the arrays next to them (`D3:AZ400*B3:B400` is 398 by
|
||||
/// 49 even when the data ends at column F) and their blanks count. References that run to the edge
|
||||
/// of the sheet (`A:A`, `A2:A1048576`, several whole rows) and larger blocks stop at the sheet's used
|
||||
/// range, so that formulas over them stay fast.
|
||||
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||
const MAX_DEPTH: usize = 400;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -90,6 +94,8 @@ struct FnCtx<'a, 'h> {
|
||||
ev: &'a mut Evaluator<'h>,
|
||||
/// The criteria argument of a database function, when it is a reference.
|
||||
criteria: Option<Area>,
|
||||
/// The LAMBDA given as the function argument of GROUPBY/PIVOTBY.
|
||||
lambda: Option<Arc<Lambda>>,
|
||||
}
|
||||
|
||||
impl Ctx for FnCtx<'_, '_> {
|
||||
@@ -124,6 +130,19 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
||||
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
||||
}
|
||||
fn has_lambda(&self) -> bool {
|
||||
self.lambda.is_some()
|
||||
}
|
||||
/// A LAMBDA must give one value per group; an array result is #CALC!.
|
||||
fn call_lambda(&mut self, arg: Value) -> Option<Value> {
|
||||
let l = self.lambda.clone()?;
|
||||
let [p] = l.params.as_slice() else { return Some(Value::Error(CellError::Value)) };
|
||||
let r = self.ev.call_lambda_values(&l, vec![(p.clone(), Ev::V(arg))]);
|
||||
Some(match self.ev.deref(r) {
|
||||
Value::Array(a) if a.data.len() != 1 => Value::Error(CellError::Calc),
|
||||
v => v.scalar(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
@@ -154,8 +173,13 @@ impl<'h> Evaluator<'h> {
|
||||
Ok(self.resolve_sheet(&r.sheet)?.into_iter().map(|sheet| Area { sheet, range }).collect())
|
||||
}
|
||||
|
||||
/// Dereferences: one cell → its value; a block → an array (trimmed to the used range);
|
||||
/// several areas → `#VALUE!`.
|
||||
/// Dereferences: one cell → its value; a block → an array; several areas → `#VALUE!`.
|
||||
///
|
||||
/// A block keeps its full shape (blanks included) when it holds at most `FULL_SIZE_CELLS`
|
||||
/// cells, or when it holds at most `FULL_SIZE_BOUNDED_CELLS` cells (one full column's worth)
|
||||
/// and does not run to the sheet's last row or column. Every other block (larger, or a
|
||||
/// bigger one that runs to the sheet's edge, such as `A:A` or `1:1`) is trimmed to the
|
||||
/// sheet's used range before its values are read. An array over `MAX_CELLS` is `#NUM!`.
|
||||
pub fn deref(&mut self, ev: Ev) -> Value {
|
||||
match ev {
|
||||
Ev::V(v) => v,
|
||||
@@ -174,7 +198,11 @@ impl<'h> Evaluator<'h> {
|
||||
// A reference keeps its size, so the array has its shape and its blanks count
|
||||
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
|
||||
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
|
||||
let range = if cells > FULL_SIZE_CELLS { self.trim(a) } else { a.range };
|
||||
// `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of
|
||||
// the column", not for a block of a chosen size.
|
||||
let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1;
|
||||
let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS);
|
||||
let range = if full_size { a.range } else { self.trim(a) };
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
if (h as u64) * (w as u64) > MAX_CELLS {
|
||||
return Value::Error(CellError::Num);
|
||||
@@ -256,13 +284,13 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn name(&mut self, n: &str) -> Ev {
|
||||
if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)) {
|
||||
if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)) {
|
||||
return v.clone();
|
||||
}
|
||||
let wb = self.host.workbook();
|
||||
// Sheet-qualified `Sheet1!Name`.
|
||||
let (scope_sheet, bare) = match n.split_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(s.trim_matches('\'')) {
|
||||
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
|
||||
Some(i) => (i, b),
|
||||
None => return err(CellError::Name),
|
||||
},
|
||||
@@ -271,7 +299,7 @@ impl<'h> Evaluator<'h> {
|
||||
if let Some(def) = wb.name(bare, scope_sheet) {
|
||||
let text = def.formula.clone();
|
||||
let scope = def.scope.unwrap_or(self.sheet);
|
||||
return match gridcraft_formula::parse(&text) {
|
||||
return match gridcraft_formula::parse_name(&text) {
|
||||
Ok(expr) => {
|
||||
// Relative references in a name are relative to A1: used here, they move by
|
||||
// this cell's offset from it.
|
||||
@@ -618,7 +646,7 @@ impl<'h> Evaluator<'h> {
|
||||
let value = self.value(a);
|
||||
evaluated.push(Arg { value, from_ref: false });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
|
||||
}
|
||||
let i = match index.to_number() {
|
||||
@@ -772,7 +800,7 @@ impl<'h> Evaluator<'h> {
|
||||
Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() }))
|
||||
}
|
||||
"ISOMITTED" => {
|
||||
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
|
||||
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
|
||||
Ev::V(Value::Bool(r))
|
||||
}
|
||||
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
|
||||
@@ -817,8 +845,21 @@ impl<'h> Evaluator<'h> {
|
||||
let mut criteria_shape = None;
|
||||
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
|
||||
let mut criteria = None;
|
||||
// GROUPBY/PIVOTBY: the LAMBDA given as their function.
|
||||
let mut lambda = None;
|
||||
let lambda_at = match name {
|
||||
"GROUPBY" => Some(2),
|
||||
"PIVOTBY" => Some(3),
|
||||
_ => None,
|
||||
};
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
let mut ev = self.eval(a);
|
||||
if lambda_at == Some(i)
|
||||
&& let Ev::L(l) = &ev
|
||||
{
|
||||
lambda = Some(l.clone());
|
||||
ev = Ev::V(Value::Empty);
|
||||
}
|
||||
if spec.category == gridcraft_functions::Category::Database
|
||||
&& i == 2
|
||||
&& let Ev::R(areas) = &ev
|
||||
@@ -853,7 +894,7 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
evaluated.push(Arg { value, from_ref });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self, criteria };
|
||||
let mut ctx = FnCtx { ev: self, criteria, lambda };
|
||||
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
|
||||
}
|
||||
|
||||
@@ -907,13 +948,13 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
let args = [Arg { value, from_ref }];
|
||||
if kind != Agg::Count {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
|
||||
Value::Number(n) => acc.sum += n,
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
}
|
||||
if kind != Agg::Sum {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
|
||||
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
@@ -975,7 +1016,7 @@ impl<'h> Evaluator<'h> {
|
||||
/// 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() {
|
||||
if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() {
|
||||
return None;
|
||||
}
|
||||
let u = n.to_ascii_uppercase();
|
||||
@@ -1012,15 +1053,18 @@ impl<'h> Evaluator<'h> {
|
||||
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);
|
||||
if (rows * cols) as u64 > MAX_CELLS {
|
||||
return err(CellError::Num);
|
||||
let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) };
|
||||
// The LAMBDA takes one parameter per array.
|
||||
if l.params.len() != arrays.len() {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let mut out = Vec::with_capacity(rows * cols);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect();
|
||||
let v = self.call_lambda_values(&l, bound);
|
||||
out.push(self.deref(v).scalar());
|
||||
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
|
||||
out.push(one);
|
||||
}
|
||||
}
|
||||
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
|
||||
@@ -1041,7 +1085,8 @@ impl<'h> Evaluator<'h> {
|
||||
let r = self.call_lambda_values(&l, bound);
|
||||
acc = self.deref(r);
|
||||
if name == "SCAN" {
|
||||
scan.push(acc.scalar());
|
||||
let Some(one) = single(acc.clone()) else { return err(CellError::Calc) };
|
||||
scan.push(one);
|
||||
}
|
||||
}
|
||||
if name == "SCAN" {
|
||||
@@ -1057,6 +1102,9 @@ impl<'h> Evaluator<'h> {
|
||||
let arr = as_array(self.value(&args[0]));
|
||||
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) };
|
||||
if l.params.len() != 1 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let by_row = name == "BYROW";
|
||||
let n = if by_row { arr.rows } else { arr.cols };
|
||||
let mut out = Vec::with_capacity(n);
|
||||
@@ -1068,7 +1116,8 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
let a = if by_row { Array::row(slice) } else { Array::column(slice) };
|
||||
let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]);
|
||||
out.push(self.deref(r).scalar());
|
||||
let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) };
|
||||
out.push(one);
|
||||
}
|
||||
let a = if by_row { Array::column(out) } else { Array::row(out) };
|
||||
Ev::V(a.into())
|
||||
@@ -1097,7 +1146,8 @@ impl<'h> Evaluator<'h> {
|
||||
(l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))),
|
||||
];
|
||||
let v = self.call_lambda_values(&l, bound);
|
||||
out.push(self.deref(v).scalar());
|
||||
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
|
||||
out.push(one);
|
||||
}
|
||||
}
|
||||
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
|
||||
@@ -1318,7 +1368,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
|
||||
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
|
||||
Value::Number(p) if p >= 1.0 => {
|
||||
let k = p as u32 - 1;
|
||||
@@ -1373,7 +1423,16 @@ impl<'h> Evaluator<'h> {
|
||||
}))
|
||||
}
|
||||
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
|
||||
"filename" => Ev::V(Value::text(String::new())),
|
||||
// Excel's `<folder>/[<file>]<sheet>`, which the classic sheet-name formula splits on
|
||||
// the `]`. Empty until the workbook has a file of its own, as in Excel (#1489).
|
||||
"filename" => Ev::V(Value::text(match &wb.source_path {
|
||||
Some(file) => {
|
||||
let name = file.file_name().unwrap_or_default().to_string_lossy();
|
||||
let on = sheet.map_or("", |s| s.name.as_str());
|
||||
file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned()
|
||||
}
|
||||
None => String::new(),
|
||||
})),
|
||||
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
|
||||
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
|
||||
"format" => {
|
||||
@@ -1422,43 +1481,74 @@ impl<'h> Evaluator<'h> {
|
||||
if name == "AGGREGATE" && func >= 14 {
|
||||
k = rest.get(1).map(|e| self.value(e));
|
||||
}
|
||||
// Every cell visited (even one skipped as hidden, nested or an error) and every value
|
||||
// counts against one budget for the whole call. All arguments are evaluated (references
|
||||
// stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes
|
||||
// `A:XFD` trim to the whole sheet, ~17 billion cells (#2175).
|
||||
enum Input {
|
||||
Cells(usize, RangeRef),
|
||||
Values(Value),
|
||||
}
|
||||
let mut inputs = Vec::new();
|
||||
let mut budget = MAX_CELLS;
|
||||
for e in refs {
|
||||
match self.eval(e) {
|
||||
Ev::R(areas) => {
|
||||
for a in areas {
|
||||
let range = self.trim(&a);
|
||||
for c in range.iter() {
|
||||
let (hidden, nested) = {
|
||||
let wb = self.host.workbook();
|
||||
let s = wb.sheet(a.sheet);
|
||||
let hidden =
|
||||
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
|
||||
let nested = skip_nested
|
||||
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
|
||||
let u = f.text.to_ascii_uppercase();
|
||||
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
|
||||
});
|
||||
(hidden, nested)
|
||||
};
|
||||
if hidden || nested {
|
||||
continue;
|
||||
}
|
||||
let v = self.host.cell_value(a.sheet, c);
|
||||
if skip_errors && v.is_error() {
|
||||
continue;
|
||||
}
|
||||
values.push(v);
|
||||
}
|
||||
budget = match budget.checked_sub(range.count()) {
|
||||
Some(left) => left,
|
||||
None => return err(CellError::Num),
|
||||
};
|
||||
inputs.push(Input::Cells(a.sheet, range));
|
||||
}
|
||||
}
|
||||
Ev::V(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
|
||||
Ev::V(v) => values.push(v),
|
||||
Ev::V(v) => {
|
||||
let n = match &v {
|
||||
Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX),
|
||||
_ => 1,
|
||||
};
|
||||
budget = match budget.checked_sub(n) {
|
||||
Some(left) => left,
|
||||
None => return err(CellError::Num),
|
||||
};
|
||||
inputs.push(Input::Values(v));
|
||||
}
|
||||
Ev::L(_) => return err(CellError::Value),
|
||||
}
|
||||
}
|
||||
for input in inputs {
|
||||
match input {
|
||||
Input::Cells(sheet, range) => {
|
||||
for c in range.iter() {
|
||||
let (hidden, nested) = {
|
||||
let wb = self.host.workbook();
|
||||
let s = wb.sheet(sheet);
|
||||
let hidden =
|
||||
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
|
||||
let nested = skip_nested
|
||||
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
|
||||
let u = f.text.to_ascii_uppercase();
|
||||
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
|
||||
});
|
||||
(hidden, nested)
|
||||
};
|
||||
if hidden || nested {
|
||||
continue;
|
||||
}
|
||||
let v = self.host.cell_value(sheet, c);
|
||||
if skip_errors && v.is_error() {
|
||||
continue;
|
||||
}
|
||||
values.push(v);
|
||||
}
|
||||
}
|
||||
Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
|
||||
Input::Values(v) => values.push(v),
|
||||
}
|
||||
}
|
||||
if let Some(k) = k {
|
||||
values.push(Value::Empty);
|
||||
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values[..values.len() - 1], &k));
|
||||
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k));
|
||||
}
|
||||
Ev::V(gridcraft_functions::aggregate_values(func, &values))
|
||||
}
|
||||
@@ -1615,7 +1705,8 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
|
||||
(Value::Array(x), Value::Array(y)) => {
|
||||
let rows = broadcast_dim(x.rows, y.rows);
|
||||
let cols = broadcast_dim(x.cols, y.cols);
|
||||
let mut data = Vec::with_capacity(rows * cols);
|
||||
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
|
||||
let mut data = Vec::with_capacity(n);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c)));
|
||||
@@ -1629,6 +1720,13 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
|
||||
}
|
||||
}
|
||||
|
||||
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
|
||||
/// Each operand passes its own size cap, but their broadcast product can be far larger
|
||||
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
|
||||
fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
|
||||
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
|
||||
}
|
||||
|
||||
fn broadcast_dim(a: usize, b: usize) -> usize {
|
||||
if a == 1 {
|
||||
b
|
||||
@@ -1645,8 +1743,9 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
|
||||
let (fr, fc) = dims(f);
|
||||
let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr);
|
||||
let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc);
|
||||
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
|
||||
let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() };
|
||||
let mut data = Vec::with_capacity(rows * cols);
|
||||
let mut data = Vec::with_capacity(n);
|
||||
for r in 0..rows {
|
||||
for col in 0..cols {
|
||||
data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col)));
|
||||
@@ -1655,6 +1754,15 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
|
||||
Value::from(Array { rows, cols, data })
|
||||
}
|
||||
|
||||
/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays
|
||||
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
|
||||
fn single(v: Value) -> Option<Value> {
|
||||
match v {
|
||||
Value::Array(a) if a.rows * a.cols > 1 => None,
|
||||
v => Some(v.scalar()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
|
||||
}
|
||||
|
||||
+179
-9
@@ -60,6 +60,12 @@ const WIDE: u32 = 64;
|
||||
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
|
||||
const PENDING: usize = 32;
|
||||
|
||||
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
|
||||
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
|
||||
/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate
|
||||
/// whatever order they come in, with bounded stack.
|
||||
const MAX_NESTED: usize = 64;
|
||||
|
||||
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
|
||||
/// evaluated again), however high Maximum Iterations is.
|
||||
const MAX_ITERATION_WORK: usize = 10_000_000;
|
||||
@@ -395,6 +401,24 @@ struct PassHost<'a> {
|
||||
cycle: bool,
|
||||
cycles: Vec<Key>,
|
||||
depth: usize,
|
||||
/// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned
|
||||
/// and it is calculated first.
|
||||
suspend: Option<Key>,
|
||||
/// Formulas whose evaluation was abandoned for another one, to be calculated again after it.
|
||||
/// They all need it, so reading one of them while it is calculated is a circular reference,
|
||||
/// as reading a formula in progress is.
|
||||
waiting: HashSet<Key>,
|
||||
/// The formulas abandoned since the last suspension.
|
||||
abandoned: Vec<Key>,
|
||||
/// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They
|
||||
/// are calculated along with the suspended one, rather than one per restart (a wide range
|
||||
/// read at the limit would restart the abandoned evaluations once per cell in it).
|
||||
also: Vec<Key>,
|
||||
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
|
||||
#[cfg(test)]
|
||||
suspensions: usize,
|
||||
/// The formulas in `cycles`.
|
||||
cycle_seen: HashSet<Key>,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
/// giving `#CIRC!`.
|
||||
iterate: bool,
|
||||
@@ -425,6 +449,13 @@ impl<'a> PassHost<'a> {
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
suspend: None,
|
||||
waiting: HashSet::default(),
|
||||
abandoned: Vec::new(),
|
||||
also: Vec::new(),
|
||||
#[cfg(test)]
|
||||
suspensions: 0,
|
||||
cycle_seen: HashSet::default(),
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
stack: Vec::new(),
|
||||
@@ -456,7 +487,65 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
|
||||
}
|
||||
|
||||
/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over
|
||||
/// `range`: the same area, nothing new in the way, and the same values.
|
||||
fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool {
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)));
|
||||
RangeRef::new(anchor, end) == range
|
||||
&& !sheet.merges.iter().any(|m| m.intersects(&range))
|
||||
&& !sheet.tables.iter().any(|t| t.range.contains(anchor))
|
||||
&& range
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
|
||||
}
|
||||
|
||||
impl PassHost<'_> {
|
||||
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
|
||||
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
|
||||
/// calculated from here, then the suspended ones again (reading the results kept so far).
|
||||
fn calculate(&mut self, k: Key) {
|
||||
// Formulas to calculate, innermost last, each with the ones abandoned to calculate the
|
||||
// next.
|
||||
let mut work: Vec<(Key, Vec<Key>)> = vec![(k, Vec::new())];
|
||||
while let Some(&(top, _)) = work.last() {
|
||||
self.compute(top);
|
||||
match self.suspend.take() {
|
||||
Some(next) => {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.suspensions += 1;
|
||||
}
|
||||
let held = std::mem::take(&mut self.abandoned);
|
||||
self.waiting.extend(held.iter().copied());
|
||||
if let Some(entry) = work.last_mut() {
|
||||
entry.1 = held;
|
||||
}
|
||||
// The other formulas read at the limit are calculated after `next`, before the
|
||||
// held evaluations restart. Each has nothing held of its own, so the held ones
|
||||
// are released only when the last of them is done.
|
||||
let mut seen: HashSet<Key> = HashSet::default();
|
||||
seen.insert(next);
|
||||
for s in std::mem::take(&mut self.also) {
|
||||
if seen.insert(s) {
|
||||
work.push((s, Vec::new()));
|
||||
}
|
||||
}
|
||||
work.push((next, Vec::new()));
|
||||
}
|
||||
None => {
|
||||
work.pop();
|
||||
if let Some(entry) = work.last_mut() {
|
||||
for a in entry.1.drain(..) {
|
||||
self.waiting.remove(&a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute(&mut self, k: Key) -> Value {
|
||||
if let Some(v) = self.results.get(&k) {
|
||||
let v = v.clone();
|
||||
@@ -468,14 +557,29 @@ impl PassHost<'_> {
|
||||
let Some(expr) = self.exprs.get(&k) else {
|
||||
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
|
||||
};
|
||||
if self.in_progress.contains(&k) || self.depth > 2000 {
|
||||
if self.suspend.is_some() {
|
||||
// Unwinding to `calculate`: the value is thrown away. A formula read at the limit is
|
||||
// calculated with the suspended one, so the evaluations are not restarted for each.
|
||||
if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) {
|
||||
self.also.push(k);
|
||||
}
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
if self.in_progress.contains(&k) || self.waiting.contains(&k) {
|
||||
self.cycle = true;
|
||||
if self.iterate {
|
||||
return self.circular_read(k);
|
||||
}
|
||||
self.cycles.push(k);
|
||||
// Once each, though a suspended evaluation meets it again.
|
||||
if self.cycle_seen.insert(k) {
|
||||
self.cycles.push(k);
|
||||
}
|
||||
return Value::Error(CellError::Circ);
|
||||
}
|
||||
if self.depth >= MAX_NESTED {
|
||||
self.suspend = Some(k);
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
self.in_progress.insert(k);
|
||||
self.depth += 1;
|
||||
let outer_taint = std::mem::replace(&mut self.taint, false);
|
||||
@@ -487,6 +591,17 @@ impl PassHost<'_> {
|
||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||
ev.value(&expr)
|
||||
};
|
||||
if self.suspend.is_some() {
|
||||
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
|
||||
self.depth -= 1;
|
||||
self.in_progress.remove(&k);
|
||||
self.abandoned.push(k);
|
||||
if self.iterate {
|
||||
self.stack.pop();
|
||||
}
|
||||
self.taint = outer_taint;
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
// A legacy array formula fills the range it was entered in.
|
||||
let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array);
|
||||
let v = match legacy {
|
||||
@@ -517,9 +632,11 @@ impl PassHost<'_> {
|
||||
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
};
|
||||
if a.rows > 1 || a.cols > 1 {
|
||||
self.spills.insert(k, a.clone());
|
||||
}
|
||||
// A dynamic-array formula anchors a spill range even while its result is one
|
||||
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
|
||||
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
|
||||
// returns a scalar never reaches this arm, so it does not become an anchor.
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
}
|
||||
Value::Empty => Value::Number(0.0),
|
||||
@@ -577,7 +694,7 @@ impl PassHost<'_> {
|
||||
self.pending = set.clone();
|
||||
self.spills.retain(|a, _| !set.contains(a));
|
||||
for k in &again {
|
||||
self.compute(*k);
|
||||
self.calculate(*k);
|
||||
}
|
||||
passes += 1;
|
||||
}
|
||||
@@ -950,6 +1067,7 @@ impl Calc {
|
||||
/// Evaluates the dirty set and writes results (and spills) back.
|
||||
fn run(&mut self, wb: &mut Workbook, mut dirty: Vec<Key>, full: bool) {
|
||||
let mut dynamic_again = !self.graph.dynamic.is_empty();
|
||||
self.last_recalc_cells = 0;
|
||||
for _round in 0..8 {
|
||||
if dirty.is_empty() {
|
||||
break;
|
||||
@@ -969,7 +1087,7 @@ impl Calc {
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.compute(*k);
|
||||
host.calculate(*k);
|
||||
}
|
||||
if host.iterate && !host.members.is_empty() {
|
||||
let passes = host.iterate_loops(&dirty, wb.calc.max_iterations, wb.calc.max_change);
|
||||
@@ -984,7 +1102,7 @@ impl Calc {
|
||||
eprintln!(" eval {} formulas {:.1} ms", results.len(), prof_now() - tp);
|
||||
}
|
||||
let tw = prof_now();
|
||||
self.last_recalc_cells = results.len();
|
||||
self.last_recalc_cells += results.len();
|
||||
self.circular = cycles;
|
||||
// Write back.
|
||||
let mut spill_changes: Vec<Key> = Vec::new();
|
||||
@@ -1004,6 +1122,14 @@ impl Calc {
|
||||
if old.is_none() && new.is_none() {
|
||||
continue;
|
||||
}
|
||||
// An array that spills exactly as before changes nothing its dependents read, so
|
||||
// they aren't queued again. Otherwise two arrays that read each other's spill areas
|
||||
// queue each other in every round, up to the round limit.
|
||||
if let (Some(r), Some(arr)) = (old, new)
|
||||
&& spills_as_before(sheet, k.1, r, arr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = old {
|
||||
for c in r.iter() {
|
||||
sheet.spill.remove(&c);
|
||||
@@ -1023,6 +1149,13 @@ impl Calc {
|
||||
if let Some(cell) = sheet.cells.get_mut(k.1) {
|
||||
cell.value = Value::Error(CellError::Spill);
|
||||
}
|
||||
// Not blocked while the pass ran (it would have been #SPILL! there), so
|
||||
// blocked by another array of this pass: the formulas that read this one,
|
||||
// at its anchor or over the area it would have spilled into, read #SPILL!
|
||||
// or empty cells now.
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents_of_range(k.0, range, &mut deps);
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k));
|
||||
} else {
|
||||
for (i, c) in range.iter().enumerate() {
|
||||
if c != k.1 {
|
||||
@@ -1036,7 +1169,15 @@ impl Calc {
|
||||
if let Some(r) = changed_range {
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents_of_range(k.0, r, &mut deps);
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k));
|
||||
// Not the anchor itself (that would read its own array), unless its old array
|
||||
// is gone or partly freed: its new formula may have read the old values, still
|
||||
// laid out while it was evaluated.
|
||||
let freed = match (old, sheet.spill_ranges.get(&k.1)) {
|
||||
(Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)),
|
||||
(_, None) => true,
|
||||
(None, Some(_)) => false,
|
||||
};
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k || freed));
|
||||
}
|
||||
if let Some(o) = old
|
||||
&& !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end))
|
||||
@@ -1322,6 +1463,35 @@ pub(crate) mod tests {
|
||||
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
|
||||
}
|
||||
|
||||
/// A formula at the nesting limit reading thousands of formulas not calculated yet suspends the
|
||||
/// evaluations in progress once, not once per formula read.
|
||||
#[test]
|
||||
fn wide_read_at_the_nesting_limit_suspends_once() {
|
||||
const WIDTH: u32 = 3000;
|
||||
let mut wb = Workbook::new();
|
||||
let mut exprs = HashMap::default();
|
||||
let mut put = |wb: &mut Workbook, at: String, text: String| {
|
||||
let at = CellRef::parse(&at).unwrap();
|
||||
wb.sheet_mut(0).unwrap().cells.set(at, Cell::formula(Formula::new(&text)));
|
||||
exprs.insert((0, at), std::sync::Arc::new(gridcraft_formula::parse(&text[1..]).unwrap()));
|
||||
};
|
||||
// A1 reads A2, ..., A64 (the 64th formula deep) reads a wide range of formulas.
|
||||
for r in 1..64 {
|
||||
put(&mut wb, format!("A{r}"), format!("=A{}+1", r + 1));
|
||||
}
|
||||
put(&mut wb, "A64".to_string(), format!("=SUM(B1:B{WIDTH})"));
|
||||
for r in 1..=WIDTH {
|
||||
put(&mut wb, format!("B{r}"), format!("={r}*2"));
|
||||
}
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0);
|
||||
host.calculate((0, CellRef::parse("A1").unwrap()));
|
||||
let total = (WIDTH * (WIDTH + 1)) as f64;
|
||||
assert_eq!(host.results.get(&(0, CellRef::parse("A1").unwrap())), Some(&Value::Number(total + 63.0)));
|
||||
assert!(host.cycles.is_empty());
|
||||
assert!(host.suspensions <= 3, "{} suspensions", host.suspensions);
|
||||
}
|
||||
|
||||
/// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the
|
||||
/// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been
|
||||
/// calculated yet, or `None` when it declines the block.
|
||||
|
||||
@@ -67,6 +67,25 @@ fn spill_range_operator() {
|
||||
assert_eq!(t.num("E1"), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_that_spill_as_before_do_not_requeue_their_dependents() {
|
||||
// Two arrays that each read the other's spill area, and a formula reading both spills.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=SEQUENCE(2)+0*B2");
|
||||
t.set("B1", "=SEQUENCE(2)+0*A2");
|
||||
t.set("C1", "=SUM(A1#)+SUM(B1#)");
|
||||
// An edit that spills the same values again: one round, not one per round up to the limit.
|
||||
t.set("A1", "=SEQUENCE(2)+0*B2+0");
|
||||
assert_eq!((t.num("A2"), t.num("B2"), t.num("C1")), (2.0, 2.0, 6.0));
|
||||
assert!(t.calc.last_recalc_cells <= 3, "{} formulas evaluated for one edit", t.calc.last_recalc_cells);
|
||||
// Only the anchor's value changes: formulas reading the whole spill still see it.
|
||||
t.set("A1", "={7;2}+0*B2");
|
||||
assert_eq!((t.num("A1"), t.num("C1")), (7.0, 12.0));
|
||||
// A spill that does change still reaches its dependents.
|
||||
t.set("A1", "=SEQUENCE(2)*10+0*B2");
|
||||
assert_eq!((t.num("A2"), t.num("C1")), (20.0, 33.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_dependencies() {
|
||||
let mut t = T::new();
|
||||
@@ -310,6 +329,29 @@ fn date_text_coerces_in_the_workbook_date_system() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_helpers_check_shapes_and_parameters() {
|
||||
let mut t = T::new();
|
||||
let calc = Value::Error(CellError::Calc);
|
||||
// A LAMBDA returning several values can't fill one cell: the whole result is #CALC!.
|
||||
t.set("A1", "=MAP({1,2},LAMBDA(x,{1,2}))");
|
||||
assert_eq!((t.get("A1"), t.get("B1")), (calc.clone(), Value::Empty));
|
||||
t.set("A2", "=BYROW({1,2;3,4},LAMBDA(r,r))");
|
||||
assert_eq!((t.get("A2"), t.get("A3")), (calc.clone(), Value::Empty));
|
||||
t.set("A4", "=MAKEARRAY(1,2,LAMBDA(r,c,{1,2}))");
|
||||
assert_eq!(t.get("A4"), calc.clone());
|
||||
t.set("D1", "=SCAN(0,{1,2},LAMBDA(a,b,{1,2}))");
|
||||
assert_eq!((t.get("D1"), t.get("E1")), (calc, Value::Empty));
|
||||
// One parameter per array (MAP) or exactly one (BYROW/BYCOL).
|
||||
t.set("A5", "=MAP({1,2},LAMBDA(x,y,x))");
|
||||
assert_eq!(t.get("A5"), Value::Error(CellError::Value));
|
||||
t.set("A6", "=BYCOL({1,2},LAMBDA(c,d,SUM(c)))");
|
||||
assert_eq!(t.get("A6"), Value::Error(CellError::Value));
|
||||
// A single value in an array is fine.
|
||||
t.set("A7", "=MAP({1,2},LAMBDA(x,SEQUENCE(1)*x))");
|
||||
assert_eq!(t.num("B7"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
@@ -368,6 +410,25 @@ fn spill_into_the_old_area_of_a_blocked_neighbour() {
|
||||
assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors() {
|
||||
// Two arrays of one recalculation want the same empty cell: the one laid out second is
|
||||
// #SPILL!, and the formulas that read it in that pass (at its anchor, through `A2#`, or over
|
||||
// the cells it would have spilled into) kept its values.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "=SEQUENCE(3)");
|
||||
t.set("A2", "=SEQUENCE(1,3)");
|
||||
t.set("D2", "=A2*10");
|
||||
t.set("E2", "=C2");
|
||||
t.set("F2", "=SUM(A2#)");
|
||||
t.set("G2", "=SUM(C2:C3)");
|
||||
// Both arrays recalculated together: B1 is laid out first and takes B2.
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
let spill = Value::Error(CellError::Spill);
|
||||
assert_eq!((t.num("B2"), t.get("A2"), t.get("D2")), (2.0, spill.clone(), spill));
|
||||
assert_eq!((t.get("C2"), t.num("E2"), t.get("F2"), t.num("G2")), (Value::Empty, 0.0, Value::Error(CellError::Ref), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_do_not_spill_in_tables() {
|
||||
// Excel: spilled array formulas aren't supported in tables.
|
||||
@@ -543,6 +604,25 @@ fn spills() {
|
||||
assert_eq!(t.num("D2"), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty() {
|
||||
// The new formula was evaluated while the old array was still laid out, and only the
|
||||
// anchor's dependents were evaluated again once it was gone.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=SEQUENCE(3)");
|
||||
assert_eq!(t.num("A3"), 3.0);
|
||||
t.set("A1", "=SUM(A2:A3)+1");
|
||||
assert_eq!((t.num("A1"), t.get("A3")), (1.0, Value::Empty));
|
||||
// Still an array, now spilling elsewhere: it read A2 as 2 and spilled three columns.
|
||||
t.set("A1", "=SEQUENCE(3)");
|
||||
t.set("A1", "=SEQUENCE(1,A2+1)");
|
||||
assert_eq!((t.num("A1"), t.get("B1"), t.get("A2")), (1.0, Value::Empty, Value::Empty));
|
||||
// A smaller array inside the old area: it read A3 as 3 and spilled over A1:A2.
|
||||
t.set("A1", "=SEQUENCE(3)");
|
||||
t.set("A1", "=SEQUENCE(MAX(1,A3-1))");
|
||||
assert_eq!((t.num("A1"), t.get("A2"), t.get("A3")), (1.0, Value::Empty, Value::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_keep_their_size_past_the_used_range() {
|
||||
let mut t = T::new();
|
||||
@@ -590,6 +670,27 @@ fn let_lambda() {
|
||||
assert_eq!(t.num("A4"), 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn let_lambda_names_ignore_case_in_any_script() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=LET(dług,100,DŁUG*2)");
|
||||
assert_eq!(t.num("A1"), 200.0);
|
||||
t.set("A2", "=LAMBDA(λ,ISOMITTED(Λ))()");
|
||||
assert_eq!(t.get("A2"), Value::Bool(true));
|
||||
t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)");
|
||||
assert_eq!(t.num("A3"), 2.0);
|
||||
t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))");
|
||||
assert_eq!(t.num("A4"), 6.0);
|
||||
t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))");
|
||||
assert_eq!(t.num("A5"), 5.0);
|
||||
// `ß` upper-cases to `SS`, yet `ẞ` is its capital.
|
||||
t.set("A6", "=LET(ß,100,ẞ*2)");
|
||||
assert_eq!(t.num("A6"), 200.0);
|
||||
// Dotless ı is a different name from i.
|
||||
t.set("A7", "=LET(i,1,ı,2,i)");
|
||||
assert_eq!(t.num("A7"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_given_by_name() {
|
||||
let mut t = T::new();
|
||||
@@ -611,6 +712,36 @@ fn functions_given_by_name() {
|
||||
assert_eq!(t.num("J1"), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groupby_and_pivotby_take_a_lambda() {
|
||||
let mut t = T::new();
|
||||
for (i, (k, c, v)) in [("a", "x", 1), ("b", "x", 2), ("a", "y", 3)].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 1), k);
|
||||
t.set(&format!("B{}", i + 1), c);
|
||||
t.set(&format!("C{}", i + 1), &v.to_string());
|
||||
}
|
||||
t.set("E1", "=GROUPBY(A1:A3,C1:C3,LAMBDA(x,SUM(x)))");
|
||||
assert_eq!(t.get("E1"), Value::from("a"));
|
||||
assert_eq!(t.num("F1"), 4.0);
|
||||
assert_eq!(t.get("E2"), Value::from("b"));
|
||||
assert_eq!(t.num("F2"), 2.0);
|
||||
assert_eq!(t.get("E3"), Value::from("Total"));
|
||||
assert_eq!(t.num("F3"), 6.0);
|
||||
// A LET-bound lambda, called once per group with that group's values.
|
||||
t.set("H1", "=LET(f,LAMBDA(v,ROWS(v)*10),GROUPBY(A1:A3,C1:C3,f))");
|
||||
assert_eq!(t.num("I1"), 20.0);
|
||||
assert_eq!(t.num("I3"), 30.0);
|
||||
// PIVOTBY: rows a, b, Total; columns x, y, Total.
|
||||
t.set("K1", "=PIVOTBY(A1:A3,B1:B3,C1:C3,LAMBDA(x,SUM(x)))");
|
||||
assert_eq!(t.get("L1"), Value::from("x"));
|
||||
assert_eq!(t.num("L2"), 1.0);
|
||||
assert_eq!(t.num("M2"), 3.0);
|
||||
assert_eq!(t.num("N4"), 6.0);
|
||||
// Neither a function nor a lambda.
|
||||
t.set("P1", "=GROUPBY(A1:A3,C1:C3,TRUE)");
|
||||
assert_eq!(t.get("P1"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_argument_count() {
|
||||
let mut t = T::new();
|
||||
@@ -657,6 +788,24 @@ fn names_and_sheets() {
|
||||
assert_eq!(t.num("A1"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_referring_to_several_ranges() {
|
||||
let mut t = T::new();
|
||||
// As Excel writes it in `<definedName>`: a union with no parentheses around it.
|
||||
let union = "Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2";
|
||||
t.wb.names.push(gridcraft_model::DefinedName { name: "u".into(), scope: None, formula: union.into(), comment: String::new(), hidden: false });
|
||||
for (at, v) in [("A1", "1"), ("A2", "2"), ("C1", "5"), ("C2", "7")] {
|
||||
t.set(at, v);
|
||||
}
|
||||
t.set("E1", "=SUM(u)");
|
||||
t.set("E2", "=AREAS(u)");
|
||||
t.set("E3", "=MIN(u)");
|
||||
t.set("E4", "=INDEX(u,2,1,2)");
|
||||
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (15.0, 2.0, 1.0, 7.0));
|
||||
t.set("C2", "70");
|
||||
assert_eq!(t.num("E1"), 78.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_formulas_fill_their_range() {
|
||||
let mut t = T::new();
|
||||
@@ -684,6 +833,34 @@ fn legacy_array_formulas_fill_their_range() {
|
||||
assert_eq!(t.get("H1"), Value::Error(CellError::Num));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_broadcast_result_is_capped() {
|
||||
// A full column against a full row broadcasts to 1048576 x 16384 (17 billion cells, about
|
||||
// 400 GB): each operand is within its own cap, so only the product check stops the abort.
|
||||
let mut t = T::new();
|
||||
let col = "SEQUENCE(1048576)";
|
||||
let row = "SEQUENCE(1,16384)";
|
||||
for (at, formula) in [
|
||||
("A1", format!("=ROWS({col}*{row})")),
|
||||
("A2", format!("=ROWS({col}&{row})")),
|
||||
("A3", format!("=ROWS({col}>{row})")),
|
||||
("A4", format!("=ROWS(IF({col},{row}))")),
|
||||
("A5", format!("=ROWS(IF({col}>0,1,{row}))")),
|
||||
("A6", format!("=ROWS(TEXT({col},{row}))")),
|
||||
("A7", format!("=ROWS(MAP({col},{row},LAMBDA(a,b,a)))")),
|
||||
] {
|
||||
t.set(at, &formula);
|
||||
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
|
||||
}
|
||||
// Broadcasts within the cap are unchanged.
|
||||
t.set("C1", "=SUM(SEQUENCE(3)*SEQUENCE(1,4))");
|
||||
assert_eq!(t.num("C1"), 60.0);
|
||||
t.set("C2", "=SUM(IF(SEQUENCE(2)>1,SEQUENCE(1,3),0))");
|
||||
assert_eq!(t.num("C2"), 6.0);
|
||||
t.set("C3", "=COLUMNS(TEXT(SEQUENCE(2),SEQUENCE(1,5)))");
|
||||
assert_eq!(t.num("C3"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_function() {
|
||||
let mut t = T::new();
|
||||
@@ -702,6 +879,41 @@ fn long_operator_chains() {
|
||||
assert_eq!(t.num("B2"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_chains_as_long_as_excel_allows() {
|
||||
// Values observed in Excel (#916): a flat chain has no operand limit below the 8,192-character
|
||||
// formula limit, which `=1+1+…` with 4,096 operands meets exactly. On a 1 MiB stack, as on
|
||||
// the Windows main thread.
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let chain = |op: &str, item: &str, n: usize| format!("={}", vec![item; n].join(op));
|
||||
let mut t = T::new();
|
||||
for r in 1..=1500 {
|
||||
t.set(&format!("A{r}"), "1");
|
||||
}
|
||||
t.set("B1", &chain("+", "1", 401));
|
||||
t.set("B2", &chain("+", "1", 4096));
|
||||
t.set("B3", &chain("-", "1", 2000));
|
||||
t.set("B4", &chain("*", "1", 4096));
|
||||
t.set("B5", &chain("/", "2", 4096));
|
||||
t.set("B6", &chain("^", "1", 4096));
|
||||
t.set("B7", &chain("+", "1%", 2000));
|
||||
t.set("B8", "=1=1=1");
|
||||
t.set("B9", &format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+")));
|
||||
t.set("B10", &chain("&", "1", 2000));
|
||||
for (at, want) in [("B1", 401.0), ("B2", 4096.0), ("B3", -1998.0), ("B4", 1.0), ("B5", 0.0), ("B6", 1.0), ("B9", 1500.0)] {
|
||||
assert_eq!(t.num(at), want, "{at}");
|
||||
}
|
||||
assert!((t.num("B7") - 20.0).abs() < 1e-9);
|
||||
assert_eq!(t.get("B8"), Value::Bool(false));
|
||||
assert_eq!(t.get("B10"), Value::text("1".repeat(2000)));
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("long chains must not overflow the stack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_skips_hidden() {
|
||||
let mut t = T::new();
|
||||
@@ -738,6 +950,40 @@ fn subtotal_109_skips_nested_subtotal() {
|
||||
assert_eq!(t.num("B2"), 3.0);
|
||||
}
|
||||
|
||||
/// One value stored in the sheet's last cell makes `A:XFD` trim to ~17 billion cells. SUBTOTAL
|
||||
/// and AGGREGATE used to read every one of them into a vector (an abort on open, #2175); now
|
||||
/// every argument is charged against `MAX_CELLS` before any cell is read, and `#NUM!` comes back.
|
||||
#[test]
|
||||
fn subtotal_and_aggregate_cap_cells_visited() {
|
||||
let mut t = T::new();
|
||||
t.set("XFD1048576", "1");
|
||||
let formulas = [
|
||||
"=SUBTOTAL(9,A:XFD)",
|
||||
"=SUBTOTAL(109,1:1048576)",
|
||||
"=AGGREGATE(9,6,A:XFD)",
|
||||
"=AGGREGATE(14,7,A:XFD,1)",
|
||||
// A:J and K:T are about 10.5 million cells each: under the limit alone, over it together,
|
||||
// so the second area is refused before the first is read.
|
||||
"=SUBTOTAL(9,A:J,K:T)",
|
||||
"=AGGREGATE(9,4,(A:J,K:T))",
|
||||
];
|
||||
for (i, f) in formulas.iter().enumerate() {
|
||||
let at = format!("B{}", i + 1);
|
||||
t.set(&at, f);
|
||||
assert_eq!(t.get(&at), Value::Error(CellError::Num), "{f}");
|
||||
}
|
||||
|
||||
// Whole columns and rows still clip to the used range when it is small.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
t.set("B2", "2");
|
||||
t.set("C2", "#N/A");
|
||||
t.set("E5", "=SUBTOTAL(9,A:B)");
|
||||
t.set("E6", "=AGGREGATE(9,6,1:2)");
|
||||
t.set("E7", "=AGGREGATE(14,6,A:C,1)");
|
||||
assert_eq!((t.num("E5"), t.num("E6"), t.num("E7")), (3.0, 3.0, 2.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).
|
||||
@@ -1267,6 +1513,142 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
|
||||
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
|
||||
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
|
||||
#[test]
|
||||
fn long_dependency_chains_against_row_order_calculate() {
|
||||
const N: u32 = 20_000;
|
||||
let mut t = T::new();
|
||||
{
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for r in 1..N {
|
||||
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
|
||||
}
|
||||
sh.cells.set(c(&format!("A{N}")), Cell::value(Value::Number(1.0)));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), N as f64);
|
||||
assert_eq!(t.num("A2"), (N - 1) as f64);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
// An edit at the far end recalculates the whole chain.
|
||||
t.set(&format!("A{N}"), "10");
|
||||
assert_eq!(t.num("A1"), (N + 9) as f64);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
|
||||
// Hopping between two sheets at every step, through a range read (SUM) as well.
|
||||
let mut t = T::new();
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
|
||||
for r in 1..N {
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=Data!A{r}+1"))));
|
||||
let next = format!("=SUM(Sheet1!A{}:A{})", r + 1, r + 1);
|
||||
t.wb.sheet_mut(1).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&next)));
|
||||
}
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{N}")), Cell::value(Value::Number(0.0)));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), (N - 1) as f64);
|
||||
assert_eq!(t.wb.sheet(1).unwrap().value(c("A1")), Value::Number((N - 2) as f64));
|
||||
assert!(t.calc.circular.is_empty());
|
||||
}
|
||||
|
||||
/// A loop longer than the on-demand nesting limit is still a circular reference (and still ends).
|
||||
#[test]
|
||||
fn long_circular_chain_is_circular() {
|
||||
const N: u32 = 5_000;
|
||||
let mut t = T::new();
|
||||
{
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for r in 1..N {
|
||||
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
|
||||
}
|
||||
sh.cells.set(c(&format!("A{N}")), Cell::formula(Formula::new("=A1+1")));
|
||||
sh.cells.set(c("B1"), Cell::formula(Formula::new("=1+2")));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.get(&format!("A{N}")), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.num("B1"), 3.0);
|
||||
assert!(!t.calc.circular.is_empty());
|
||||
// Each formula is listed once at most.
|
||||
let mut seen = t.calc.circular.clone();
|
||||
seen.sort_by_key(|(s, c)| (*s, c.row, c.col));
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), t.calc.circular.len());
|
||||
// With iterative calculation the loop settles to numbers instead (no hang, no crash).
|
||||
t.wb.calc.iterative = true;
|
||||
t.wb.calc.max_iterations = 3;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert!(t.get("A1").as_f64().is_some());
|
||||
}
|
||||
|
||||
/// A loop closing back into the middle of a chain deeper than the on-demand nesting limit is found
|
||||
/// where calculating it in one go finds it: at the formula read while it was being calculated.
|
||||
#[test]
|
||||
fn deep_loop_is_found_where_it_closes() {
|
||||
let mut t = T::new();
|
||||
{
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for r in 1..300 {
|
||||
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
|
||||
}
|
||||
sh.cells.set(c("A300"), Cell::formula(Formula::new("=A30+1")));
|
||||
// Reads the loop through IFERROR, so its value shows where the loop was cut.
|
||||
sh.cells.set(c("B1"), Cell::formula(Formula::new("=IFERROR(A1,-1)")));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.calc.circular, vec![(0, c("A30"))]);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.num("B1"), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_bounded_references_keep_their_shape() {
|
||||
// D3:AZ400 is 19,502 cells, past the size where a block used to be cut to the used range
|
||||
// (which ends at column F here). Sliced that way it no longer lined up with the full-size
|
||||
// operands next to it. Expected values observed in Excel 365 on the same layout.
|
||||
let mut t = T::new();
|
||||
for (at, v) in [
|
||||
("D1", "1"),
|
||||
("E1", "2"),
|
||||
("F1", "3"),
|
||||
("A3", "1"),
|
||||
("A4", "2"),
|
||||
("A5", "1"),
|
||||
("B3", "2"),
|
||||
("B4", "3"),
|
||||
("B5", "4"),
|
||||
("D3", "10"),
|
||||
("E3", "20"),
|
||||
("F3", "30"),
|
||||
("D4", "40"),
|
||||
("E4", "50"),
|
||||
("F4", "60"),
|
||||
("D5", "70"),
|
||||
("E5", "80"),
|
||||
("F5", "90"),
|
||||
] {
|
||||
t.set(at, v);
|
||||
}
|
||||
let cases = [
|
||||
("=SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(360.0)),
|
||||
("=ROWS(D3:AZ400*1)", Value::Number(398.0)),
|
||||
("=COLUMNS(D3:AZ400*1)", Value::Number(49.0)),
|
||||
("=SUMPRODUCT(D3:AZ400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(100.0)),
|
||||
("=SUMPRODUCT(D3:AZ400+0,D3:AZ401)", Value::Error(CellError::Value)),
|
||||
("=COUNTBLANK(D3:AZ400)", Value::Number(19493.0)),
|
||||
("=SUMPRODUCT(--(D3:AZ400=\"\"))", Value::Number(19493.0)),
|
||||
("=SUM(D3:AZ400*B3:B400)", Value::Number(1530.0)),
|
||||
("=COLUMNS(TRANSPOSE(D3:AZ400))", Value::Number(398.0)),
|
||||
// Whole columns are still read up to the used range.
|
||||
("=SUMPRODUCT((A:A=1)*B:B)", Value::Number(6.0)),
|
||||
];
|
||||
for (i, (formula, want)) in cases.iter().enumerate() {
|
||||
let at = format!("C{}", 1001 + i);
|
||||
t.set(&at, formula);
|
||||
assert_eq!(&t.get(&at), want, "{formula}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_large_values_recalculate_dependents() {
|
||||
let mut t = T::new();
|
||||
@@ -1294,3 +1676,95 @@ fn median_large_values_recalculate_dependents() {
|
||||
assert!((t.num(cell) + 1.3).abs() < 1e-14);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_filename_names_the_workbook_file() {
|
||||
// Excel returns `<folder>/[<file>]<sheet>`, and the classic sheet-name formula splits it on
|
||||
// the `]`; an empty string broke that formula in every German workbook that used it (#1489).
|
||||
let dir = std::path::Path::new("books");
|
||||
let mut t = T::new();
|
||||
t.wb.sheet_mut(0).unwrap().name = "Info".into();
|
||||
|
||||
// No file yet: empty, as in a workbook Excel has never saved.
|
||||
t.set("A1", "1");
|
||||
t.set("B1", "=CELL(\"filename\",A1)");
|
||||
assert_eq!(t.get("B1"), Value::from(""));
|
||||
|
||||
t.wb.source_path = Some(dir.join("gc_cell_info_de.xlsx"));
|
||||
t.calc = Calc::new();
|
||||
let expect = dir.join("[gc_cell_info_de.xlsx]Info").to_string_lossy().into_owned();
|
||||
|
||||
t.set("B2", "=CELL(\"filename\",A1)");
|
||||
assert_eq!(t.get("B2"), Value::from(expect.as_str()));
|
||||
|
||||
// The two formulas from the report: the sheet name, and the test that the `]` is there.
|
||||
t.set("B3", "=MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)");
|
||||
assert_eq!(t.get("B3"), Value::from("Info"), "the sheet-name formula");
|
||||
t.set("B4", "=ISNUMBER(FIND(\"]\",CELL(\"filename\",A1)))");
|
||||
assert_eq!(t.get("B4"), Value::Bool(true));
|
||||
// The sheet is the one the reference is on, not the active one.
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Second")));
|
||||
t.set("B6", "=CELL(\"filename\",Second!A1)");
|
||||
assert_eq!(t.get("B6"), Value::from(dir.join("[gc_cell_info_de.xlsx]Second").to_string_lossy().into_owned().as_str()));
|
||||
|
||||
// A workbook that arrived as bytes knows its name but no folder: still splittable.
|
||||
t.wb.source_path = Some(std::path::PathBuf::from("Book.xlsx"));
|
||||
t.calc = Calc::new();
|
||||
t.set("B7", "=CELL(\"filename\",A1)");
|
||||
assert_eq!(t.get("B7"), Value::from("[Book.xlsx]Info"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_lifts_over_a_computed_array_but_reads_the_first_cell_of_a_range() {
|
||||
let mut t = T::new();
|
||||
t.set("B1", "0");
|
||||
t.set("B2", "23850");
|
||||
t.set("D2", "10%");
|
||||
t.set("D3", "12%");
|
||||
t.set("F1", "50000");
|
||||
t.set("H1", "=SUMPRODUCT(D2:D3-D1:D2,F1-B1:B2,N(F1>B1:B2))");
|
||||
assert!((t.num("H1") - 5523.0).abs() < 1e-9, "{:?}", t.get("H1"));
|
||||
t.set("H2", "=SUMPRODUCT(N(B1:B2>0))");
|
||||
assert_eq!(t.num("H2"), 1.0);
|
||||
// A spill of N(D2:D3) would also show 10% in H3; summing it tells the two apart.
|
||||
t.set("H3", "=SUM(N(D2:D3))");
|
||||
assert!((t.num("H3") - 0.1).abs() < 1e-12, "{:?}", t.get("H3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_array_of_one_value_still_anchors_a_spill() {
|
||||
// In Excel a dynamic-array formula anchors a spill range even while its result is a single
|
||||
// value, so `C1#` is the 1 x 1 range and ROWS/SUM/COUNT of it work. GridCraft gave #REF!
|
||||
// (and COUNT 0), so every formula counting a filtered list broke the moment the list had
|
||||
// exactly one entry (#1491). Values and cells follow the reported fixture.
|
||||
let mut t = T::new();
|
||||
for i in 1..=5 {
|
||||
t.set(&format!("A{i}"), &i.to_string());
|
||||
t.set(&format!("B{i}"), "7");
|
||||
}
|
||||
t.set("C1", "=FILTER(A1:A5,A1:A5>4)");
|
||||
t.set("C3", "=SORT(FILTER(A1:A5,A1:A5=3))");
|
||||
t.set("C5", "=UNIQUE(B1:B5)");
|
||||
// Control: the same FILTER matching two rows, which worked before.
|
||||
t.set("C9", "=FILTER(A1:A5,A1:A5>3)");
|
||||
|
||||
t.set("E1", "=ROWS(C1#)");
|
||||
t.set("E2", "=SUM(C1#)");
|
||||
t.set("E3", "=ROWS(C3#)");
|
||||
t.set("E5", "=COUNT(C5#)");
|
||||
t.set("E9", "=ROWS(C9#)");
|
||||
assert_eq!(t.get("E1"), Value::from(1.0));
|
||||
assert_eq!(t.get("E2"), Value::from(5.0));
|
||||
assert_eq!(t.get("E3"), Value::from(1.0));
|
||||
assert_eq!(t.get("E5"), Value::from(1.0));
|
||||
assert_eq!(t.get("E9"), Value::from(2.0), "a spilling array is unchanged");
|
||||
|
||||
// The anchors still show their first value, as they did.
|
||||
assert_eq!(t.get("C1"), Value::from(5.0));
|
||||
assert_eq!(t.get("C5"), Value::from(7.0));
|
||||
|
||||
// A formula whose result is a scalar never becomes an anchor: `#` on it is still #REF!.
|
||||
t.set("D1", "=1+1");
|
||||
t.set("E11", "=ROWS(D1#)");
|
||||
assert_eq!(t.get("E11"), Value::Error(CellError::Ref));
|
||||
}
|
||||
@@ -123,6 +123,11 @@ impl Measure for ApproxMeasure {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChartData {
|
||||
pub categories: Vec<String>,
|
||||
/// Outer levels of a multi-level category axis (a category range two columns wide beside a
|
||||
/// column of values: year beside month), nearest the labels first. One entry per category,
|
||||
/// non-empty where a group starts: `["2023", "", "", "2024", "", ""]`.
|
||||
#[serde(default)]
|
||||
pub category_levels: Vec<Vec<String>>,
|
||||
pub series: Vec<SeriesData>,
|
||||
/// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
|
||||
#[serde(default)]
|
||||
|
||||
+121
-20
@@ -3,7 +3,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind};
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};
|
||||
|
||||
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
|
||||
@@ -192,6 +192,7 @@ fn sanitize(d: &ChartData) -> ChartData {
|
||||
ChartData {
|
||||
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
|
||||
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
category_levels: d.category_levels.iter().take(3).map(|l| l.iter().take(MAX_POINTS).cloned().collect()).collect(),
|
||||
series: d
|
||||
.series
|
||||
.iter()
|
||||
@@ -391,10 +392,20 @@ fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos
|
||||
// ---------------------------------------------------------------- axes frame
|
||||
|
||||
enum CatAxis<'a> {
|
||||
Labels(&'a [String]),
|
||||
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
|
||||
/// one entry per category, non-empty where a group starts).
|
||||
Labels(&'a [String], &'a [Vec<String>]),
|
||||
Numeric(Scale, &'a Fmt),
|
||||
}
|
||||
|
||||
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
|
||||
/// non-empty entry (and at the first category).
|
||||
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
|
||||
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
|
||||
starts.push(n);
|
||||
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
|
||||
}
|
||||
|
||||
struct ValAxis<'a> {
|
||||
scale: Scale,
|
||||
fmt: &'a Fmt,
|
||||
@@ -420,7 +431,11 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
|
||||
_ => 4.0,
|
||||
};
|
||||
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 };
|
||||
let levels = match &cat {
|
||||
CatAxis::Labels(_, o) => o.len() as f32,
|
||||
CatAxis::Numeric(..) => 0.0,
|
||||
};
|
||||
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
|
||||
if !plot.ok() {
|
||||
return plot;
|
||||
}
|
||||
@@ -441,7 +456,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
|
||||
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
|
||||
match cat {
|
||||
CatAxis::Labels(l) => {
|
||||
CatAxis::Labels(l, outer) => {
|
||||
let n = l.len().max(1);
|
||||
let cw = plot.w / n as f32;
|
||||
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
||||
@@ -450,6 +465,22 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
}
|
||||
// Outer levels, one row each below the labels: a group's name centred under its
|
||||
// categories, and a separator at each group boundary down through the rows.
|
||||
for (i, level) in outer.iter().enumerate() {
|
||||
let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0);
|
||||
for (first, end) in level_groups(level, n) {
|
||||
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
|
||||
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
|
||||
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
|
||||
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
}
|
||||
if first > 0 {
|
||||
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
CatAxis::Numeric(s, f) => {
|
||||
let ticks = s.ticks();
|
||||
@@ -469,13 +500,17 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
}
|
||||
plot
|
||||
} else {
|
||||
let labels: Vec<String> = match cat {
|
||||
CatAxis::Labels(l) => l.to_vec(),
|
||||
CatAxis::Numeric(..) => vec![],
|
||||
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
|
||||
CatAxis::Labels(l, o) => (l.to_vec(), o),
|
||||
CatAxis::Numeric(..) => (vec![], &[]),
|
||||
};
|
||||
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
|
||||
// Outer levels stand in columns left of the labels, each as wide as its longest name.
|
||||
let level_w: Vec<f32> =
|
||||
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
|
||||
let levels_w: f32 = level_w.iter().sum();
|
||||
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
|
||||
let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 };
|
||||
let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };
|
||||
if !plot.ok() {
|
||||
return plot;
|
||||
}
|
||||
@@ -501,6 +536,24 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let t = ctx.fit(t, ls, lw - 6.0);
|
||||
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);
|
||||
}
|
||||
// Outer levels: a group's name centred beside its categories, separators at the
|
||||
// boundaries running right to the axis.
|
||||
let mut x1 = plot.x - lw;
|
||||
for (level, w) in outer.iter().zip(&level_w) {
|
||||
let x0 = x1 - w;
|
||||
for (first, end) in level_groups(level, n) {
|
||||
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
|
||||
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
|
||||
let t = ctx.fit(t, ls, w - 4.0);
|
||||
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);
|
||||
}
|
||||
if first > 0 {
|
||||
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
ctx.hline(x0, plot.x, plot.y, AXIS, 0.75);
|
||||
x1 = x0;
|
||||
}
|
||||
plot
|
||||
}
|
||||
}
|
||||
@@ -658,6 +711,13 @@ fn forecast(v: f64) -> f64 {
|
||||
if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Most empty category slots a forward forecast may add after `ncat` categories: four times the
|
||||
/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every
|
||||
/// column to nothing.
|
||||
fn max_forward_slots(ncat: usize) -> f64 {
|
||||
(ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64
|
||||
}
|
||||
|
||||
/// Longest forward forecast among `s`'s regression trendlines.
|
||||
fn max_forward(s: &SeriesData) -> f64 {
|
||||
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
|
||||
@@ -748,6 +808,7 @@ fn draw_trendlines(
|
||||
frac: &dyn Fn(f64, f64) -> (f64, f64),
|
||||
px: &dyn Fn((f64, f64)) -> [f32; 2],
|
||||
labels: &mut Vec<String>,
|
||||
window: (f64, f64),
|
||||
) {
|
||||
let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
|
||||
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
|
||||
@@ -756,7 +817,8 @@ fn draw_trendlines(
|
||||
TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
|
||||
_ => {
|
||||
let Some(fit) = fit_trendline(spec, pts) else { continue };
|
||||
let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward));
|
||||
// Sample only the part of the forecast that is on the plot.
|
||||
let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1));
|
||||
if spec.display_equation {
|
||||
labels.push(fit.equation());
|
||||
}
|
||||
@@ -936,9 +998,12 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
.filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k)))
|
||||
.map(|(_, s)| max_forward(s))
|
||||
.fold(0.0, f64::max);
|
||||
let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat));
|
||||
let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat));
|
||||
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines);
|
||||
// Outer levels padded to the slots (forecast slots stay in the last group).
|
||||
let outer: Vec<Vec<String>> =
|
||||
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1102,7 +1167,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
[plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]
|
||||
}
|
||||
};
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
}
|
||||
@@ -1196,7 +1261,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect();
|
||||
let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min));
|
||||
let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h];
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max));
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
}
|
||||
@@ -1236,7 +1301,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let maxc = counts.iter().copied().max().unwrap_or(0) as f64;
|
||||
let ys = nice_scale(0.0, maxc, true);
|
||||
let fc = Fmt::new("General");
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1288,7 +1353,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0));
|
||||
let ys = nice_scale(lo, hi, true);
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1561,12 +1626,16 @@ fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
|
||||
// ---------------------------------------------------------------- box & whisker
|
||||
|
||||
fn quantile(sorted: &[f64], q: f64) -> f64 {
|
||||
fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 {
|
||||
let n = sorted.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let pos = q * (n - 1) as f64;
|
||||
// Clamp exclusive ranks for short samples so one or two points remain drawable.
|
||||
let pos = match method {
|
||||
QuartileMethod::Inclusive => q * (n - 1) as f64,
|
||||
QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64),
|
||||
};
|
||||
let i = pos.floor() as usize;
|
||||
let frac = pos - i as f64;
|
||||
let a = sorted.get(i).copied().unwrap_or(0.0);
|
||||
@@ -1579,7 +1648,7 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
|
||||
let ys = nice_scale(lo, hi, false);
|
||||
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1593,7 +1662,8 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
continue;
|
||||
}
|
||||
v.sort_by(f64::total_cmp);
|
||||
let (q1, med, q3) = (quantile(&v, 0.25), quantile(&v, 0.5), quantile(&v, 0.75));
|
||||
let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default();
|
||||
let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method));
|
||||
let iqr = q3 - q1;
|
||||
let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
|
||||
let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1);
|
||||
@@ -1627,7 +1697,7 @@ fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
|
||||
let ys = nice_scale(lo, hi, false);
|
||||
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1769,3 +1839,34 @@ mod smooth_tests {
|
||||
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod quartile_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn box_whisker_default_quartiles_are_exclusive() {
|
||||
let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
|
||||
assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75);
|
||||
assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5);
|
||||
assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quartile_methods_and_short_samples() {
|
||||
for (values, exclusive, inclusive) in [
|
||||
(vec![1., 2., 3.], (1., 3.), (1.5, 2.5)),
|
||||
(vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)),
|
||||
(vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)),
|
||||
(vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)),
|
||||
(vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)),
|
||||
(vec![1., 2.], (1., 2.), (1.25, 1.75)),
|
||||
(vec![1.], (1., 1.), (1., 1.)),
|
||||
(vec![], (0., 0.), (0., 0.)),
|
||||
] {
|
||||
for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] {
|
||||
assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-10
@@ -1,7 +1,7 @@
|
||||
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
|
||||
|
||||
use gridcraft_calc::evaluate;
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, number_to_text};
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
|
||||
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
|
||||
use gridcraft_numfmt::{NumberFormat, format_value};
|
||||
|
||||
@@ -41,7 +41,7 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
|
||||
let (sh, addr) = match f.rfind('!') {
|
||||
Some(i) => {
|
||||
let name = f.get(..i)?.trim();
|
||||
let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string());
|
||||
let name = gridcraft_core::unquote_sheet(name);
|
||||
(wb.sheet_index(&name)?, f.get(i + 1..)?)
|
||||
}
|
||||
None => (sheet, f),
|
||||
@@ -133,12 +133,16 @@ fn value_text(v: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Category labels, formatted with each source cell's number format (dates, currency…).
|
||||
fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
|
||||
/// Category labels, formatted with each source cell's number format (dates, currency…), and
|
||||
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
|
||||
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
|
||||
/// has one entry per category, non-empty where a group starts, as the cells are.
|
||||
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let vals = eval_list(wb, sheet, formula);
|
||||
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
|
||||
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
|
||||
vals.iter()
|
||||
let texts: Vec<String> = vals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
if let (Some((s, r)), Value::Number(_)) = (src, v) {
|
||||
@@ -153,16 +157,42 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
|
||||
}
|
||||
value_text(v)
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
let Some((_, r)) = src else { return (texts, vec![]) };
|
||||
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
|
||||
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
|
||||
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
|
||||
if texts.len() != w.saturating_mul(h) {
|
||||
return (texts, vec![]); // truncated or not a plain block: a flat list
|
||||
}
|
||||
let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
|
||||
let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
|
||||
let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
|
||||
let outer = levels.outer as usize;
|
||||
if levels.by_columns {
|
||||
(column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect())
|
||||
} else {
|
||||
(row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is.
|
||||
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
|
||||
/// that sheet), the way the evaluator looks names up.
|
||||
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
|
||||
match t.split_once('!') {
|
||||
Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
None => wb.name(t, sheet).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated,
|
||||
/// a quoted or other bare literal is used as is.
|
||||
fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
|
||||
let t = name.trim();
|
||||
if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) {
|
||||
return q.replace("\"\"", "\"");
|
||||
}
|
||||
if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') {
|
||||
if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || is_defined_name(wb, sheet, t) {
|
||||
let vals = eval_list(wb, sheet, t);
|
||||
let parts: Vec<String> = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect();
|
||||
return parts.join(" ");
|
||||
@@ -197,6 +227,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
|
||||
let mut series = Vec::new();
|
||||
let mut categories: Option<Vec<String>> = None;
|
||||
let mut category_levels = Vec::new();
|
||||
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
|
||||
let values = nums(wb, sheet, &s.values);
|
||||
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
|
||||
@@ -211,9 +242,10 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
if categories.is_none()
|
||||
&& let Some(f) = &s.categories
|
||||
{
|
||||
let c = category_texts(wb, sheet, f);
|
||||
let (c, outer) = category_table(wb, sheet, f, &s.values);
|
||||
if !c.is_empty() {
|
||||
categories = Some(c);
|
||||
category_levels = outer;
|
||||
}
|
||||
}
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
|
||||
@@ -242,5 +274,5 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
|
||||
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
|
||||
ChartData { categories, series, palette }
|
||||
ChartData { categories, category_levels, series, palette }
|
||||
}
|
||||
+171
-13
@@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData {
|
||||
let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2);
|
||||
b.secondary = kind == ChartKind::Combo;
|
||||
ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: cats.iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
@@ -227,6 +228,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -238,6 +240,7 @@ fn resolve_from_workbook() {
|
||||
color: Some(Color::rgb(1, 2, 3)),
|
||||
secondary: true,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -249,6 +252,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -287,6 +291,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -298,6 +303,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: true,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -314,6 +320,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
@@ -331,6 +338,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
@@ -363,7 +371,8 @@ fn scatter_with_text_x_values_numbers_the_points() {
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].x, None, "{kind:?}");
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty =
|
||||
render(&c, &ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
|
||||
// Numeric X values are kept.
|
||||
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
|
||||
@@ -456,8 +465,12 @@ fn pie_starts_at_twelve_clockwise() {
|
||||
#[test]
|
||||
fn negative_values_put_axis_below_zero() {
|
||||
let c = chart(ChartKind::ColumnClustered);
|
||||
let d =
|
||||
ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)],
|
||||
};
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let t = texts(&prims);
|
||||
assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}");
|
||||
@@ -469,11 +482,13 @@ fn edge_cases_never_panic() {
|
||||
let m = ApproxMeasure;
|
||||
let empty = ChartData::default();
|
||||
let zeros = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into()],
|
||||
series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)],
|
||||
};
|
||||
let weird = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![String::new(); 3],
|
||||
series: vec![SeriesData {
|
||||
@@ -505,7 +520,12 @@ fn thousands_of_points_are_decimated() {
|
||||
let vals: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect();
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.data_labels = true;
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)],
|
||||
};
|
||||
let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure);
|
||||
let max_line = prims
|
||||
.iter()
|
||||
@@ -530,7 +550,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
|
||||
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = smooth;
|
||||
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats.clone(), series: vec![s] };
|
||||
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
@@ -547,7 +567,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
|
||||
// Rendering either way must stay finite and inside the canvas.
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats, series: vec![s] };
|
||||
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
|
||||
}
|
||||
|
||||
@@ -557,7 +577,7 @@ fn smooth_line_splits_at_gaps() {
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
s.values[1] = None; // gap between two data points
|
||||
let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
|
||||
// Two separate runs (before/after the gap) → at least two line primitives.
|
||||
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
|
||||
@@ -634,7 +654,12 @@ fn fuzz_random_data_and_sizes() {
|
||||
})
|
||||
.collect();
|
||||
let ncat = (r.next() % 50) as usize;
|
||||
let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(),
|
||||
series,
|
||||
};
|
||||
let mut c = chart(kind);
|
||||
c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize];
|
||||
c.data_labels = r.next().is_multiple_of(2);
|
||||
@@ -809,7 +834,8 @@ fn line_chart_draws_trendline_forecast_and_labels() {
|
||||
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.trendlines =
|
||||
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
|
||||
let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let d =
|
||||
ChartData { category_levels: vec![], palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
@@ -833,7 +859,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
|
||||
assert!(lines_of(&pie, tc).is_empty());
|
||||
// One point: nothing to fit, nothing drawn.
|
||||
s.values.truncate(1);
|
||||
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
|
||||
let one = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into()], series: vec![s] };
|
||||
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
|
||||
}
|
||||
|
||||
@@ -853,7 +879,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
||||
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
|
||||
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
|
||||
];
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![s] };
|
||||
let mut c = chart(ChartKind::Scatter);
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
@@ -868,6 +894,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
||||
#[test]
|
||||
fn treemap_and_histogram_shapes() {
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into(), "c".into(), "d".into()],
|
||||
series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)],
|
||||
@@ -889,7 +916,12 @@ fn treemap_and_histogram_shapes() {
|
||||
assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}");
|
||||
|
||||
let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)],
|
||||
};
|
||||
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
|
||||
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
|
||||
assert_eq!(bins, 10);
|
||||
@@ -898,7 +930,12 @@ fn treemap_and_histogram_shapes() {
|
||||
#[test]
|
||||
fn histogram_boundary_values_match_interval_labels() {
|
||||
let heights = |vals: &[f64]| -> Vec<f32> {
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)],
|
||||
};
|
||||
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
|
||||
prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect()
|
||||
};
|
||||
@@ -967,6 +1004,7 @@ fn box_and_stock_charts_honour_the_gridlines_flag() {
|
||||
#[test]
|
||||
fn funnel_value_labels_follow_the_data_labels_flag() {
|
||||
let data = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into(), "c".into()],
|
||||
series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)],
|
||||
@@ -1001,6 +1039,7 @@ fn pie_points_follow_workbook_theme() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
@@ -1057,3 +1096,122 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
assert!(first_label > last_poly, "{kind:?}: label drawn under a later area");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_level_categories_resolve_and_draw() {
|
||||
let years = ["2023", "", "", "2024", "", ""];
|
||||
let months = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"];
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "Sales".into();
|
||||
for (i, (y, m)) in years.iter().zip(months).enumerate() {
|
||||
// Year beside month beside the values, points down the rows…
|
||||
let r = i as u32 + 1;
|
||||
if !y.is_empty() {
|
||||
s.set_value(CellRef::new(r, 0), Value::from(*y));
|
||||
s.set_value(CellRef::new(10, i as u32), Value::from(*y));
|
||||
}
|
||||
s.set_value(CellRef::new(r, 1), Value::from(m));
|
||||
s.set_value(CellRef::new(r, 2), Value::Number(10.0 * (i as f64 + 1.0)));
|
||||
// …and the same table in rows, points along the columns.
|
||||
s.set_value(CellRef::new(11, i as u32), Value::from(m));
|
||||
s.set_value(CellRef::new(12, i as u32), Value::Number(10.0 * (i as f64 + 1.0)));
|
||||
}
|
||||
}
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.series = vec![Series { categories: Some("Sales!$A$2:$B$7".into()), values: "Sales!$C$2:$C$7".into(), ..Default::default() }];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.categories, months);
|
||||
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
|
||||
|
||||
let texts = |prims: &[Prim]| -> Vec<(String, f32, f32)> {
|
||||
prims.iter().filter_map(|p| if let Prim::Text { text, x, y, .. } = p { Some((text.clone(), *x, *y)) } else { None }).collect()
|
||||
};
|
||||
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 600.0, 300.0);
|
||||
let t = texts(&prims);
|
||||
let at = |t: &[(String, f32, f32)], s: &str| {
|
||||
t.iter().find(|(x, _, _)| x == s).map(|(_, x, y)| (*x, *y)).unwrap_or_else(|| panic!("no {s:?} in {t:?}"))
|
||||
};
|
||||
// Six evenly spaced month labels across the plot, not twelve slots for a two-column range.
|
||||
let xs: Vec<f32> = months.iter().map(|m| at(&t, m).0).collect();
|
||||
let step = xs[1] - xs[0];
|
||||
assert!(step > 40.0 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "{xs:?}");
|
||||
// The years sit in a row below the months, each under its own group.
|
||||
let (x23, y23) = at(&t, "2023");
|
||||
let (x24, y24) = at(&t, "2024");
|
||||
assert!(y23 > at(&t, "Oct").1 && (y23 - y24).abs() < 0.01, "{t:?}");
|
||||
assert!(x23 < at(&t, "Dec").0 && x24 > at(&t, "Dec").0 && x24 < at(&t, "Mar").0, "{t:?}");
|
||||
|
||||
// Bars: the levels stand in a column left of the labels.
|
||||
c.kind = ChartKind::BarClustered;
|
||||
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 600.0, 300.0);
|
||||
let t = texts(&prims);
|
||||
let (x23, y23) = at(&t, "2023");
|
||||
let (x_oct, y_oct) = at(&t, "Oct");
|
||||
// Categories run up the axis: the group's name sits left of its labels, between Oct and Dec.
|
||||
assert!(x23 < x_oct && y23 < y_oct && y23 > at(&t, "Dec").1, "{t:?}");
|
||||
let (x24, y24) = at(&t, "2024");
|
||||
assert!((x23 - x24).abs() < 0.01 && y24 < y23, "same column, 2024 above: {t:?}");
|
||||
|
||||
// A row of values: the levels are rows.
|
||||
c.series[0].categories = Some("Sales!$A$11:$F$12".into());
|
||||
c.series[0].values = "Sales!$A$13:$F$13".into();
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.categories, months);
|
||||
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
|
||||
|
||||
// One column of categories: a plain axis, as before.
|
||||
c.series[0].categories = Some("Sales!$B$2:$B$7".into());
|
||||
c.series[0].values = "Sales!$C$2:$C$7".into();
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.categories, months);
|
||||
assert!(d.category_levels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_trendline_forecast_does_not_collapse_the_columns() {
|
||||
let m = ApproxMeasure;
|
||||
let tc: Rgba = [1, 2, 3, 255];
|
||||
let col: Rgba = [10, 20, 30, 255];
|
||||
let mut s = series("Sales", &[1.0, 3.0, 2.0, 5.0, 4.0], col, ChartKind::ColumnClustered);
|
||||
s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1e6, backward: 1e6, ..Trendline::default() }, color: tc }];
|
||||
let d = ChartData { palette: vec![], category_levels: vec![], categories: ["a", "b", "c", "d", "e"].map(String::from).to_vec(), series: vec![s] };
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
assert_sane(&prims, 600.0, 400.0);
|
||||
let widths: Vec<f32> =
|
||||
prims.iter().filter_map(|p| if let Prim::Rect { w, fill: Some(f), .. } = p { (*f == col).then_some(*w) } else { None }).collect();
|
||||
assert_eq!(widths.len(), 5);
|
||||
assert!(widths.iter().all(|w| *w > 4.0), "columns stay visible: {widths:?}");
|
||||
let trend = lines_of(&prims, tc);
|
||||
assert_eq!(trend.len(), 1);
|
||||
assert!(trend[0].len() >= 20, "the trendline is sampled over the plot, not the forecast: {}", trend[0].len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_whisker_draws_each_series_quartile_method() {
|
||||
use gridcraft_model::QuartileMethod;
|
||||
let mut c = chart(ChartKind::BoxWhisker);
|
||||
c.legend = LegendPos::None;
|
||||
c.series = vec![Series::default(), Series { quartiles: QuartileMethod::Inclusive, ..Default::default() }];
|
||||
let color = [12, 34, 56, 255];
|
||||
let d = ChartData {
|
||||
series: vec![series("A", &[1., 2., 3., 4., 5., 6.], color, c.kind), series("B", &[1., 2., 3., 4., 5., 6.], color, c.kind)],
|
||||
..Default::default()
|
||||
};
|
||||
let prims = render(&c, &d, 640., 400., &ApproxMeasure);
|
||||
let heights: Vec<f32> = prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Rect { h, fill, .. } if *fill == Some(color) => Some(*h),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(heights.len(), 2);
|
||||
// The boxes span 3.5 and 2.5 data units on the same value axis.
|
||||
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
|
||||
}
|
||||
@@ -237,6 +237,59 @@ impl fmt::Display for RangeRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1`
|
||||
/// in any case.
|
||||
fn is_r1c1_token(name: &str) -> bool {
|
||||
let mut chars = name.chars().peekable();
|
||||
let mut letters = 0;
|
||||
for letter in ['r', 'c'] {
|
||||
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
|
||||
chars.next();
|
||||
letters += 1;
|
||||
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
letters > 0 && chars.next().is_none()
|
||||
}
|
||||
|
||||
/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or
|
||||
/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell within
|
||||
/// the sheet or as an R1C1 reference, or is a boolean.
|
||||
pub fn sheet_needs_quotes(name: &str) -> bool {
|
||||
let Some(first) = name.chars().next() else {
|
||||
return true;
|
||||
};
|
||||
if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) {
|
||||
return true;
|
||||
}
|
||||
CellRef::parse(name).is_some() || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE")
|
||||
}
|
||||
|
||||
/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it.
|
||||
pub fn quote_sheet(name: &str) -> String {
|
||||
if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// The inverse of [`quote_sheet`]: takes the quotes off a sheet name and reads `''` as `'`.
|
||||
/// Text that isn't quoted is returned as it is.
|
||||
pub fn unquote_sheet(text: &str) -> String {
|
||||
match text.trim().strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
|
||||
Some(inner) => inner.replace("''", "'"),
|
||||
None => text.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). Splits at the last `!`, so a quoted name may
|
||||
/// hold one (`'a!b'!A1`).
|
||||
pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
|
||||
match reference.rsplit_once('!') {
|
||||
Some((sheet, body)) => (Some(unquote_sheet(sheet)), body),
|
||||
None => (None, reference),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -253,6 +306,50 @@ mod tests {
|
||||
assert_eq!((r.start.row, r.end.row), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_quoting_follows_excel() {
|
||||
for quoted in [
|
||||
"A1",
|
||||
"XFD1",
|
||||
"AB1048576",
|
||||
"R1",
|
||||
"C1",
|
||||
"RC1",
|
||||
"R",
|
||||
"C",
|
||||
"RC",
|
||||
"R1C",
|
||||
"R1C1",
|
||||
"r2c3",
|
||||
"CR1",
|
||||
".x",
|
||||
"TRUE",
|
||||
"false",
|
||||
"1abc",
|
||||
"My Sheet",
|
||||
"Bob's",
|
||||
"a-b",
|
||||
"",
|
||||
] {
|
||||
assert!(sheet_needs_quotes(quoted), "{quoted:?} should be quoted");
|
||||
}
|
||||
for bare in ["Sheet1", "XFE1", "AB1048577", "Ärger", "日本", "Σ", "Sheet.1", "_x", "A1A", "Data", "RR", "CRX"] {
|
||||
assert!(!sheet_needs_quotes(bare), "{bare:?} should stay bare");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_unquote_and_split_sheet() {
|
||||
assert_eq!(quote_sheet("Bob's"), "'Bob''s'");
|
||||
assert_eq!(quote_sheet("Data"), "Data");
|
||||
assert_eq!(unquote_sheet("'Bob''s'"), "Bob's");
|
||||
assert_eq!(unquote_sheet("Data"), "Data");
|
||||
assert_eq!(unquote_sheet("'"), "'");
|
||||
assert_eq!(split_sheet("'a!b'!A1"), (Some("a!b".to_string()), "A1"));
|
||||
assert_eq!(split_sheet("'Bob''s'!$A$1:$B$2"), (Some("Bob's".to_string()), "$A$1:$B$2"));
|
||||
assert_eq!(split_sheet("A1"), (None, "A1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_roundtrip() {
|
||||
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
||||
@@ -312,3 +409,52 @@ mod tests {
|
||||
assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3));
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of a chart's category range beside its values: a plain list, or a multi-level
|
||||
/// axis (Excel: a category range two or more columns wide beside a column of values is a
|
||||
/// two-level axis, year beside month; one two or more rows tall above a row of values likewise).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CategoryLevels {
|
||||
/// The levels are columns and the points run down the rows (else the levels are rows and the
|
||||
/// points run along the columns).
|
||||
pub by_columns: bool,
|
||||
/// Outer levels beyond the innermost labels (the column or row nearest the values): 1 for a
|
||||
/// two-level axis. At most 3 are kept.
|
||||
pub outer: u32,
|
||||
}
|
||||
|
||||
/// How `categories` lays its labels out beside `values` (`None` when the values aren't a
|
||||
/// reference: the categories' own shape decides). `None` for a plain list of labels.
|
||||
pub fn category_levels(categories: &RangeRef, values: Option<&RangeRef>) -> Option<CategoryLevels> {
|
||||
if categories.is_full_rows() || categories.is_full_cols() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (categories.width(), categories.height());
|
||||
let vertical = match values {
|
||||
Some(v) => v.height() >= v.width(),
|
||||
None => h >= w,
|
||||
};
|
||||
let outer = if vertical { w.saturating_sub(1) } else { h.saturating_sub(1) };
|
||||
(outer > 0).then_some(CategoryLevels { by_columns: vertical, outer: outer.min(3) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod category_level_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() {
|
||||
let r = |s: &str| RangeRef::parse(s).unwrap();
|
||||
assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 }));
|
||||
assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped");
|
||||
assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list");
|
||||
// A row of values: levels are rows.
|
||||
assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None);
|
||||
// Literal values: the categories' own shape decides.
|
||||
assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Shared legacy indexed colours used by XLSX and number-format rendering.
|
||||
|
||||
/// The 64-entry legacy indexed colour palette, stored as `RRGGBB`.
|
||||
pub const INDEXED: [u32; 64] = [
|
||||
0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00,
|
||||
0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF,
|
||||
0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF,
|
||||
0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696,
|
||||
0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333,
|
||||
];
|
||||
|
||||
/// Resolve an Excel number-format color index (`Color1` through `Color56`).
|
||||
pub fn indexed_color(index: u8) -> Option<[u8; 3]> {
|
||||
if !(1..=56).contains(&index) {
|
||||
return None;
|
||||
}
|
||||
let slot = index.checked_add(7)?;
|
||||
let rgb = *INDEXED.get(slot as usize)?;
|
||||
Some([(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8])
|
||||
}
|
||||
@@ -4,10 +4,14 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod addr;
|
||||
pub mod color;
|
||||
pub mod date;
|
||||
pub mod parse;
|
||||
pub mod value;
|
||||
|
||||
pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col};
|
||||
pub use addr::{
|
||||
CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes,
|
||||
split_sheet, unquote_sheet,
|
||||
};
|
||||
pub use date::DateSystem;
|
||||
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
|
||||
+96
-12
@@ -49,14 +49,34 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
parse_numeric(s.trim(), sys).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
/// Text that Excel's "Number Stored as Text" check flags: it would be a number if typed (signs,
|
||||
/// thousands separators, currency, percent, exponent, a parenthesised negative). Dates, times and
|
||||
/// fractions kept as text are not flagged, as in Excel.
|
||||
pub fn number_stored_as_text(s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n),
|
||||
None => parse_plain(t).map(|(n, _)| n),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
|
||||
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
|
||||
let mut t = t;
|
||||
let mut neg = false;
|
||||
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
t = inner.trim();
|
||||
neg = !neg;
|
||||
}
|
||||
let (n, f) = parse_unparenthesised(t, sys)?;
|
||||
Some((if neg { -n } else { n }, f))
|
||||
}
|
||||
|
||||
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Parenthesised negatives: (100) = -100.
|
||||
if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f));
|
||||
}
|
||||
if let Some(r) = parse_plain(t) {
|
||||
return Some(r);
|
||||
}
|
||||
@@ -146,14 +166,28 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
Some((n, fmt))
|
||||
}
|
||||
|
||||
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
|
||||
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept.
|
||||
fn plain_number(s: &str, frac: bool) -> Option<f64> {
|
||||
let (int, dec) = match s.split_once('.') {
|
||||
Some((i, d)) if frac => (i, Some(d)),
|
||||
_ => (s, None),
|
||||
};
|
||||
let digits = |x: &str| !x.is_empty() && x.bytes().all(|c| c.is_ascii_digit());
|
||||
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
|
||||
fn parse_fraction(t: &str) -> Option<f64> {
|
||||
let (whole, frac) = t.split_once(' ')?;
|
||||
let neg = whole.starts_with('-');
|
||||
let w: f64 = whole.trim_start_matches('-').parse().ok()?;
|
||||
let w = plain_number(whole.trim_start_matches('-'), false)?;
|
||||
let (a, b) = frac.trim().split_once('/')?;
|
||||
let a: f64 = a.parse().ok()?;
|
||||
let b: f64 = b.parse().ok()?;
|
||||
let a = plain_number(a, false)?;
|
||||
let b = plain_number(b, false)?;
|
||||
if b == 0.0 || !w.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -184,12 +218,12 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
|
||||
if parts.len() == 1 && ampm.is_none() {
|
||||
return None;
|
||||
}
|
||||
if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) {
|
||||
if parts.len() > 3 || parts.iter().any(|p| p.is_empty() || p.starts_with('+')) {
|
||||
return None;
|
||||
}
|
||||
let mut h: f64 = parts.first()?.parse().ok()?;
|
||||
let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?;
|
||||
let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?;
|
||||
let mut h = plain_number(parts.first()?, false)?;
|
||||
let m = parts.get(1).map_or(Some(0.0), |p| plain_number(p, false))?;
|
||||
let s = parts.get(2).map_or(Some(0.0), |p| plain_number(p, true))?;
|
||||
if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 {
|
||||
return None;
|
||||
}
|
||||
@@ -211,7 +245,13 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
|
||||
|
||||
fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
|
||||
let t = t.trim().trim_end_matches(',');
|
||||
let num = |s: &str| s.trim().parse::<i64>().ok();
|
||||
let num = |s: &str| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<i64>().ok()
|
||||
};
|
||||
// ISO yyyy-mm-dd or yyyy/mm/dd
|
||||
let seps: &[char] = &['/', '-', '.'];
|
||||
let parts: Vec<&str> = t.split(seps).collect();
|
||||
@@ -326,6 +366,21 @@ mod tests {
|
||||
parse_input(s, DateSystem::D1900)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_date_parts_stay_text() {
|
||||
for s in ["+3/4/2026", "3/+4/2026", "3/4/+2026", "1/1/2020 +5:00"] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
}
|
||||
assert_eq!(p("3/4/2026").value, Value::Number(46085.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_name_short_year() {
|
||||
for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
|
||||
@@ -352,6 +407,24 @@ mod tests {
|
||||
assert_eq!(p(".5").value, Value::Number(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_parentheses_do_not_overflow() {
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
|
||||
let depth = 100_000;
|
||||
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
|
||||
assert_eq!(parse_number_text(&s), Some(1.0));
|
||||
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
|
||||
assert_eq!(parse_number_text(&odd), Some(-1.0));
|
||||
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
|
||||
assert_eq!(parse_number_text(&unbalanced), None);
|
||||
assert_eq!(p(&s).value, Value::Number(1.0));
|
||||
// Existing results are unchanged.
|
||||
assert_eq!(parse_number_text("(100)"), Some(-100.0));
|
||||
assert_eq!(parse_number_text("((100))"), Some(100.0));
|
||||
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
|
||||
assert_eq!(parse_number_text("()"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texts_and_specials() {
|
||||
assert_eq!(p("hello").value, Value::text("hello"));
|
||||
@@ -398,4 +471,15 @@ mod tests {
|
||||
assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
|
||||
assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_stored_as_text() {
|
||||
for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] {
|
||||
assert_eq!(number_stored_as_text(text), Some(n), "{text}");
|
||||
}
|
||||
// Dates, times, fractions, words and blanks are not flagged.
|
||||
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] {
|
||||
assert_eq!(number_stored_as_text(text), None, "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,6 +359,10 @@ pub fn number_to_text(n: f64) -> String {
|
||||
if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 {
|
||||
return sci_text(n, 15);
|
||||
}
|
||||
// A value just below 1e15 can round up to 1e15 at 15 significant digits.
|
||||
if format!("{:.14e}", a).parse::<f64>().is_ok_and(|r| r >= 1e15) {
|
||||
return sci_text(n, 15);
|
||||
}
|
||||
// 15 significant digits, trailing zeros trimmed.
|
||||
let mag = a.log10().floor() as i32;
|
||||
let decimals = (14 - mag).clamp(0, 30) as usize;
|
||||
@@ -402,6 +406,13 @@ mod tests {
|
||||
assert_eq!(number_to_text(1.0 / 3.0), "0.333333333333333");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_text_rounds_up_to_scientific_boundary() {
|
||||
assert_eq!(number_to_text(999999999999999.9), "1E+15");
|
||||
assert_eq!(number_to_text(-999999999999999.9), "-1E+15");
|
||||
assert_eq!(number_to_text(999999999999999.0), "999999999999999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coercion() {
|
||||
assert_eq!(Value::from("12").to_number(), Ok(12.0));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Times the fill-handle double-click scan against a densely-filled neighbour column: the old
|
||||
//! row-by-row `value_ref` loop from `grid.rs` vs. `Sheet::last_contiguous_nonempty`.
|
||||
//! `cargo run --release -p gridcraft-engine --example fill_handle_perf -- [rows]`
|
||||
|
||||
#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use serde_json::json;
|
||||
|
||||
fn main() {
|
||||
let rows: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1_000_000);
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let t = Instant::now();
|
||||
let chunk = 10_000u32;
|
||||
let mut r = 0u32;
|
||||
while r < rows {
|
||||
let n = chunk.min(rows - r);
|
||||
let vals: Vec<serde_json::Value> = (0..n).map(|i| json!([(r + i) as f64])).collect();
|
||||
s.execute("range.setValues", json!({"range": format!("A{}", r + 1), "values": vals})).expect("set");
|
||||
r += n;
|
||||
}
|
||||
println!("fill A1:A{rows}: {:?}", t.elapsed());
|
||||
|
||||
let d = s.doc().expect("doc");
|
||||
let sh = d.wb.active().expect("sheet");
|
||||
let neighbour = 0u32; // column A
|
||||
let cap = (rows - 1).min(999_999);
|
||||
|
||||
let t = Instant::now();
|
||||
let mut last = 0u32;
|
||||
let mut iters = 0u64;
|
||||
while last + 1 < gridcraft_engine::core::MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last < cap
|
||||
{
|
||||
last += 1;
|
||||
iters += 1;
|
||||
}
|
||||
let old = t.elapsed();
|
||||
println!("old row-by-row scan: {iters} iterations, {old:?} ({:.1} ns/iter)", old.as_nanos() as f64 / iters.max(1) as f64);
|
||||
|
||||
let t = Instant::now();
|
||||
let new_last = sh.last_contiguous_nonempty(neighbour, 1, cap);
|
||||
let new_t = t.elapsed();
|
||||
println!("new last_contiguous_nonempty: last={new_last}, {new_t:?} ({:.2}x faster)", old.as_secs_f64() / new_t.as_secs_f64().max(1e-12));
|
||||
|
||||
assert_eq!(last, new_last, "old and new scans must agree");
|
||||
}
|
||||
+194
-25
@@ -34,7 +34,43 @@ struct RuleStats {
|
||||
counts: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usize>) {
|
||||
/// Which statistics a rule reads. Scanning the rule's whole range for them costs O(range), so
|
||||
/// rules that read none (cell value, formula, text, blanks, errors, dates) never scan.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum StatsNeed {
|
||||
/// Sorted numbers, their mean and deviation (top/bottom N, above average, scales, bars, icons).
|
||||
Numbers,
|
||||
/// How often each displayed text occurs (duplicate / unique values).
|
||||
Counts,
|
||||
}
|
||||
|
||||
fn stats_need(rule: &CfRule) -> Option<StatsNeed> {
|
||||
match rule {
|
||||
CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts),
|
||||
CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => {
|
||||
Some(StatsNeed::Numbers)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl RuleStats {
|
||||
fn build(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> RuleStats {
|
||||
let (sorted, counts) = numbers_in(sh, ranges, need);
|
||||
let n = sorted.len().max(1) as f64;
|
||||
let avg = sorted.iter().sum::<f64>() / n;
|
||||
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
|
||||
RuleStats { sorted, avg, std, counts }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Cells `numbers_in` visited on this thread (tests count the work a lookup does).
|
||||
static SCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec<f64>, HashMap<String, usize>) {
|
||||
let mut nums = Vec::new();
|
||||
let mut counts = HashMap::new();
|
||||
for r in ranges {
|
||||
@@ -43,12 +79,20 @@ fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usi
|
||||
None => continue,
|
||||
};
|
||||
for c in r.iter().take(2_000_000) {
|
||||
#[cfg(test)]
|
||||
SCANNED.with(|n| n.set(n.get() + 1));
|
||||
let v = sh.value(c);
|
||||
if let Value::Number(n) = v {
|
||||
nums.push(n);
|
||||
}
|
||||
if !v.is_empty() {
|
||||
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
|
||||
match need {
|
||||
StatsNeed::Numbers => {
|
||||
if let Value::Number(n) = v {
|
||||
nums.push(n);
|
||||
}
|
||||
}
|
||||
StatsNeed::Counts => {
|
||||
if !v.is_empty() {
|
||||
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,19 +153,13 @@ impl CfCache {
|
||||
sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect();
|
||||
rules.sort_by_key(|(_, cf)| cf.priority);
|
||||
let v = sh.value(c);
|
||||
let no_stats = RuleStats::default();
|
||||
for (idx, cf) in rules {
|
||||
let first = cf.ranges.first().map(|r| r.start).unwrap_or(c);
|
||||
let st = self
|
||||
.stats
|
||||
.entry(idx)
|
||||
.or_insert_with(|| {
|
||||
let (sorted, counts) = numbers_in(sh, &cf.ranges);
|
||||
let n = sorted.len().max(1) as f64;
|
||||
let avg = sorted.iter().sum::<f64>() / n;
|
||||
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
|
||||
RuleStats { sorted, avg, std, counts }
|
||||
})
|
||||
.clone();
|
||||
let st: &RuleStats = match stats_need(&cf.rule) {
|
||||
Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
|
||||
None => &no_stats,
|
||||
};
|
||||
let text = v.display();
|
||||
let matched_style: Option<&Style> = match &cf.rule {
|
||||
CfRule::CellIs { op, a, b, style } => {
|
||||
@@ -147,10 +185,18 @@ impl CfCache {
|
||||
CfRule::Expression { formula, style } => {
|
||||
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::ContainsText { text: t, style } => text.to_lowercase().contains(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
CfRule::NotContainsText { text: t, style } => (!text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()),
|
||||
CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
// As in Excel, an error value never contains, begins or ends with anything (its rule
|
||||
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
|
||||
CfRule::ContainsText { text: t, style } => {
|
||||
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::NotContainsText { text: t, style } => {
|
||||
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::BeginsWith { text: t, style } => {
|
||||
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()),
|
||||
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
|
||||
@@ -218,7 +264,7 @@ impl CfCache {
|
||||
if let Some(n) = v.as_f64() {
|
||||
let pts: Vec<(f64, [u8; 3])> = stops
|
||||
.iter()
|
||||
.map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
|
||||
.map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
|
||||
.collect();
|
||||
let rgb = match pts.as_slice() {
|
||||
[(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }),
|
||||
@@ -240,8 +286,8 @@ impl CfCache {
|
||||
}
|
||||
CfRule::DataBar { min, max, color, gradient, show_value } => {
|
||||
if let Some(n) = v.as_f64() {
|
||||
let lo = threshold(wb, sheet, c, &st, min);
|
||||
let hi = threshold(wb, sheet, c, &st, max);
|
||||
let lo = threshold(wb, sheet, c, st, min);
|
||||
let hi = threshold(wb, sheet, c, st, max);
|
||||
let lo = if lo > 0.0 { 0.0 } else { lo };
|
||||
let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
if out.bar.is_none() {
|
||||
@@ -270,7 +316,7 @@ impl CfCache {
|
||||
defaults.get(1..).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect();
|
||||
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, st, t)).collect();
|
||||
let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
|
||||
if *reverse {
|
||||
idx = k - 1 - idx.min(k - 1);
|
||||
@@ -397,6 +443,105 @@ mod tests {
|
||||
assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
|
||||
}
|
||||
|
||||
/// The statistics as every rule used to get them: one pass collecting both parts.
|
||||
fn all_stats_before(sh: &Sheet, ranges: &[RangeRef]) -> RuleStats {
|
||||
let mut nums = Vec::new();
|
||||
let mut counts = HashMap::new();
|
||||
for r in ranges {
|
||||
let Some(r) = sh.used_range().and_then(|u| r.intersection(&u)) else { continue };
|
||||
for c in r.iter() {
|
||||
let v = sh.value(c);
|
||||
if let Value::Number(n) = v {
|
||||
nums.push(n);
|
||||
}
|
||||
if !v.is_empty() {
|
||||
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let n = nums.len().max(1) as f64;
|
||||
let avg = nums.iter().sum::<f64>() / n;
|
||||
let std = (nums.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
|
||||
RuleStats { sorted: nums, avg, std, counts }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_statistics_equal_the_full_ones() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[3, "x"], [7, "X"], [3, null], ["=NA()", 2.5], [" ", 9], [-1, "y"]]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
for ranges in ["A1:B6", "A1:A3", "B2:B9", "D1:E4", "A1:A2"].map(|r| vec![RangeRef::parse(r).unwrap()]) {
|
||||
let full = all_stats_before(sh, &ranges);
|
||||
let numbers = RuleStats::build(sh, &ranges, StatsNeed::Numbers);
|
||||
assert_eq!((numbers.sorted, numbers.avg.to_bits(), numbers.std.to_bits()), (full.sorted, full.avg.to_bits(), full.std.to_bits()));
|
||||
assert_eq!(RuleStats::build(sh, &ranges, StatsNeed::Counts).counts, full.counts);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_that_read_statistics_still_match() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, "a"], [2, "A"], [3, "b"], [10, "c"], [2, null]]})).unwrap();
|
||||
let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
|
||||
s.execute("home.conditionalFormat", rule("B1:B5", json!({"type": "duplicate", "preset": "lightRedFill"}))).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "top10", "rank": 1, "preset": "redBorder"}))).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "colorScale"}))).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap());
|
||||
let fill = |l: Option<CfLook>| l.and_then(|l| l.style).is_some_and(|st| st.fill.pattern == PatternType::Solid);
|
||||
// Duplicates are case-insensitive texts: "a" and "A".
|
||||
assert_eq!(["B1", "B2", "B3", "B4", "B5"].map(|a| fill(look(a))), [true, true, false, false, false]);
|
||||
// The top value gets the border, the scale runs from the minimum to the maximum.
|
||||
assert!(look("A4").and_then(|l| l.style).is_some_and(|st| !st.borders.top.is_none()));
|
||||
assert!(look("A1").and_then(|l| l.style).is_none_or(|st| st.borders.top.is_none()));
|
||||
assert_eq!(look("A1").and_then(|l| l.scale_fill), Some([0xF8, 0x69, 0x6B]));
|
||||
assert_eq!(look("A4").and_then(|l| l.scale_fill), Some([0x63, 0xBE, 0x7B]));
|
||||
}
|
||||
|
||||
/// Looking cells up does work in proportion to the cells asked for, not the rule's range: the
|
||||
/// statistics are only gathered for rules that read them, and once per rule.
|
||||
#[test]
|
||||
fn lookups_do_not_scan_the_range_for_rules_without_statistics() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let rows: Vec<serde_json::Value> = (1..=10_000).map(|i| json!([i, i % 7])).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "A1:B10000", "rule": {"type": "expression", "formula": "=MOD(ROW(),2)=0", "preset": "lightRedFill"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "A1:B10000", "rule": {"type": "cellIs", "operator": "greater", "value": "5", "preset": "redBorder"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:B10000", "rule": {"type": "noBlanks", "preset": "redBorder"}})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
SCANNED.with(|n| n.set(0));
|
||||
for r in 1..=50 {
|
||||
for col in ["A", "B"] {
|
||||
cache.look(&d.wb, 0, CellRef::parse(&format!("{col}{r}")).unwrap());
|
||||
}
|
||||
}
|
||||
assert_eq!(SCANNED.with(|n| n.get()), 0);
|
||||
// A rule that does read them scans its range once, however many cells are looked up.
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A10000", "rule": {"type": "top10", "preset": "redBorder"}})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
SCANNED.with(|n| n.set(0));
|
||||
for r in 1..=50 {
|
||||
cache.look(&d.wb, 0, CellRef::parse(&format!("A{r}")).unwrap());
|
||||
}
|
||||
assert_eq!(SCANNED.with(|n| n.get()), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_value_operator_names() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -410,4 +555,28 @@ mod tests {
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
|
||||
assert_eq!([look("A1"), look("A2")], [true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_rules_skip_error_values() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let values = json!([["=NA()", "=NA()", "=1/0", "=NA()"], ["abc", "#x", "x!", "abc"], ["xyz", "y", "y", "xyz"]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap();
|
||||
let rule = |range: &str, kind: &str, text: &str| json!({"range": range, "rule": {"type": kind, "text": text, "preset": "lightRedFill"}});
|
||||
s.execute("home.conditionalFormat", rule("A1:A3", "containsText", "a")).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("B1:B3", "beginsWith", "#")).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("C1:C3", "endsWith", "!")).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("D1:D3", "notContainsText", "a")).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
|
||||
// Contains "a": abc, never #N/A although its text holds an "a".
|
||||
assert_eq!([look("A1"), look("A2"), look("A3")], [false, true, false]);
|
||||
// Begins with "#": #x, never #N/A.
|
||||
assert_eq!([look("B1"), look("B2"), look("B3")], [false, true, false]);
|
||||
// Ends with "!": x!, never #DIV/0!.
|
||||
assert_eq!([look("C1"), look("C2"), look("C3")], [false, true, false]);
|
||||
// Does not contain "a": #N/A and xyz, not abc.
|
||||
assert_eq!([look("D1"), look("D2"), look("D3")], [true, false, true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Charts as inline SVG for Save as Web Page: the chart crate's drawing primitives, text included.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_chart::{HAlign, Prim, Rgba, VAlign};
|
||||
use gridcraft_model::{Chart, Workbook};
|
||||
|
||||
use crate::io::esc;
|
||||
|
||||
/// Widest or tallest chart written; larger anchors are clamped.
|
||||
const MAX_SIDE: f32 = 16_384.0;
|
||||
|
||||
struct HelveticaMeasure;
|
||||
|
||||
impl gridcraft_chart::Measure for HelveticaMeasure {
|
||||
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
|
||||
gridcraft_pdf::text_width(if bold { gridcraft_pdf::Font::HelveticaBold } else { gridcraft_pdf::Font::Helvetica }, size, text)
|
||||
}
|
||||
}
|
||||
|
||||
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate size.
|
||||
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
|
||||
let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE));
|
||||
if !(w >= 1.0 && h >= 1.0) {
|
||||
return None;
|
||||
}
|
||||
let data = gridcraft_chart::resolve(wb, sheet, chart);
|
||||
let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure);
|
||||
Some(prims_svg(&prims, w, h, style))
|
||||
}
|
||||
|
||||
fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let _ = write!(
|
||||
out,
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
|
||||
);
|
||||
for p in prims {
|
||||
match p {
|
||||
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
|
||||
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
|
||||
if *radius > 0.0 {
|
||||
let _ = write!(out, " rx=\"{}\"", num(*radius));
|
||||
}
|
||||
paint(&mut out, *fill, *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Line { pts, color, width, dash } => {
|
||||
let _ = write!(out, "<polyline points=\"{}\"", points(pts));
|
||||
paint(&mut out, None, Some((*color, *width)));
|
||||
if *dash {
|
||||
out.push_str(" stroke-dasharray=\"3 2\"");
|
||||
}
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Polygon { pts, fill, stroke } => {
|
||||
let _ = write!(out, "<polygon points=\"{}\"", points(pts));
|
||||
paint(&mut out, Some(*fill), *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
|
||||
let _ = write!(out, "<path d=\"{}\"", wedge_path(*cx, *cy, *r_outer, *r_inner, *a0, *a1));
|
||||
paint(&mut out, Some(*fill), *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Circle { cx, cy, r, fill, stroke } => {
|
||||
let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\"", num(*cx), num(*cy), num(*r));
|
||||
paint(&mut out, *fill, *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
|
||||
// Baseline offsets as in the PDF export.
|
||||
let dy = match valign {
|
||||
VAlign::Top => size * 0.78,
|
||||
VAlign::Middle => size * 0.35,
|
||||
VAlign::Bottom => -size * 0.21,
|
||||
};
|
||||
let anchor = match align {
|
||||
HAlign::Left => "start",
|
||||
HAlign::Center => "middle",
|
||||
HAlign::Right => "end",
|
||||
};
|
||||
let _ = write!(out, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" text-anchor=\"{anchor}\"", num(*x), num(y + dy), num(*size));
|
||||
if *bold {
|
||||
out.push_str(" font-weight=\"bold\"");
|
||||
}
|
||||
if rotation.is_finite() && *rotation != 0.0 {
|
||||
let _ = write!(out, " transform=\"rotate({} {} {})\"", num(rotation.to_degrees()), num(*x), num(*y));
|
||||
}
|
||||
let _ = write!(out, " fill=\"{}\"{}>{}</text>", hex(*color), opacity("fill", *color), esc(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("</svg>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Fill and stroke attributes of an open shape element; the caller closes it.
|
||||
fn paint(out: &mut String, fill: Option<Rgba>, stroke: Option<(Rgba, f32)>) {
|
||||
match fill {
|
||||
Some(c) => {
|
||||
let _ = write!(out, " fill=\"{}\"{}", hex(c), opacity("fill", c));
|
||||
}
|
||||
None => out.push_str(" fill=\"none\""),
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = write!(out, " stroke=\"{}\" stroke-width=\"{}\"{}", hex(c), num(w), opacity("stroke", c));
|
||||
}
|
||||
}
|
||||
|
||||
/// A pie or doughnut sector. Angles run clockwise from 12 o'clock, like the chart crate's.
|
||||
fn wedge_path(cx: f32, cy: f32, r_outer: f32, r_inner: f32, a0: f32, a1: f32) -> String {
|
||||
let span = (a1 - a0).clamp(-std::f32::consts::TAU + 0.001, std::f32::consts::TAU - 0.001);
|
||||
let a1 = a0 + span;
|
||||
let at = |a: f32, r: f32| format!("{} {}", num(cx + r * a.sin()), num(cy - r * a.cos()));
|
||||
let large = u8::from(span.abs() > std::f32::consts::PI);
|
||||
let sweep = u8::from(span >= 0.0);
|
||||
let mut d = format!("M{} A{r} {r} 0 {large} {sweep} {}", at(a0, r_outer), at(a1, r_outer), r = num(r_outer));
|
||||
if r_inner > 0.0 {
|
||||
let _ = write!(d, " L{} A{r} {r} 0 {large} {} {} Z", at(a1, r_inner), 1 - sweep, at(a0, r_inner), r = num(r_inner));
|
||||
} else {
|
||||
let _ = write!(d, " L{} {} Z", num(cx), num(cy));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn points(pts: &[[f32; 2]]) -> String {
|
||||
pts.iter().map(|p| format!("{},{}", num(p[0]), num(p[1]))).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn hex(c: Rgba) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
fn opacity(attr: &str, c: Rgba) -> String {
|
||||
if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) }
|
||||
}
|
||||
|
||||
/// A coordinate with two decimals; a non-finite value is 0 so the SVG stays well-formed.
|
||||
fn num(v: f32) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let s = format!("{v:.2}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_primitive_becomes_an_svg_element() {
|
||||
let red = [255, 0, 0, 255];
|
||||
let prims = vec![
|
||||
Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some(red), stroke: None, radius: 0.0 },
|
||||
Prim::Line { pts: vec![[0.0, 0.0], [5.0, 5.5]], color: red, width: 1.0, dash: true },
|
||||
Prim::Polygon { pts: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], fill: red, stroke: Some(([0, 0, 0, 255], 1.0)) },
|
||||
Prim::Wedge { cx: 10.0, cy: 10.0, r_outer: 5.0, r_inner: 2.0, a0: 0.0, a1: 1.0, fill: red, stroke: None },
|
||||
Prim::Circle { cx: 1.0, cy: 1.0, r: 2.0, fill: Some([0, 0, 255, 128]), stroke: None },
|
||||
Prim::Text {
|
||||
x: 5.0,
|
||||
y: 6.0,
|
||||
text: "A<B".into(),
|
||||
size: 10.0,
|
||||
color: red,
|
||||
bold: true,
|
||||
align: HAlign::Center,
|
||||
valign: VAlign::Top,
|
||||
rotation: -1.5,
|
||||
},
|
||||
];
|
||||
let svg = prims_svg(&prims, 100.0, 50.0, "left:1px");
|
||||
for part in [
|
||||
"<rect x=\"1\" y=\"2\" width=\"3\" height=\"4\" fill=\"#ff0000\"/>",
|
||||
"<polyline points=\"0,0 5,5.5\" fill=\"none\" stroke=\"#ff0000\" stroke-width=\"1\" stroke-dasharray=\"3 2\"/>",
|
||||
"<polygon ",
|
||||
"<path d=\"M10 5 A5 5 0 0 1 ",
|
||||
"fill-opacity=\"0.50\"",
|
||||
"text-anchor=\"middle\"",
|
||||
"font-weight=\"bold\"",
|
||||
"transform=\"rotate(-85.94 5 6)\"",
|
||||
">A<B</text>",
|
||||
] {
|
||||
assert!(svg.contains(part), "{part} in {svg}");
|
||||
}
|
||||
assert!(svg.starts_with("<svg ") && svg.ends_with("</svg>") && svg.contains("style=\"left:1px\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_numbers_do_not_break_the_markup() {
|
||||
let prims = [Prim::Circle { cx: f32::NAN, cy: f32::INFINITY, r: 1.0, fill: None, stroke: None }];
|
||||
assert!(prims_svg(&prims, 10.0, 10.0, "").contains("<circle cx=\"0\" cy=\"0\" r=\"1\" fill=\"none\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_circle_wedge_keeps_a_drawable_arc() {
|
||||
let d = wedge_path(0.0, 0.0, 10.0, 0.0, 0.0, std::f32::consts::TAU);
|
||||
assert!(d.contains("A10 10 0 1 1 ") && d.ends_with("L0 0 Z"), "{d}");
|
||||
}
|
||||
}
|
||||
+166
-42
@@ -11,6 +11,11 @@
|
||||
const MAX_HTML: usize = 16 * 1024 * 1024;
|
||||
/// Widest `colspan` honoured (the sheet's column count).
|
||||
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
|
||||
/// Tallest `rowspan` honoured (the sheet's row count).
|
||||
const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
|
||||
/// Most cells, span placeholders included, one paste reads from the HTML. Past it the rest of the
|
||||
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
|
||||
const MAX_HTML_CELLS: usize = 1_000_000;
|
||||
|
||||
/// One table cell: its visible text and the number the source recorded for it, if any.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -19,25 +24,28 @@ pub(crate) struct HtmlCell {
|
||||
pub num: Option<f64>,
|
||||
}
|
||||
|
||||
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
|
||||
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
|
||||
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
|
||||
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
|
||||
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
|
||||
/// `max_cols` cells (the pasted text's shape, itself clamped to the sheet) and [`MAX_HTML_CELLS`]
|
||||
/// in all. A cell spanning columns is followed by empty cells, and a column covered by a `rowspan`
|
||||
/// from above holds an empty cell, so positions line up with the tab-separated text. Empty when
|
||||
/// there is no table.
|
||||
pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<Vec<HtmlCell>> {
|
||||
let mut grid = Grid::new(max_rows, max_cols);
|
||||
if html.len() > MAX_HTML {
|
||||
return rows;
|
||||
return grid.rows;
|
||||
}
|
||||
let mut cell: Option<(HtmlCell, usize)> = None;
|
||||
let mut cell: Option<OpenCell> = None;
|
||||
let mut in_table = false;
|
||||
let mut rest = html;
|
||||
while !rest.is_empty() {
|
||||
while !rest.is_empty() && !grid.full {
|
||||
let Some(lt) = rest.find('<') else {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest);
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push_str(rest);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest.get(..lt).unwrap_or_default());
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push_str(rest.get(..lt).unwrap_or_default());
|
||||
}
|
||||
rest = rest.get(lt..).unwrap_or_default();
|
||||
if let Some(after) = rest.strip_prefix("<!--") {
|
||||
@@ -65,49 +73,131 @@ pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
|
||||
in_table = true;
|
||||
}
|
||||
("table", true) => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if !rows.is_empty() {
|
||||
grid.finish(&mut cell);
|
||||
if !grid.rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
in_table = false;
|
||||
}
|
||||
("tr", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
rows.push(Vec::new());
|
||||
grid.finish(&mut cell);
|
||||
grid.start_row();
|
||||
}
|
||||
("td" | "th", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
grid.finish(&mut cell);
|
||||
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
|
||||
cell = Some((HtmlCell { text: String::new(), num }, span));
|
||||
let colspan = span(attrs, "colspan", MAX_SPAN);
|
||||
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
|
||||
}
|
||||
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
|
||||
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
|
||||
("br", _) => {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push(' ');
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push(' ');
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
finish(&mut rows, &mut cell);
|
||||
rows
|
||||
grid.finish(&mut cell);
|
||||
grid.rows
|
||||
}
|
||||
|
||||
/// Closes the open cell (if any) into the last row, decoding its text.
|
||||
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
|
||||
let Some((mut c, span)) = cell.take() else { return };
|
||||
c.text = decode(&c.text);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
/// A `colspan`/`rowspan` attribute: at least 1, at most `max` (the sheet's extent), so a hostile
|
||||
/// "999999999999" is clamped before anything is expanded.
|
||||
fn span(attrs: &str, name: &str, max: usize) -> usize {
|
||||
attr(attrs, name).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, max)
|
||||
}
|
||||
|
||||
/// A cell whose closing tag hasn't been seen yet.
|
||||
struct OpenCell {
|
||||
cell: HtmlCell,
|
||||
colspan: usize,
|
||||
rowspan: usize,
|
||||
}
|
||||
|
||||
/// The table being read, with its size limits.
|
||||
struct Grid {
|
||||
rows: Vec<Vec<HtmlCell>>,
|
||||
max_rows: usize,
|
||||
max_cols: usize,
|
||||
/// Cells still allowed before the table is cut short.
|
||||
budget: usize,
|
||||
/// Per column: the row index (exclusive) down to which a `rowspan` from above covers it.
|
||||
covered_until: Vec<usize>,
|
||||
/// The row or cell budget ran out: nothing more is read.
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn new(max_rows: usize, max_cols: usize) -> Self {
|
||||
let max_rows = max_rows.min(MAX_ROW_SPAN);
|
||||
let max_cols = max_cols.min(MAX_SPAN);
|
||||
let budget = MAX_HTML_CELLS.min(max_rows.saturating_mul(max_cols));
|
||||
Grid { rows: Vec::new(), max_rows, max_cols, budget, covered_until: Vec::new(), full: false }
|
||||
}
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push(c);
|
||||
let room = MAX_SPAN.saturating_sub(row.len());
|
||||
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
|
||||
|
||||
/// Opens a new row, or marks the grid full when the pasted text has no more rows.
|
||||
fn start_row(&mut self) {
|
||||
if self.rows.len() >= self.max_rows {
|
||||
self.full = true;
|
||||
} else {
|
||||
self.rows.push(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one cell to the current row; false when the row or the cell budget is used up.
|
||||
fn push(&mut self, cell: HtmlCell) -> bool {
|
||||
let Some(row) = self.rows.last_mut() else { return false };
|
||||
if row.len() >= self.max_cols {
|
||||
return false;
|
||||
}
|
||||
let Some(left) = self.budget.checked_sub(1) else {
|
||||
self.full = true;
|
||||
return false;
|
||||
};
|
||||
self.budget = left;
|
||||
row.push(cell);
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the open cell (if any) into the current row, decoding its text: first past the
|
||||
/// columns a `rowspan` above still covers, then the cell, then a placeholder per extra column.
|
||||
fn finish(&mut self, open: &mut Option<OpenCell>) {
|
||||
let Some(OpenCell { mut cell, colspan, rowspan }) = open.take() else { return };
|
||||
if self.rows.is_empty() {
|
||||
self.start_row();
|
||||
}
|
||||
let Some(r) = self.rows.len().checked_sub(1) else { return };
|
||||
while self.covered_until.get(self.col()).is_some_and(|&until| until > r) {
|
||||
if !self.push(HtmlCell::default()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let start = self.col();
|
||||
cell.text = decode(&cell.text);
|
||||
if !self.push(cell) {
|
||||
return;
|
||||
}
|
||||
for _ in 1..colspan {
|
||||
if !self.push(HtmlCell::default()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if rowspan > 1 {
|
||||
let (end, until) = (self.col(), r.saturating_add(rowspan));
|
||||
if self.covered_until.len() < end {
|
||||
self.covered_until.resize(end, 0);
|
||||
}
|
||||
for u in self.covered_until.get_mut(start..end).into_iter().flatten() {
|
||||
*u = (*u).max(until);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The column the next cell of the current row lands in.
|
||||
fn col(&self) -> usize {
|
||||
self.rows.last().map_or(0, Vec::len)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +366,7 @@ mod tests {
|
||||
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
|
||||
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
|
||||
<tr><td colspan=2 x:num="2.5">2.50 & up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
|
||||
let rows = table_cells(excel);
|
||||
let rows = table_cells(excel, 2, 3);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
|
||||
@@ -286,10 +376,10 @@ mod tests {
|
||||
|
||||
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
|
||||
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
|
||||
assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
|
||||
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
|
||||
assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -306,10 +396,44 @@ mod tests {
|
||||
"<style>",
|
||||
"<table><table><td>",
|
||||
] {
|
||||
let _ = table_cells(html);
|
||||
let _ = table_cells(html, MAX_ROW_SPAN, MAX_SPAN);
|
||||
}
|
||||
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
|
||||
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
|
||||
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>", 1, 1)[0][0].num, None);
|
||||
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>", 1, usize::MAX)[0].len() <= MAX_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_spans_stay_within_the_cell_budget() {
|
||||
// #2171: 100,000 rows of `colspan=16384` once expanded to ~1.6 billion cells (OOM). Even
|
||||
// with the sheet's full extent as the limit, at most MAX_HTML_CELLS are materialized.
|
||||
let row = "<tr><td colspan=16384 rowspan=99999999999 x:num='1'>1</td><td colspan=4294967296>2</td></tr>";
|
||||
let html = format!("<table>{}</table>", row.repeat(100_000));
|
||||
let rows = table_cells(&html, usize::MAX, usize::MAX);
|
||||
let cells: usize = rows.iter().map(Vec::len).sum();
|
||||
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
|
||||
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
|
||||
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
|
||||
|
||||
// Bounded by the pasted text's shape, the same markup yields just that shape.
|
||||
let rows = table_cells(&html, 3, 2);
|
||||
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [2, 2, 2]);
|
||||
assert_eq!(rows[0][0].num, Some(1.0));
|
||||
assert!(table_cells(&html, 0, 5).is_empty());
|
||||
assert!(table_cells(&html, 4, 0).iter().all(Vec::is_empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rowspan_keeps_later_rows_in_their_columns() {
|
||||
// A merged A1:A2 is one `rowspan=2` cell; row 2 omits it, and its text has an empty field there.
|
||||
let html = r#"<table><tr><td rowspan=2 colspan=2>Merged</td><td x:num="1">1</td></tr>
|
||||
<tr><td x:num="2">2</td></tr><tr><td x:num="3">3</td><td>c</td></tr></table>"#;
|
||||
let rows = table_cells(html, 3, 3);
|
||||
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [3, 3, 2]);
|
||||
assert_eq!(rows[0][2].num, Some(1.0));
|
||||
assert_eq!((rows[1][0].clone(), rows[1][1].clone()), (HtmlCell::default(), HtmlCell::default()));
|
||||
assert_eq!(rows[1][2].num, Some(2.0));
|
||||
assert_eq!(rows[2][0].num, Some(3.0), "the span ends after its second row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -782,15 +782,24 @@ fn forecast_grid(x: &[f64], f: &[Option<f64>], se_window: usize, p: &Json) -> Gr
|
||||
g.put(idx(i), 0, fi.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
if flag(p, "standardErrors") {
|
||||
// A running sum of squared residuals over the last `se_window` points: O(n), not
|
||||
// O(n·window). A point without a forecast restarts it, so every window touching it is #N/A.
|
||||
let sq = |j: usize| Some((x.get(j)? - (*f.get(j)?)?).powi(2));
|
||||
let (mut acc, mut run) = (0.0, 0usize);
|
||||
for i in 0..f.len() {
|
||||
let start = (i + 1).checked_sub(se_window);
|
||||
let v = start.and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
match sq(i) {
|
||||
Some(t) => {
|
||||
acc += t;
|
||||
run += 1;
|
||||
}
|
||||
Some((acc / se_window as f64).sqrt())
|
||||
});
|
||||
None => (acc, run) = (0.0, 0),
|
||||
}
|
||||
if run > se_window {
|
||||
acc -= i.checked_sub(se_window).and_then(sq).unwrap_or(0.0);
|
||||
run = se_window;
|
||||
}
|
||||
// Rounding in the subtraction can leave a tiny negative sum.
|
||||
let v = (run == se_window).then(|| (acc.max(0.0) / se_window as f64).sqrt());
|
||||
g.put(idx(i), 1, v.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
}
|
||||
@@ -1190,6 +1199,37 @@ mod tests {
|
||||
x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_standard_errors_match_the_full_window_rescan() {
|
||||
// #1043: the running sum must give what re-adding every window gave, gaps included.
|
||||
let x: [f64; 11] = [2.0, 4.0, 5.0, 4.0, 5.0, 7.0, 1.0, 3.0, 8.0, 6.0, 2.0];
|
||||
let f = [None, Some(3.0), Some(4.5), None, Some(4.0), Some(6.5), Some(2.0), Some(2.5), Some(7.0), Some(5.0), Some(4.0)];
|
||||
let rescan = |i: usize, w: usize| -> Value {
|
||||
let v = (i + 1).checked_sub(w).and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
}
|
||||
Some((acc / w as f64).sqrt())
|
||||
});
|
||||
v.map(Value::number).unwrap_or(Value::Error(gridcraft_core::CellError::NA))
|
||||
};
|
||||
for w in 1..=5 {
|
||||
let g = super::forecast_grid(&x, &f, w, &json!({"standardErrors": true}));
|
||||
for i in 0..f.len() {
|
||||
let (got, want) = (g.cells.get(&(i as u32, 1)).cloned().unwrap_or(Value::Empty), rescan(i, w));
|
||||
match (got.as_f64(), want.as_f64()) {
|
||||
(Some(a), Some(b)) => assert!(close(a, b, 1e-12), "window {w}, row {i}: {a} vs {b}"),
|
||||
_ => assert_eq!(got, want, "window {w}, row {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Row 3 has no forecast: windows through it are #N/A, the next full window after it is not.
|
||||
let g = super::forecast_grid(&x, &f, 3, &json!({"standardErrors": true}));
|
||||
assert_eq!(g.cells.get(&(5, 1)), Some(&Value::Error(gridcraft_core::CellError::NA)));
|
||||
assert!(g.cells.get(&(6, 1)).and_then(Value::as_f64).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_statistics_of_one_to_five() {
|
||||
// 1..5: mean 3, s = √2.5, kurtosis −1.2, skewness 0 (textbook values).
|
||||
|
||||
+400
-53
@@ -80,13 +80,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Group",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Right"),
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default)}",
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
||||
has_doc,
|
||||
|s, p| group(s, p, true)
|
||||
),
|
||||
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols}", has_doc, |s, p| group(
|
||||
s, p, false
|
||||
)),
|
||||
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
|
||||
group(s, p, false)
|
||||
}),
|
||||
cmd!(
|
||||
"data.hideDetail",
|
||||
"Hide Detail",
|
||||
@@ -147,6 +147,23 @@ fn data_range(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
Ok(current_region(sh, d.selection.active))
|
||||
}
|
||||
|
||||
/// The sheet and range a data command works on: the sheet `sheet` or a `Sheet!` prefix on `range`
|
||||
/// names, else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
|
||||
fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
if let Some(t) = str_param(p, "range")
|
||||
&& let (Some(name), _) = split_sheet(t)
|
||||
&& d.wb.sheet_index(&name) != Some(sheet)
|
||||
{
|
||||
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
if sheet != d.wb.active_sheet && p.get("range").is_none() {
|
||||
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
|
||||
}
|
||||
Ok((sheet, data_range(s, p)?))
|
||||
}
|
||||
|
||||
/// Excel's header guess: the first row is text over a column of numbers, or is bold.
|
||||
fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool {
|
||||
if let Some(t) = sh.table_at(r.start) {
|
||||
@@ -182,19 +199,36 @@ struct SortKey {
|
||||
list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names,
|
||||
/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
|
||||
fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
if let Some(t) = str_param(p, "range")
|
||||
&& let (Some(name), _) = split_sheet(t)
|
||||
&& d.wb.sheet_index(&name) != Some(sheet)
|
||||
{
|
||||
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
if sheet != d.wb.active_sheet && p.get("range").is_none() {
|
||||
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
|
||||
}
|
||||
Ok((sheet, data_range(s, p)?))
|
||||
}
|
||||
|
||||
fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = sort_target(s, p)?;
|
||||
let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col);
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let header = guess_header(&d.wb, sh, r);
|
||||
do_sort(s, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
|
||||
do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
|
||||
}
|
||||
|
||||
fn sort(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = sort_target(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
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);
|
||||
@@ -228,10 +262,12 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if keys.len() > 64 {
|
||||
return Err(bad("data.sort", "too many sort levels (64 max)"));
|
||||
}
|
||||
do_sort(s, r, header, keys, by_cols, case)
|
||||
do_sort(s, sheet, r, header, keys, by_cols, case)
|
||||
}
|
||||
|
||||
fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
|
||||
fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
|
||||
// Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here.
|
||||
super::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?;
|
||||
let body = if by_cols {
|
||||
if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r }
|
||||
} else if header && r.height() > 1 {
|
||||
@@ -242,7 +278,6 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
if body.count() > 20_000_000 {
|
||||
return Err(bad("data.sort", "range too large"));
|
||||
}
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) };
|
||||
if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) {
|
||||
@@ -295,6 +330,21 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
}
|
||||
// Move whole lines (cells with formulas adjusted for relative references).
|
||||
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
|
||||
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
|
||||
// describe — the way cell pictures and row heights do. Collect them before inserting,
|
||||
// so a swap can't resurrect one from a destination that is also a source.
|
||||
let side: Vec<(CellRef, Comment)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
|
||||
.collect();
|
||||
let links: Vec<(CellRef, Hyperlink)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
|
||||
.collect();
|
||||
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
|
||||
let mut pictures = Vec::new();
|
||||
for (dst_i, &src_i) in order.iter().enumerate() {
|
||||
@@ -354,6 +404,18 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every cell of the body is the destination of exactly one source cell, so clearing the
|
||||
// body and inserting the permuted entries is the same permutation the cells went through.
|
||||
for c in body.iter() {
|
||||
shm.comments.remove(&c);
|
||||
shm.hyperlinks.remove(&c);
|
||||
}
|
||||
for (at, cm) in side {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (i, h) in heights.into_iter().enumerate() {
|
||||
let row = body.start.row + i as u32;
|
||||
@@ -437,10 +499,12 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
|
||||
// ---------------------------------------------------------------- filter
|
||||
|
||||
fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let (sheet, r) = data_target(s, p, "data.filter")?;
|
||||
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
|
||||
let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start };
|
||||
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
|
||||
let in_table =
|
||||
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone()));
|
||||
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
|
||||
if let Some(name) = in_table {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -473,8 +537,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
/// 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)?;
|
||||
let (sheet, mut r) = data_target(s, p, "data.filter")?;
|
||||
// A selected header row filters the data below it (#201).
|
||||
if p.get("range").is_none() && r.height() == 1 {
|
||||
let d = s.doc()?;
|
||||
@@ -492,6 +555,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let af = match &sh.autofilter {
|
||||
Some(a) => a.clone(),
|
||||
@@ -583,6 +647,11 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result.
|
||||
fn filter_blank(v: &Value) -> bool {
|
||||
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
|
||||
}
|
||||
|
||||
/// Hides rows of the autofilter range that fail any criterion.
|
||||
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(sh) = wb.sheet(sheet) else { return };
|
||||
@@ -616,7 +685,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let ok = match crit {
|
||||
FilterCriterion::Values { values, blanks } => {
|
||||
if v.is_empty() {
|
||||
if filter_blank(&v) {
|
||||
*blanks
|
||||
} else {
|
||||
values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))
|
||||
@@ -703,10 +772,11 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
|
||||
for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {
|
||||
let c = CellRef::new(row, col);
|
||||
let v = sh.value(c);
|
||||
let t = if v.is_empty() { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
|
||||
let blank = filter_blank(&v);
|
||||
let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
|
||||
let on = match selected {
|
||||
Some(FilterCriterion::Values { values, blanks }) => {
|
||||
if v.is_empty() {
|
||||
if blank {
|
||||
*blanks
|
||||
} else {
|
||||
values.iter().any(|x| x.eq_ignore_ascii_case(&t))
|
||||
@@ -714,7 +784,8 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
seen.entry((sort_key(&v), t)).or_insert(on);
|
||||
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
|
||||
seen.entry((key, t)).or_insert(on);
|
||||
}
|
||||
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
}
|
||||
@@ -738,9 +809,9 @@ fn sort_key(v: &Value) -> (u8, i64, String) {
|
||||
// ---------------------------------------------------------------- tools
|
||||
|
||||
fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Rows and columns past the data are blank: only the part with data is compared (whole
|
||||
// columns compared a million blank rows and reported them as removed duplicates).
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
|
||||
@@ -750,7 +821,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
|
||||
.unwrap_or_else(|| (r.start.col..=r.end.col).collect());
|
||||
let sheet = d.wb.active_sheet;
|
||||
// With no column to compare, every row key is empty: the first row would survive and every
|
||||
// other row would be deleted as a duplicate. A column outside the range compares data the
|
||||
// user didn't select. Excel refuses both.
|
||||
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
|
||||
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
|
||||
}
|
||||
let start = r.start.row + header as u32;
|
||||
let mut removed = 0;
|
||||
edit(s, |cx| {
|
||||
@@ -776,6 +852,24 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
|
||||
})
|
||||
.collect();
|
||||
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
|
||||
// describe them move with them; the ones on removed rows are dropped with the row.
|
||||
let notes: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
|
||||
})
|
||||
.collect();
|
||||
let rows: Vec<Vec<Option<Cell>>> =
|
||||
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
@@ -784,6 +878,13 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
shm.remove_cell(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
for row in start..=r.end.row {
|
||||
for c in r.start.col..=r.end.col {
|
||||
let at = CellRef::new(row, c);
|
||||
shm.comments.remove(&at);
|
||||
shm.hyperlinks.remove(&at);
|
||||
}
|
||||
}
|
||||
for (i, cells) in rows.into_iter().enumerate() {
|
||||
for (j, cell) in cells.into_iter().enumerate() {
|
||||
if let Some(cell) = cell {
|
||||
@@ -792,6 +893,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (at, cm) in notes {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
@@ -826,7 +933,10 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(w) => {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut cuts = vec![0];
|
||||
cuts.extend(w.iter().copied().filter(|x| *x < chars.len()));
|
||||
let mut breaks: Vec<usize> = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect();
|
||||
breaks.sort_unstable();
|
||||
breaks.dedup();
|
||||
cuts.extend(breaks);
|
||||
cuts.push(chars.len());
|
||||
cuts.windows(2)
|
||||
.map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::<String>().trim().to_string()).unwrap_or_default())
|
||||
@@ -951,13 +1061,43 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"time" => ValidationKind::Time,
|
||||
"textLength" => ValidationKind::TextLength,
|
||||
"custom" => ValidationKind::Custom,
|
||||
_ => ValidationKind::Any,
|
||||
"any" => ValidationKind::Any,
|
||||
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
|
||||
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
|
||||
};
|
||||
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
|
||||
return Err(bad("data.validation", "a list validation needs `formula1`"));
|
||||
}
|
||||
const OPERATORS: [&str; 12] = [
|
||||
"between",
|
||||
"notBetween",
|
||||
"equal",
|
||||
"notEqual",
|
||||
"greater",
|
||||
"greaterThan",
|
||||
"less",
|
||||
"lessThan",
|
||||
"greaterOrEqual",
|
||||
"greaterThanOrEqual",
|
||||
"lessOrEqual",
|
||||
"lessThanOrEqual",
|
||||
];
|
||||
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
|
||||
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
|
||||
}
|
||||
let error_style = match str_param(p, "errorStyle") {
|
||||
None | Some("stop") => ErrorStyle::Stop,
|
||||
Some("warning") => ErrorStyle::Warning,
|
||||
Some("information") => ErrorStyle::Information,
|
||||
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
|
||||
};
|
||||
let f1 = str_param(p, "formula1").unwrap_or("");
|
||||
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
|
||||
let dv = Validation {
|
||||
ranges: ranges.clone(),
|
||||
kind,
|
||||
op: op_param(str_param(p, "operator")),
|
||||
f1: str_param(p, "formula1").unwrap_or("").trim_start_matches('=').to_string(),
|
||||
f1,
|
||||
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
|
||||
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
|
||||
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
|
||||
@@ -966,11 +1106,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
show_input: true,
|
||||
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
|
||||
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
|
||||
error_style: match str_param(p, "errorStyle") {
|
||||
Some("warning") => ErrorStyle::Warning,
|
||||
Some("information") => ErrorStyle::Information,
|
||||
_ => ErrorStyle::Stop,
|
||||
},
|
||||
error_style,
|
||||
show_error: true,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
@@ -981,14 +1117,36 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
|
||||
/// than a reference or a name to evaluate.
|
||||
fn is_literal_list(f: &str) -> bool {
|
||||
f.starts_with('"')
|
||||
|| (!f.contains('!')
|
||||
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
|
||||
}
|
||||
|
||||
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
|
||||
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
|
||||
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
|
||||
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
|
||||
fn list_source(f: &str) -> String {
|
||||
let t = f.trim();
|
||||
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
|
||||
return f.trim_start_matches('=').to_string();
|
||||
}
|
||||
format!("\"{}\"", t.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// List entries of a list validation (literal or from a range).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
|
||||
let f = dv.f1.trim();
|
||||
if f.starts_with('"')
|
||||
|| (!f.contains('!')
|
||||
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
|
||||
{
|
||||
return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
if is_literal_list(f) {
|
||||
// A quoted list is a string literal: `""` inside stands for one `"`.
|
||||
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
|
||||
Some(body) => body.replace("\"\"", "\""),
|
||||
None => f.trim_matches('"').to_string(),
|
||||
};
|
||||
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
}
|
||||
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
|
||||
match gridcraft_calc::evaluate(wb, sheet, at, f) {
|
||||
@@ -1129,10 +1287,10 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
// ---------------------------------------------------------------- outline
|
||||
|
||||
fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
if let Some(r) = str_param(p, "rows").and_then(RangeRef::parse) {
|
||||
if let Some(r) = str_param(p, "rows").and_then(parse_lines) {
|
||||
return Ok((true, r.start.row, r.end.row));
|
||||
}
|
||||
if let Some(r) = str_param(p, "cols").and_then(RangeRef::parse) {
|
||||
if let Some(r) = str_param(p, "cols").and_then(parse_lines) {
|
||||
return Ok((false, r.start.col, r.end.col));
|
||||
}
|
||||
let r = s.doc()?.selection.current();
|
||||
@@ -1145,9 +1303,13 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
// The selection belongs to the active sheet, so another sheet needs its lines.
|
||||
if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() {
|
||||
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
|
||||
}
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let b = b.min(a.saturating_add(1_048_575));
|
||||
@@ -1257,7 +1419,8 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = data_target(s, p, "data.subtotal")?;
|
||||
let header = bool_param(p, "header").unwrap_or(true);
|
||||
let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col);
|
||||
let func = str_param(p, "function").unwrap_or("sum");
|
||||
let code = match func {
|
||||
@@ -1281,13 +1444,13 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"product" => "Product",
|
||||
_ => "Total",
|
||||
};
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
// Group boundaries (header in the first row).
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let mut groups: Vec<(u32, u32, String)> = Vec::new();
|
||||
let mut start = r.start.row + 1;
|
||||
for row in r.start.row + 1..=r.end.row + 1 {
|
||||
let first = r.start.row + u32::from(header);
|
||||
let mut start = first;
|
||||
for row in first..=r.end.row + 1 {
|
||||
let cur = sh.value(CellRef::new(row, group_col)).display();
|
||||
let prev = sh.value(CellRef::new(start, group_col)).display();
|
||||
if row > r.end.row || cur != prev {
|
||||
@@ -1298,25 +1461,28 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Insert a total row after each group, bottom-up so indices stay valid.
|
||||
let mut inserted = 0;
|
||||
for (g0, g1, name) in groups.iter().rev() {
|
||||
s.execute("home.insertRows", json!({"rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
|
||||
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
|
||||
let row = g1 + 1;
|
||||
let lbl = CellRef::new(row, group_col).a1();
|
||||
s.execute("cell.set", json!({"cell": lbl, "input": format!("{name} {label}")}))?;
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?;
|
||||
for c in &cols {
|
||||
let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1();
|
||||
s.execute("cell.set", json!({"cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
}
|
||||
s.execute("home.bold", json!({"range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}))?;
|
||||
s.execute("data.group", json!({"rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
|
||||
s.execute(
|
||||
"home.bold",
|
||||
json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}),
|
||||
)?;
|
||||
s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
|
||||
inserted += 1;
|
||||
}
|
||||
let last = r.end.row + inserted + 1;
|
||||
let lbl = CellRef::new(last, group_col).a1();
|
||||
s.execute("home.insertRows", json!({"rows": format!("{}:{}", last + 1, last + 1)}))?;
|
||||
s.execute("cell.set", json!({"cell": lbl, "input": format!("Grand {label}")}))?;
|
||||
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?;
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?;
|
||||
for c in &cols {
|
||||
let range = RangeRef::new(CellRef::new(r.start.row + 1, *c), CellRef::new(last - 1, *c)).a1();
|
||||
s.execute("cell.set", json!({"cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1();
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
}
|
||||
Ok(json!({"groups": inserted}))
|
||||
}
|
||||
@@ -1325,6 +1491,37 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_blanks_include_empty_text_and_formula_results() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:B5"})).unwrap();
|
||||
let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap();
|
||||
assert_eq!(r["hiddenRows"], json!(2));
|
||||
let vals = filter_values(&s.doc().unwrap().wb, 0, 1);
|
||||
// The `=""` result joins (Blanks) instead of showing as its own empty entry.
|
||||
assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}");
|
||||
let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect();
|
||||
assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_honours_header_false() {
|
||||
// #131: with header:false the first data row was treated as a header and left out of its
|
||||
// group's subtotal and the grand total.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap();
|
||||
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!("East"));
|
||||
assert_eq!(get(&mut s, "A3")["text"], json!("East Total"));
|
||||
assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows");
|
||||
assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total"));
|
||||
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
@@ -1357,6 +1554,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_validation_without_formula1_is_an_error() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
|
||||
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
|
||||
s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
|
||||
assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
|
||||
let mut s = Session::new();
|
||||
@@ -1530,10 +1738,149 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_row_and_column_parameters() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "B"})).unwrap();
|
||||
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
|
||||
assert_eq!(v(&mut s, "C3"), json!(4.0));
|
||||
s.execute("home.hideRows", json!({"rows": "1"})).unwrap();
|
||||
s.execute("data.group", json!({"cols": "C"})).unwrap();
|
||||
assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err());
|
||||
s.execute("home.deleteRows", json!({"rows": "2"})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap();
|
||||
assert_eq!(v(&mut s, "B2"), json!(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() {
|
||||
let cases: [(Json, [&str; 4]); 5] = [
|
||||
(json!([6, 3]), ["abc", "def", "ghij", ""]),
|
||||
(json!([3, 3]), ["abc", "defghij", "", ""]),
|
||||
(json!([0]), ["abcdefghij", "", "", ""]),
|
||||
(json!([20]), ["abcdefghij", "", "", ""]),
|
||||
(json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]),
|
||||
];
|
||||
for (widths, want) in cases {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap();
|
||||
s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let got: Vec<String> = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect();
|
||||
assert_eq!(got, want, "{widths}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split() {
|
||||
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
|
||||
assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]);
|
||||
assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_without_a_valid_column_is_refused() {
|
||||
// `columns: []` (or only unusable names) used to compare nothing and delete every row
|
||||
// but the first.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
|
||||
for columns in [json!([]), json!(["ZZZ"])] {
|
||||
let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err();
|
||||
assert!(err.to_string().contains("columns"), "{err}");
|
||||
}
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
|
||||
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
|
||||
// In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
|
||||
// References, names and formulas stay unquoted, and every rule survives a save and reopen.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
|
||||
let rules = [
|
||||
("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
|
||||
("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
|
||||
("C1", " 1,2,3 ", r#""1,2,3""#),
|
||||
("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
|
||||
("E1", "=$K$1:$K$3", "$K$1:$K$3"),
|
||||
("F1", "$K$1:$K$3", "$K$1:$K$3"),
|
||||
("G1", "=Colors", "Colors"),
|
||||
("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
|
||||
("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
|
||||
];
|
||||
for (cell, source, _) in rules {
|
||||
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
|
||||
}
|
||||
let accepted = [
|
||||
("A1", "In progress"),
|
||||
("B1", "Closed"),
|
||||
("C1", "2"),
|
||||
("D1", r#"Say "hi""#),
|
||||
("E1", "Green"),
|
||||
("F1", "Blue"),
|
||||
("G1", "Red"),
|
||||
("H1", "Green"),
|
||||
];
|
||||
let check = |wb: &Workbook| {
|
||||
for (cell, input) in accepted {
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
|
||||
assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
|
||||
}
|
||||
};
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
check(&wb);
|
||||
|
||||
let sheet_xml = |wb: &Workbook| {
|
||||
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
};
|
||||
let xml = sheet_xml(&wb);
|
||||
for (cell, _, saved) in rules {
|
||||
assert!(xml.contains(&format!(r#"sqref="{cell}"><formula1>{saved}</formula1>"#)), "{cell}: {saved} missing from {xml}");
|
||||
}
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
|
||||
check(&reopened);
|
||||
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
|
||||
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
|
||||
// with no error.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for params in [
|
||||
json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
|
||||
json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
|
||||
json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
|
||||
json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
|
||||
] {
|
||||
let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown"), "{params}: {e}");
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
|
||||
for params in [
|
||||
json!({"range": "A1", "type": "any"}),
|
||||
json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
|
||||
json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
|
||||
] {
|
||||
s.execute("data.validation", params).unwrap();
|
||||
}
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert_eq!(sh.validations.len(), 3);
|
||||
assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
|
||||
}
|
||||
}
|
||||
+199
-27
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoFill",
|
||||
[],
|
||||
None,
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
|
||||
has_doc,
|
||||
auto_fill
|
||||
),
|
||||
@@ -138,18 +138,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
|
||||
if is_formula {
|
||||
let body = input.strip_prefix('=').unwrap_or(input);
|
||||
// Excel closes missing parentheses for you.
|
||||
let mut text = body.to_string();
|
||||
let mut parsed = gridcraft_formula::parse(&text);
|
||||
for _ in 0..8 {
|
||||
if parsed.is_ok() {
|
||||
break;
|
||||
}
|
||||
text.push(')');
|
||||
parsed = gridcraft_formula::parse(&text);
|
||||
}
|
||||
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
|
||||
let expr = wb.name_call_case(expr);
|
||||
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
||||
check_arg_counts(&expr)?;
|
||||
let mut cell = Cell::formula(Formula::from_expr(expr));
|
||||
cell.style = style;
|
||||
@@ -176,7 +165,17 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
}
|
||||
return Ok(Some(cell));
|
||||
}
|
||||
let parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
|
||||
let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
|
||||
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
|
||||
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
|
||||
if parsed.format.is_none()
|
||||
&& !input.contains('%')
|
||||
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
|
||||
&& let Value::Number(n) = parsed.value
|
||||
&& n.abs() >= 1.0
|
||||
{
|
||||
parsed.value = Value::Number(n / 100.0);
|
||||
}
|
||||
let mut style = style;
|
||||
if let Some(code) = parsed.format {
|
||||
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
|
||||
@@ -191,11 +190,19 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style }))
|
||||
}
|
||||
|
||||
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
|
||||
/// and refuses a formula that still doesn't parse.
|
||||
pub(crate) fn parse_typed_formula(body: &str) -> Result<gridcraft_formula::Expr> {
|
||||
// Excel closes missing parentheses for you.
|
||||
let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse);
|
||||
parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))
|
||||
}
|
||||
|
||||
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
|
||||
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
|
||||
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
|
||||
/// unknown functions are skipped.
|
||||
fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
|
||||
pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
|
||||
let mut bad = None;
|
||||
expr.walk(&mut |e| {
|
||||
if bad.is_none()
|
||||
@@ -373,6 +380,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
|
||||
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
||||
let Some(p) = sheet.protection.as_ref() else { return true };
|
||||
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
|
||||
}
|
||||
|
||||
fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if p.get("sheet").is_some() {
|
||||
let i = target_sheet(s, p)?;
|
||||
@@ -390,6 +403,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
|
||||
if let Some(sh) = d.wb.active() {
|
||||
sel.expand_merges(sh);
|
||||
if !cell_selectable(&d.wb, sh, sel.active) {
|
||||
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
|
||||
}
|
||||
}
|
||||
d.selection = sel;
|
||||
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
|
||||
@@ -458,6 +474,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
to = m.start;
|
||||
}
|
||||
if !extend && !cell_selectable(&d.wb, sheet, to) {
|
||||
// Step on to the next selectable cell in the same direction; stay put if there is none.
|
||||
let (sr, sc) = (dr.signum(), dc.signum());
|
||||
let mut t = to;
|
||||
let mut found = false;
|
||||
if !jmp && (sr, sc) != (0, 0) {
|
||||
for _ in 0..10_000 {
|
||||
let n = t.offset_clamped(sr, sc);
|
||||
if n == t {
|
||||
break;
|
||||
}
|
||||
t = n;
|
||||
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
|
||||
}
|
||||
to = t;
|
||||
}
|
||||
if extend {
|
||||
sel.extend_to(to);
|
||||
sel.expand_merges(sheet);
|
||||
@@ -525,7 +564,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else if let Some((si, ti)) = d.wb.table(reference) {
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
|
||||
format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t)
|
||||
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
|
||||
} else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
}
|
||||
@@ -612,12 +651,13 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let now = d.wb.clone();
|
||||
for _ in 0..steps {
|
||||
let Some(e) = d.undo.pop() else { break };
|
||||
let cur_wb = d.wb.clone();
|
||||
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
|
||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: e.selection.clone() };
|
||||
d.redo.push(cur);
|
||||
let cur_selection = e.selection.clone();
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
@@ -632,11 +672,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let now = d.wb.clone();
|
||||
for _ in 0..steps {
|
||||
let Some(e) = d.redo.pop() else { break };
|
||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
||||
d.undo.push(cur);
|
||||
let cur_wb = d.wb.clone();
|
||||
let cur_selection = d.selection.clone();
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
@@ -805,10 +846,11 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
|
||||
let rows: Vec<Vec<String>> = parse_tsv(text);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
|
||||
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
|
||||
let h = rows.len();
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
|
||||
// only as far as the text's own shape, so spans in the markup can't expand without bound.
|
||||
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
for (ci, v) in row.iter().enumerate() {
|
||||
@@ -948,6 +990,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src_wb = clip.wb.clone();
|
||||
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
|
||||
let src_sheet_name = src_sheet.name.clone();
|
||||
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
|
||||
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
|
||||
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Styles from another workbook must be re-interned.
|
||||
let mut style_map = std::collections::HashMap::new();
|
||||
@@ -964,6 +1010,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let source = cx.sheet_mut(clip.sheet)?;
|
||||
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
|
||||
let taken = source.take_cells(src);
|
||||
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
|
||||
// with the block: the destination is written from the clipboard snapshot taken
|
||||
// before the cut, so removing them here loses nothing and leaves the old place
|
||||
// clean instead of leaving one merged block and one note behind.
|
||||
source.comments.retain(|c, _| !src.contains(*c));
|
||||
source.hyperlinks.retain(|c, _| !src.contains(*c));
|
||||
source.merges.retain(|m| !src.contains_range(m));
|
||||
for (c, _) in &taken {
|
||||
cx.touch(clip.sheet, *c);
|
||||
}
|
||||
@@ -1011,11 +1064,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// The array range moves with its formula.
|
||||
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
||||
moved.dynamic = f.dynamic;
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
let moved =
|
||||
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula::from_expr(moved)))
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
@@ -1142,6 +1196,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
@@ -1226,12 +1283,13 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
{
|
||||
let mut nf = Formula::from_expr(new);
|
||||
nf.array = array;
|
||||
nf.dynamic = f.dynamic;
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in wb.names.iter_mut() {
|
||||
if let Ok(expr) = gridcraft_formula::parse(&n.formula) {
|
||||
if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) {
|
||||
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
|
||||
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
|
||||
n.formula = gridcraft_formula::print(&new);
|
||||
@@ -1344,7 +1402,7 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
|
||||
None => return Ok(Json::Null),
|
||||
}
|
||||
}
|
||||
if r.count() > 5_000_000 {
|
||||
if r.count() > crate::fill::MAX_FILL_CELLS {
|
||||
return Err(bad("edit.fill", "range too large"));
|
||||
}
|
||||
// With one row selected, Fill Down copies from the row above (Excel behaviour).
|
||||
@@ -1378,11 +1436,18 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"alternate" => crate::fill::FillMode::Alternate,
|
||||
"formats" => crate::fill::FillMode::Formats,
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
"extend" => crate::fill::FillMode::Extend,
|
||||
_ => crate::fill::FillMode::Series,
|
||||
};
|
||||
if target.count() > 5_000_000 || src.count() > 5_000_000 {
|
||||
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
||||
return Err(bad("edit.autoFill", "range too large"));
|
||||
}
|
||||
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
|
||||
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
|
||||
// into whole columns (`A:A`).
|
||||
if !crate::fill::holds_source_in_corner(src, target) {
|
||||
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
crate::fill::fill(cx, sheet, src, target, mode)?;
|
||||
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
|
||||
@@ -1567,10 +1632,24 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
|
||||
/// byte positions in the folded text still line up with the original's character boundaries.
|
||||
fn folds_in_place(s: &str) -> bool {
|
||||
s.chars().all(|c| {
|
||||
let mut folded = c.to_lowercase();
|
||||
let first = folded.next();
|
||||
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_ci(text: &str, what: &str, with: &str) -> String {
|
||||
let lower = text.to_lowercase();
|
||||
let w = what.to_lowercase();
|
||||
if lower.len() != text.len() || w.is_empty() {
|
||||
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
|
||||
// boundary: a length check alone misses foldings that keep the total length while shifting
|
||||
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
|
||||
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
|
||||
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
|
||||
return text.replace(what, with);
|
||||
}
|
||||
let mut out = String::new();
|
||||
@@ -1595,6 +1674,63 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
|
||||
let (h, w) = (src.height(), src.width());
|
||||
let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
|
||||
// Whole rows or columns take a line format, as the ribbon's formatting commands give
|
||||
// them, instead of a style on each of their cells (a column alone has a million). The
|
||||
// line format comes from the first cell of the source pattern across the line; cells
|
||||
// already stored follow the pattern. Row heights and column widths stay as they are.
|
||||
let rows = dest.is_full_rows() && !dest.is_full_cols();
|
||||
let cols = dest.is_full_cols() && !dest.is_full_rows();
|
||||
let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
|
||||
if rows || cols {
|
||||
let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
|
||||
let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
|
||||
for line in lines {
|
||||
let sl = src_first + (line - first) % n;
|
||||
// The source cell for position `k` along the line.
|
||||
let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
|
||||
let line_style = if src_full {
|
||||
if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
|
||||
} else {
|
||||
Some(src_sheet.style_id(at(0)))
|
||||
};
|
||||
let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
|
||||
let (dest_line, src_line) = if rows {
|
||||
(RangeRef::rows(line, line), RangeRef::rows(sl, sl))
|
||||
} else {
|
||||
(RangeRef::cols(line, line), RangeRef::cols(sl, sl))
|
||||
};
|
||||
let mut cells: Vec<CellRef> =
|
||||
cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
|
||||
if src_full {
|
||||
cells.extend(
|
||||
src_sheet
|
||||
.cells
|
||||
.iter_range(src_line)
|
||||
.map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
|
||||
);
|
||||
}
|
||||
if cols {
|
||||
// A row format outranks a column format: the column's cells in formatted
|
||||
// rows need a style of their own.
|
||||
cells.extend(
|
||||
cx.wb
|
||||
.sheet(dest_sheet)
|
||||
.into_iter()
|
||||
.flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
|
||||
);
|
||||
}
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
(if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
|
||||
for c in cells {
|
||||
let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
|
||||
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
|
||||
cx.sheet_mut(dest_sheet)?.set_style(c, st);
|
||||
cx.touch(dest_sheet, c);
|
||||
}
|
||||
}
|
||||
*cx.sel = crate::Selection::range(dest);
|
||||
return Ok(());
|
||||
}
|
||||
for c in dest.iter().take(2_000_000) {
|
||||
let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
|
||||
let st = src_sheet.style_id(sc);
|
||||
@@ -1713,8 +1849,44 @@ mod tests {
|
||||
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_paste_that_overflows_the_grid_moves_nothing() {
|
||||
// #997: the source was emptied first and the cells that fell off the grid were skipped.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
||||
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
||||
// Not transposed, the second row would be past the last row.
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
||||
// Not transposed it still fits in column XFD.
|
||||
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
|
||||
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
|
||||
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
|
||||
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
|
||||
// no longer line up with the original, and the old slicing dropped the tail.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let text = "\u{212A}\u{0130}\u{0130}";
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
|
||||
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rotate",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: shape, id, flip?: horizontal|vertical} (swaps width/height for 90° turns)",
|
||||
"{kind: shape, id} (swaps width/height for a 90° turn; `flip` is rejected: shapes have no flip state)",
|
||||
has_doc,
|
||||
rotate
|
||||
),
|
||||
@@ -351,7 +351,7 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
|
||||
/// Element ▸ Trendline choices, plus the Format Trendline options).
|
||||
fn trendline_param(p: &Json) -> Result<Trendline> {
|
||||
let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
|
||||
let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0));
|
||||
let mut t = Trendline {
|
||||
forward: num("forward"),
|
||||
backward: num("backward"),
|
||||
@@ -662,6 +662,9 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if p.get("flip").is_some_and(|f| !f.is_null()) {
|
||||
return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state"));
|
||||
}
|
||||
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
|
||||
}
|
||||
|
||||
@@ -829,6 +832,9 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
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();
|
||||
if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) {
|
||||
return Err(EngineError::Other("A table can't overlap another table.".into()));
|
||||
}
|
||||
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
|
||||
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
|
||||
return Err(EngineError::Other(
|
||||
|
||||
@@ -58,6 +58,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
|
||||
let (mut wb, warnings) = crate::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?;
|
||||
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
|
||||
// What `CELL("filename")` reports (#1489). A workbook opened from bytes knows its name but
|
||||
// never a folder, so it reports `[name]Sheet` with none; an import reports the file it came
|
||||
// from, as Excel does, even though Save must still ask where to put the .xlsx.
|
||||
wb.source_path = match str_param(p, "path") {
|
||||
Some(path) => crate::io::source_path(path),
|
||||
None => Some(std::path::PathBuf::from(&name)),
|
||||
};
|
||||
let imported =
|
||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||
@@ -124,6 +131,13 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
|
||||
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
|
||||
// The new file is the workbook's own now, so `CELL("filename")` reports it (#1489). Set
|
||||
// before the `saved` snapshot, or the workbook would read as dirty the moment it was
|
||||
// saved; and only when it changes, so an ordinary Save does not clone the workbook.
|
||||
let file = crate::io::source_path(&path);
|
||||
if d.wb.source_path != file {
|
||||
std::sync::Arc::make_mut(&mut d.wb).source_path = file;
|
||||
}
|
||||
d.saved = d.wb.clone();
|
||||
d.path = Some(path.clone());
|
||||
}
|
||||
@@ -141,8 +155,9 @@ fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let fmt = str_param(p, "format").unwrap_or("xlsx");
|
||||
let d = s.doc()?;
|
||||
let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?;
|
||||
let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
|
||||
// The file name goes into a PivotChart's link to its PivotTable.
|
||||
let bytes = crate::io::save_bytes(&d.wb, &name)?;
|
||||
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -411,6 +411,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
|
||||
+175
-50
@@ -133,9 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
number_format
|
||||
),
|
||||
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(
|
||||
s, p, ACCOUNTING
|
||||
)),
|
||||
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, accounting),
|
||||
cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")),
|
||||
cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)),
|
||||
cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)),
|
||||
@@ -156,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Row Height…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
|
||||
"{sheet?, rows?: \"2:5\" | \"2:2,4:5\" | \"Sheet2!2:5\", height: 20 (pixels) | points: 15}",
|
||||
has_doc,
|
||||
row_height
|
||||
),
|
||||
@@ -165,28 +163,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Column Width…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
|
||||
"{sheet?, cols?: \"B:D\" | \"B:B,D:F\" | \"Sheet2!B:D\", width: 64 (pixels) | chars: 8.43}",
|
||||
has_doc,
|
||||
col_width
|
||||
),
|
||||
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows),
|
||||
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols),
|
||||
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width),
|
||||
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide(
|
||||
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{sheet?, rows?}", has_doc, autofit_rows),
|
||||
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{sheet?, cols?}", has_doc, autofit_cols),
|
||||
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{sheet?, width: 64 (pixels)}", has_doc, default_width),
|
||||
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{sheet?, rows?}", has_doc, |s, p| hide(
|
||||
s, p, true, true
|
||||
)),
|
||||
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide(
|
||||
s, p, false, true
|
||||
)),
|
||||
cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide(
|
||||
s, p, true, false
|
||||
)),
|
||||
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{sheet?, cols?}", has_doc, |s, p| {
|
||||
hide(s, p, false, true)
|
||||
}),
|
||||
cmd!(
|
||||
"home.unhideRows",
|
||||
"Unhide Rows",
|
||||
["Home", "Cells", "Format", "Hide & Unhide"],
|
||||
Some("Cmd+Shift+9"),
|
||||
"{sheet?, rows?}",
|
||||
has_doc,
|
||||
|s, p| hide(s, p, true, false)
|
||||
),
|
||||
cmd!(
|
||||
"home.unhideColumns",
|
||||
"Unhide Columns",
|
||||
["Home", "Cells", "Format", "Hide & Unhide"],
|
||||
Some("Cmd+Shift+0"),
|
||||
"{cols?}",
|
||||
"{sheet?, cols?}",
|
||||
has_doc,
|
||||
|s, p| hide(s, p, false, false)
|
||||
),
|
||||
@@ -202,6 +206,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
const ACCOUNTING: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)";
|
||||
const COMMA: &str = "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)";
|
||||
|
||||
/// Accounting Number Format with the currency symbol to use (Excel's own button takes the system
|
||||
/// currency; `symbol` is how the control channel picks another one, e.g. `"L"` for lempiras).
|
||||
fn accounting(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let symbol = str_param(p, "symbol").unwrap_or("$");
|
||||
// The symbol goes into a quoted literal: a quote or a backslash would end it, and a control
|
||||
// character would reach the format's text as-is.
|
||||
if symbol.contains(['"', '\\']) || symbol.chars().any(char::is_control) {
|
||||
return Err(bad("home.accounting", "the symbol can't contain a quote, a backslash or a control character"));
|
||||
}
|
||||
set_fmt(s, p, &accounting_format(symbol))
|
||||
}
|
||||
|
||||
fn accounting_format(symbol: &str) -> String {
|
||||
let symbol = format!("\"{symbol}\"");
|
||||
format!("_({symbol}* #,##0.00_);_({symbol}* \\(#,##0.00\\);_({symbol}* \"-\"??_);_(@_)")
|
||||
}
|
||||
|
||||
fn with(p: &Json, key: &str, v: Json) -> Json {
|
||||
let mut m = p.as_object().cloned().unwrap_or_default();
|
||||
m.insert(key.into(), v);
|
||||
@@ -296,7 +317,11 @@ fn underline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn font_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) };
|
||||
apply_style(s, p, |st| st.font.name = name.clone())
|
||||
apply_style(s, p, |st| {
|
||||
st.font.name = name.clone();
|
||||
st.font.charset = None;
|
||||
st.font.family = None;
|
||||
})
|
||||
}
|
||||
|
||||
fn font_size(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -488,7 +513,7 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(patch) = p.get("style").cloned() else {
|
||||
let Some(patch) = p.get("style").map(snake_case_keys) else {
|
||||
// Menu invocation without params: the UI opens the dialog.
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("formatCells".into(), json!({})));
|
||||
return ok();
|
||||
@@ -506,6 +531,32 @@ fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the
|
||||
/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay.
|
||||
fn snake_case_keys(v: &Json) -> Json {
|
||||
match v {
|
||||
Json::Object(m) => Json::Object(
|
||||
m.iter()
|
||||
.map(|(k, v)| {
|
||||
let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) {
|
||||
k.chars().fold(String::new(), |mut out, c| {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(c.to_ascii_lowercase());
|
||||
out
|
||||
})
|
||||
} else {
|
||||
k.clone()
|
||||
};
|
||||
(key, snake_case_keys(v))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-merges `patch` into `base` (objects merge, everything else replaces).
|
||||
pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json {
|
||||
match (&mut base, patch) {
|
||||
@@ -933,29 +984,60 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
// ---------------------------------------------------------------- sizes
|
||||
|
||||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
|
||||
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
|
||||
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
|
||||
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
|
||||
/// sheet, so another sheet needs explicit lines.
|
||||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
|
||||
let key = if rows { "rows" } else { "cols" };
|
||||
let mut sheet = target_sheet(s, p)?;
|
||||
if let Some(t) = str_param(p, key) {
|
||||
let (prefix, body) = split_sheet(t);
|
||||
if let Some(name) = prefix {
|
||||
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
|
||||
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
|
||||
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
sheet = named;
|
||||
}
|
||||
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
|
||||
return t
|
||||
let lines = body
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
return Ok((sheet, lines));
|
||||
}
|
||||
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
|
||||
let d = s.doc()?;
|
||||
if sheet != d.wb.active_sheet {
|
||||
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
|
||||
}
|
||||
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
|
||||
}
|
||||
|
||||
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
|
||||
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
|
||||
/// and of the file (`ht` is in points).
|
||||
fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let h = f64_param(p, "height").ok_or_else(|| bad("home.rowHeight", "missing `height`"))?;
|
||||
if !(0.0..=546.0).contains(&h) {
|
||||
return Err(EngineError::Other("Row height must be between 0 and 409 points.".into()));
|
||||
}
|
||||
let lines = line_targets(s, p, true)?;
|
||||
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
|
||||
(Some(h), _) => {
|
||||
if !(0.0..=546.0).contains(&h) {
|
||||
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
|
||||
}
|
||||
h
|
||||
}
|
||||
(None, Some(pt)) => {
|
||||
if !(0.0..=409.5).contains(&pt) {
|
||||
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
|
||||
}
|
||||
pt * 4.0 / 3.0
|
||||
}
|
||||
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
|
||||
};
|
||||
let (si, lines) = line_targets(s, p, true)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (a, b) in lines {
|
||||
for r in a..=b.min(a.saturating_add(1_048_575)) {
|
||||
@@ -972,16 +1054,22 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
|
||||
(Some(w), _) => w,
|
||||
(None, Some(c)) => chars_to_points(c),
|
||||
_ => return Err(bad("home.columnWidth", "missing `width`")),
|
||||
(Some(w), _) => {
|
||||
if !(0.0..=1800.0).contains(&w) {
|
||||
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
|
||||
}
|
||||
w
|
||||
}
|
||||
(None, Some(c)) => {
|
||||
if !(0.0..=255.0).contains(&c) {
|
||||
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
|
||||
}
|
||||
chars_to_points(c)
|
||||
}
|
||||
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
|
||||
};
|
||||
if !(0.0..=1800.0).contains(&w) {
|
||||
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
|
||||
}
|
||||
let lines = line_targets(s, p, false)?;
|
||||
let (si, lines) = line_targets(s, p, false)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (a, b) in lines {
|
||||
for c in a..=b {
|
||||
@@ -994,7 +1082,7 @@ fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Excel column width in characters (of the default font's digit width, 7 px) → points.
|
||||
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
|
||||
pub fn chars_to_points(c: f64) -> f64 {
|
||||
if c <= 0.0 {
|
||||
0.0
|
||||
@@ -1008,7 +1096,7 @@ pub fn chars_to_points(c: f64) -> f64 {
|
||||
pub use gridcraft_model::points_to_chars;
|
||||
|
||||
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
|
||||
/// widths for the default font; the UI refines with real measurement when it autofits).
|
||||
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
|
||||
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
|
||||
let mut w = 0.0;
|
||||
for c in text.chars() {
|
||||
@@ -1025,12 +1113,18 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
|
||||
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
|
||||
}
|
||||
|
||||
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
|
||||
/// quick measure so a huge column doesn't lay out every value.
|
||||
const EXACT_TEXT_WIDTHS: usize = 2_000;
|
||||
|
||||
fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let lines = line_targets(s, p, false)?;
|
||||
let (si, lines) = line_targets(s, p, false)?;
|
||||
let measure = s.text_width.clone();
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
|
||||
let mut widths = Vec::new();
|
||||
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
|
||||
let mut distinct = 0usize;
|
||||
let used = sh.used_range();
|
||||
for (a, b) in lines {
|
||||
for c in a..=b.min(a + 16383) {
|
||||
@@ -1041,12 +1135,26 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = cx.wb.styles.get(sh.style_id(cell));
|
||||
let id = sh.style_id(cell);
|
||||
let st = cx.wb.styles.get(id);
|
||||
let text = crate::display::cell_text(&cx.wb, sh, cell);
|
||||
for line in text.lines() {
|
||||
max = max.max(
|
||||
approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell),
|
||||
);
|
||||
let w = match &measure {
|
||||
Some(m) => {
|
||||
let known = measured.entry(id).or_default();
|
||||
match known.get(line) {
|
||||
Some(w) => *w,
|
||||
None => {
|
||||
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
|
||||
known.insert(line.to_owned(), w);
|
||||
distinct += 1;
|
||||
w
|
||||
}
|
||||
}
|
||||
}
|
||||
None => approx_text_width(line, st.font.size, st.font.bold),
|
||||
};
|
||||
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
|
||||
}
|
||||
}
|
||||
if max > 0.0 {
|
||||
@@ -1065,9 +1173,8 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let lines = line_targets(s, p, true)?;
|
||||
let (si, lines) = line_targets(s, p, true)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
for (a, b) in lines {
|
||||
let b = b.min(a + 100_000);
|
||||
if let Some(sh) = cx.wb.sheet_mut(si) {
|
||||
@@ -1087,17 +1194,16 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
|
||||
let si = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
|
||||
let lines = line_targets(s, p, rows)?;
|
||||
let (si, lines) = line_targets(s, p, rows)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (a, b) in lines {
|
||||
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
|
||||
@@ -1137,6 +1243,11 @@ mod tests {
|
||||
assert_eq!(v(&mut s, "D1"), json!(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_symbol_reproduces_the_accounting_format() {
|
||||
assert_eq!(accounting_format("$"), ACCOUNTING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_steps() {
|
||||
let v = gridcraft_core::Value::Number(1.5);
|
||||
@@ -1149,6 +1260,20 @@ mod tests {
|
||||
assert_eq!(adjust_decimals("0%", &v, 1), "0.0%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cells_takes_the_documented_keys() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap();
|
||||
let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"];
|
||||
assert_eq!(st["num_fmt"], "0.0");
|
||||
assert_eq!(st["borders"]["diag_up"]["style"], "Thin");
|
||||
// The style's own snake_case keys keep working.
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_units() {
|
||||
assert_eq!(chars_to_points(8.43), 64.0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -49,11 +49,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
|
||||
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
|
||||
cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas),
|
||||
cmd!(
|
||||
"formulas.convertToNumber",
|
||||
"Convert to Number",
|
||||
[],
|
||||
None,
|
||||
"{range?} → {converted} (numbers stored as text in the range or selection become numbers)",
|
||||
has_doc,
|
||||
convert_to_number
|
||||
),
|
||||
cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking),
|
||||
cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula),
|
||||
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate),
|
||||
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate),
|
||||
cmd!(noundo "formulas.calculateNow", "Calculate Now", ["Formulas", "Calculation"], Some("F9"), "{}", has_doc, calc_now),
|
||||
cmd!(noundo "formulas.calculateSheet", "Calculate Sheet", ["Formulas", "Calculation"], Some("Shift+F9"), "{}", has_doc, calc_now),
|
||||
cmd!(
|
||||
@@ -237,7 +248,7 @@ fn valid_name(n: &str) -> bool {
|
||||
|
||||
/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't
|
||||
/// accept as a name.
|
||||
fn is_r1c1(n: &str) -> bool {
|
||||
pub(crate) fn is_r1c1(n: &str) -> bool {
|
||||
let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count();
|
||||
let mut rest = n;
|
||||
if let Some(r) = rest.strip_prefix(['R', 'r']) {
|
||||
@@ -265,7 +276,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let refers = match str_param(p, "refersTo") {
|
||||
// As in Excel, relative references are relative to the active cell; names keep them
|
||||
// relative to A1, as files do.
|
||||
Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) {
|
||||
Some(r) => match gridcraft_formula::parse_name(r.trim_start_matches('=')) {
|
||||
Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => {
|
||||
let at = d.selection.active;
|
||||
gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64)))
|
||||
@@ -280,7 +291,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), body)
|
||||
}
|
||||
};
|
||||
if gridcraft_formula::parse(&refers).is_err() {
|
||||
if gridcraft_formula::parse_name(&refers).is_err() {
|
||||
return Err(EngineError::Other("There's a problem with this formula.".into()));
|
||||
}
|
||||
let comment = str_param(p, "comment").unwrap_or("").to_string();
|
||||
@@ -348,6 +359,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
|
||||
let Some(e) = f.expr().and_then(rename) else { continue };
|
||||
let mut nf = Formula::from_expr(e);
|
||||
nf.array = f.array;
|
||||
nf.dynamic = f.dynamic;
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
@@ -355,7 +367,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
|
||||
}
|
||||
for n in wb.names.iter_mut() {
|
||||
if n.scope.is_none_or(applies)
|
||||
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
|
||||
&& let Some(e) = gridcraft_formula::parse_name(&n.formula).ok().and_then(rename)
|
||||
{
|
||||
n.formula = gridcraft_formula::print(&e);
|
||||
}
|
||||
@@ -389,7 +401,9 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
.filter(|n| !n.hidden)
|
||||
.map(|n| {
|
||||
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
|
||||
let v = gridcraft_calc::evaluate(&d.wb, sheet, CellRef::default(), &n.formula);
|
||||
// parse_name, not evaluate's parse: a name's top-level commas are unions.
|
||||
let v = gridcraft_formula::parse_name(&n.formula)
|
||||
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
|
||||
json!({"name": n.name, "refersTo": format!("={}", n.formula), "scope": n.scope.and_then(|i| d.wb.sheet(i)).map(|s| s.name.clone()).unwrap_or_else(|| "Workbook".into()), "value": crate::cmd::inspect::value_json(&v), "comment": n.comment})
|
||||
})
|
||||
.collect();
|
||||
@@ -479,6 +493,82 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
|
||||
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
|
||||
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
|
||||
use std::collections::BTreeSet;
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet;
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
|
||||
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
|
||||
let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new();
|
||||
let mut frontier = vec![(sheet, at)];
|
||||
seen.insert((sheet, at));
|
||||
while let Some((ks, kc)) = frontier.pop() {
|
||||
if precedents {
|
||||
for a in d.calc.graph.precedents_of((ks, kc)) {
|
||||
if all {
|
||||
// Walk only the stored cells inside the precedent area (sparse
|
||||
// range iterator, not the whole sheet): blanks are never selected
|
||||
// and only formula cells extend the frontier, so a formula late in
|
||||
// a large range is not skipped.
|
||||
let Some(sh) = d.wb.sheet(a.sheet) else { continue };
|
||||
for (c, cell) in sh.cells.iter_range(a.range) {
|
||||
if matches!(cell.value, Value::Empty) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert((a.sheet, c)) {
|
||||
found.insert((a.sheet, c.a1()));
|
||||
if cell.formula.is_some() {
|
||||
frontier.push((a.sheet, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
found.insert((a.sheet, a.range.a1()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut deps = Vec::new();
|
||||
d.calc.graph.dependents(ks, kc, &mut deps);
|
||||
for k2 in deps {
|
||||
if all {
|
||||
if seen.insert(k2) {
|
||||
found.insert((k2.0, k2.1.a1()));
|
||||
frontier.push(k2);
|
||||
}
|
||||
} else {
|
||||
found.insert((k2.0, k2.1.a1()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found.remove(&(sheet, at.a1()));
|
||||
let name = if precedents { "precedent" } else { "dependent" };
|
||||
let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else {
|
||||
return Err(bad("cell", format!("No {name} cells.")));
|
||||
};
|
||||
let list: Vec<String> = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect();
|
||||
let mut params = json!({ "range": list.join(",") });
|
||||
if si != sheet {
|
||||
params["sheet"] = json!(si);
|
||||
}
|
||||
s.execute("selection.set", params)
|
||||
}
|
||||
|
||||
fn select_precedents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
select_trace_cells(s, p, true)
|
||||
}
|
||||
|
||||
fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
select_trace_cells(s, p, false)
|
||||
}
|
||||
|
||||
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
|
||||
@@ -489,6 +579,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The number a cell's text stands for when Excel's background check flags it as a number stored
|
||||
/// as text: a constant (not a formula's result) that would be a number if typed.
|
||||
pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option<f64> {
|
||||
match &cell.value {
|
||||
Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to Number: each number stored as text in the range becomes that number, keeping the
|
||||
/// cell's format.
|
||||
fn convert_to_number(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let mut converted = 0;
|
||||
for r in &ranges {
|
||||
let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue };
|
||||
let found: Vec<(CellRef, f64)> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect())
|
||||
.unwrap_or_default();
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, n) in &found {
|
||||
sh.set_value(*c, Value::number(*n));
|
||||
}
|
||||
cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c)));
|
||||
converted += found.len();
|
||||
}
|
||||
Ok(json!({"converted": converted}))
|
||||
})
|
||||
}
|
||||
|
||||
fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
@@ -528,23 +652,30 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut steps = Vec::new();
|
||||
let mut nodes = Vec::new();
|
||||
expr.walk(&mut |e| {
|
||||
// Borrowed, not cloned: copying every operator of a long chain A1+A2+… copies the
|
||||
// rest of the chain each time (quadratic memory).
|
||||
if matches!(e, gridcraft_formula::Expr::Call(..) | gridcraft_formula::Expr::Binary(..) | gridcraft_formula::Expr::Ref(_)) {
|
||||
nodes.push(e.clone());
|
||||
nodes.push(e);
|
||||
}
|
||||
});
|
||||
for e in nodes.into_iter().rev().take(200) {
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e);
|
||||
steps.push(json!({"expression": gridcraft_formula::print(&e), "value": crate::cmd::inspect::value_json(&v)}));
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, e);
|
||||
steps.push(json!({"expression": gridcraft_formula::print(e), "value": crate::cmd::inspect::value_json(&v)}));
|
||||
}
|
||||
let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr);
|
||||
Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)}))
|
||||
}
|
||||
|
||||
/// Evaluates what typing `formula` into a cell would show. A formula `cell.set` would refuse (it
|
||||
/// doesn't parse, or calls a function with the wrong number of arguments) is an error that says
|
||||
/// why, not `#NAME?`; an unknown function name still evaluates to `#NAME?`, as in Excel.
|
||||
fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f);
|
||||
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
|
||||
crate::cmd::edit::check_arg_counts(&expr)?;
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
|
||||
Ok(crate::cmd::inspect::value_json(&v))
|
||||
}
|
||||
|
||||
@@ -624,4 +755,39 @@ mod tests {
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:A2", "top": true})).unwrap();
|
||||
assert_eq!(r["created"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_referring_to_several_ranges() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
|
||||
// Excel writes and accepts a union in a name without parentheses.
|
||||
let r = s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
|
||||
assert_eq!(r["refersTo"], "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2");
|
||||
for (cell, input) in [("E1", "=SUM(u)"), ("E2", "=AREAS(u)"), ("E3", "=MIN(u)"), ("E4", "=INDEX(u,2,1,2)")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
|
||||
assert_eq!(v(&mut s, "E1"), json!(15.0));
|
||||
assert_eq!(v(&mut s, "E2"), json!(2.0));
|
||||
assert_eq!(v(&mut s, "E3"), json!(1.0));
|
||||
assert_eq!(v(&mut s, "E4"), json!(7.0));
|
||||
// Renaming the sheet follows every area, and the definition keeps Excel's form.
|
||||
s.execute("sheet.rename", json!({"name": "Data"})).unwrap();
|
||||
let name = s.doc().unwrap().wb.names.iter().find(|n| n.name == "u").map(|n| n.formula.clone());
|
||||
assert_eq!(name.as_deref(), Some("Data!$A$1:$A$2,Data!$C$1:$C$2"));
|
||||
assert_eq!(v(&mut s, "E1"), json!(15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_manager_previews_a_name_over_several_ranges() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
|
||||
let list = s.execute("formulas.nameManager", json!({})).unwrap();
|
||||
let u = list.as_array().and_then(|l| l.iter().find(|n| n["name"] == "u")).cloned().unwrap();
|
||||
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
|
||||
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
|
||||
}
|
||||
}
|
||||
+247
-27
@@ -2,9 +2,11 @@
|
||||
//! conditional formatting (Home › Styles) and symbols.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::*;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::sheet::{rewrite, shift_cell_features};
|
||||
use super::*;
|
||||
use crate::selection::current_region;
|
||||
|
||||
@@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -188,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Conditional Formatting",
|
||||
["Home", "Styles"],
|
||||
None,
|
||||
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font..fill..}|preset: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}}",
|
||||
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font?: {bold?, italic?, color?}, fill?: \"#FFC7CE\" | {pattern?, fg?, bg?}, borders?, num_fmt?} (colours \"#RRGGBB\") | preset?: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}; a text `value` is matched as text, or kept when already a quoted literal}",
|
||||
has_doc,
|
||||
add_cf
|
||||
),
|
||||
@@ -224,6 +226,17 @@ fn block(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
Ok(current_region(sh, d.selection.active))
|
||||
}
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
return target_ranges(s, p);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.selection.ranges.len() > 1 {
|
||||
return Ok(d.selection.ranges.clone());
|
||||
}
|
||||
Ok(vec![block(s, p)?])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
|
||||
fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -276,13 +289,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
|
||||
}
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
|
||||
.into_iter()
|
||||
.filter_map(|(c, v)| {
|
||||
let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) };
|
||||
dest.map(|c| (c, v))
|
||||
})
|
||||
.collect();
|
||||
// Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
|
||||
// reference (on any sheet) at the cells that moved.
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(&mut cx.wb, &name, &e);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
}
|
||||
@@ -472,6 +485,7 @@ fn valid_table_name(name: &str) -> bool {
|
||||
name.chars().count() <= 255
|
||||
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
|
||||
&& CellRef::parse(name).is_none()
|
||||
&& !super::formulas::is_r1c1(name)
|
||||
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
|
||||
}
|
||||
|
||||
@@ -481,7 +495,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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)) {
|
||||
let wb = &s.doc()?.wb;
|
||||
if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
@@ -604,6 +619,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
@@ -619,6 +635,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
@@ -627,6 +644,36 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
out
|
||||
}
|
||||
|
||||
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
|
||||
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
|
||||
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
|
||||
if areas.len() == 1 {
|
||||
let by_rows = first.width() > first.height() + 1;
|
||||
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
|
||||
}
|
||||
let valid_shape = first.width() == 1
|
||||
&& first.height() > 1
|
||||
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
|
||||
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
|
||||
if !valid_shape {
|
||||
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
|
||||
}
|
||||
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
|
||||
let mut series = Vec::with_capacity(areas.len() - 1);
|
||||
for area in &areas[1..] {
|
||||
let mut area_series = series_from_range(sh, *area, false);
|
||||
let Some(mut item) = area_series.pop() else {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
};
|
||||
if item.name.is_none() {
|
||||
return Err(bad("insert.chart", "multi-area series columns require headers"));
|
||||
}
|
||||
item.categories = category.clone();
|
||||
series.push(item);
|
||||
}
|
||||
Ok((bounds, false, series))
|
||||
}
|
||||
|
||||
/// A scatter plot of a block without category labels takes its first column (or row) as the X
|
||||
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
|
||||
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
|
||||
@@ -647,15 +694,14 @@ const MAX_CHART_CELLS: u64 = 1_000_000;
|
||||
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
|
||||
/// series → clustered and stacked columns and bars, line.
|
||||
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = r.width() > r.height() + 1;
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
|
||||
let ranges: Vec<RangeRef> = series
|
||||
.iter()
|
||||
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
|
||||
@@ -707,8 +753,8 @@ pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'st
|
||||
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
|
||||
/// goes on and the chart (previews in the Recommended Charts dialog).
|
||||
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let mut t = str_param(p, "type").unwrap_or("column").to_string();
|
||||
@@ -721,18 +767,14 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Excel plots series by the longer dimension (more rows → series in columns).
|
||||
let data_h = r.height();
|
||||
let data_w = r.width();
|
||||
let by_rows = data_w > data_h + 1;
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let mut series = series_from_range(sh, r, by_rows);
|
||||
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
scatter_x_from_first(kind, &mut series);
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
|
||||
let title = str_param(p, "title").map(str::to_string).or_else(|| {
|
||||
if series.len() == 1 {
|
||||
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
|
||||
@@ -759,7 +801,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
|
||||
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
@@ -789,12 +831,25 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", si)?;
|
||||
let quartiles = p
|
||||
.get("quartiles")
|
||||
.map(|v| match v.as_str() {
|
||||
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
|
||||
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
|
||||
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
|
||||
})
|
||||
.transpose()?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
}
|
||||
if let Some(method) = quartiles {
|
||||
for series in &mut c.series {
|
||||
series.quartiles = method;
|
||||
}
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(str::to_string);
|
||||
}
|
||||
@@ -978,7 +1033,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
|
||||
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
|
||||
},
|
||||
data,
|
||||
data: data.into(),
|
||||
mime,
|
||||
alt: str_param(p, "alt").unwrap_or("").into(),
|
||||
};
|
||||
@@ -1250,7 +1305,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
at,
|
||||
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
|
||||
Comment {
|
||||
author: author.clone(),
|
||||
text: text.clone(),
|
||||
replies: vec![],
|
||||
threaded,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
Ok(json!({"cell": at.a1()}))
|
||||
})
|
||||
@@ -1335,6 +1399,110 @@ fn preset_style(name: &str) -> Style {
|
||||
s
|
||||
}
|
||||
|
||||
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
|
||||
fn is_string_literal(v: &str) -> bool {
|
||||
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
|
||||
}
|
||||
|
||||
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
|
||||
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
|
||||
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
|
||||
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
|
||||
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
|
||||
fn cf_style_json(st: &Json) -> Result<Json> {
|
||||
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
|
||||
let err = |m: String| bad("home.conditionalFormat", m);
|
||||
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
|
||||
let mut st = obj.clone();
|
||||
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
|
||||
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
|
||||
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
|
||||
}
|
||||
// A colour written the way the other formatting commands take it, as the model stores it.
|
||||
let color = |v: &mut Json, what: &str| -> Result<()> {
|
||||
if v.is_string() || v.get("theme").is_some() {
|
||||
let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
|
||||
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
|
||||
&& let Some(c) = f.get_mut("color")
|
||||
{
|
||||
color(c, "font.color")?;
|
||||
}
|
||||
if let Some(fill) = st.get_mut("fill") {
|
||||
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
|
||||
if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) {
|
||||
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
} else if fill.is_string() || fill.get("theme").is_some() {
|
||||
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
|
||||
}
|
||||
if let Some(f) = fill.as_object_mut() {
|
||||
for k in ["fg", "bg"] {
|
||||
if let Some(c) = f.get_mut(k) {
|
||||
color(c, &format!("fill.{k}"))?;
|
||||
}
|
||||
}
|
||||
// A colour with no pattern is a solid fill, not an invisible one.
|
||||
if f.contains_key("fg") && !f.contains_key("pattern") {
|
||||
f.insert("pattern".into(), json!("Solid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
|
||||
for (side, line) in b.iter_mut() {
|
||||
if let Some(c) = line.get_mut("color") {
|
||||
color(c, &format!("borders.{side}.color"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json::Object(st))
|
||||
}
|
||||
|
||||
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
|
||||
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
|
||||
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
|
||||
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
|
||||
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
|
||||
// only parses after several strips and would still be saved unparseable. (#1999 review)
|
||||
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
|
||||
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
|
||||
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
|
||||
check_cf_formula("home.conditionalFormat", what, f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_cf_rule(rule: &CfRule) -> Result<()> {
|
||||
const CMD: &str = "home.conditionalFormat";
|
||||
match rule {
|
||||
CfRule::CellIs { a, b, .. } => {
|
||||
check_cf_formula(CMD, "comparison value", a)?;
|
||||
if let Some(b) = b {
|
||||
check_cf_formula(CMD, "second comparison value", b)?;
|
||||
}
|
||||
}
|
||||
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
|
||||
CfRule::DataBar { min, max, .. } => {
|
||||
check_cf_value("data bar minimum", min)?;
|
||||
check_cf_value("data bar maximum", max)?;
|
||||
}
|
||||
CfRule::ColorScale { stops } => {
|
||||
for (kind, _) in stops {
|
||||
check_cf_value("color scale threshold", kind)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
@@ -1344,9 +1512,10 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
|
||||
let style = Box::new(match rule.get("style") {
|
||||
Some(st) if st.is_object() => {
|
||||
Some(st) if !st.is_null() => {
|
||||
let st = cf_style_json(st)?;
|
||||
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
serde_json::from_value(super::format::merge_json(base, st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
|
||||
serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
|
||||
}
|
||||
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
|
||||
});
|
||||
@@ -1385,8 +1554,12 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
|
||||
let b = rule.get("value2").map(super::edit::json_to_input);
|
||||
let quote = |v: String| {
|
||||
// A number, a formula, or text already written as a string literal (`"Closed"`, as
|
||||
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
|
||||
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
|
||||
v.trim_start_matches('=').to_string()
|
||||
} else if is_string_literal(&v) {
|
||||
v
|
||||
} else {
|
||||
format!("\"{}\"", v.replace('"', "\"\""))
|
||||
}
|
||||
@@ -1443,6 +1616,7 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
},
|
||||
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
|
||||
};
|
||||
check_cf_rule(&rule)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
|
||||
@@ -1554,4 +1728,50 @@ mod tests {
|
||||
let rules = s.execute("home.manageRules", json!({})).unwrap();
|
||||
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_without_header_keeps_outside_references() {
|
||||
// #1030: the data moved down for the new header row, but formulas outside kept the old addresses.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "20"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"sheet": "Two", "cell": "A1", "input": "=Sheet1!A2"})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A2", "header": false})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], "Column1");
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(10.0));
|
||||
assert_eq!(get(&mut s, "B1")["formula"], "=SUM(A2:A3)");
|
||||
assert_eq!(get(&mut s, "B1")["value"], json!(30.0));
|
||||
assert_eq!(get(&mut s, "C1")["formula"], "=A3");
|
||||
assert_eq!(get(&mut s, "C1")["value"], json!(20.0));
|
||||
let other = s.execute("cell.get", json!({"sheet": "Two", "cell": "A1"})).unwrap();
|
||||
assert_eq!(other["formula"], "=Sheet1!A3");
|
||||
assert_eq!(other["value"], json!(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_names_refuse_defined_names_and_r1c1() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:A2", "values": [["h"], ["1"]]})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.2"})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A2"})).unwrap();
|
||||
let e = s.execute("table.rename", json!({"table": "Table1", "name": "Rate"})).unwrap_err();
|
||||
assert!(e.to_string().contains("already exists"), "{e}");
|
||||
let e = s.execute("table.rename", json!({"table": "Table1", "name": "rate"})).unwrap_err();
|
||||
assert!(e.to_string().contains("already exists"), "{e}");
|
||||
for n in ["R2C3", "c", "r", "RC", "C5"] {
|
||||
let e = s.execute("table.rename", json!({"table": "Table1", "name": n})).unwrap_err();
|
||||
assert!(e.to_string().contains("not valid"), "{n}: {e}");
|
||||
let e = s.execute("insert.table", json!({"range": "C1:C2", "name": n})).unwrap_err();
|
||||
assert!(e.to_string().contains("not valid"), "{n}: {e}");
|
||||
}
|
||||
s.execute("table.rename", json!({"table": "Table1", "name": "Table1"})).unwrap();
|
||||
s.execute("table.rename", json!({"table": "Table1", "name": "Rates"})).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"text": crate::display::cell_text(&d.wb, sh, c),
|
||||
"input": sh.input_text(c),
|
||||
"picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})),
|
||||
"spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()),
|
||||
"spilledFrom": sh.spill_anchor(c).map(|a| a.a1()),
|
||||
"spill": sh.spill_ranges.get(&c).map(|r| r.a1()),
|
||||
"style": style,
|
||||
"merge": sh.merge_at(c).map(|m| m.a1()),
|
||||
|
||||
@@ -273,6 +273,11 @@ pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
Ok(s.doc()?.selection.ranges.clone())
|
||||
}
|
||||
|
||||
/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`.
|
||||
pub(crate) fn parse_lines(t: &str) -> Option<RangeRef> {
|
||||
RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) })
|
||||
}
|
||||
|
||||
pub(crate) fn target_range(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
Ok(target_ranges(s, p)?.first().copied().unwrap_or_default())
|
||||
}
|
||||
@@ -296,13 +301,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
|
||||
Ok(d.wb.active_sheet)
|
||||
}
|
||||
|
||||
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
|
||||
pub(crate) fn split_sheet(r: &str) -> (Option<String>, &str) {
|
||||
match r.rsplit_once('!') {
|
||||
Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body),
|
||||
None => (None, r),
|
||||
}
|
||||
}
|
||||
pub(crate) use gridcraft_core::split_sheet;
|
||||
|
||||
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
|
||||
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
|
||||
@@ -431,6 +430,18 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuses an action a protected sheet does not allow, by the tick box for it in Review ▸ Protect
|
||||
/// Sheet. `what` names the action in the message. An unprotected sheet allows everything.
|
||||
///
|
||||
/// Cell edits are guarded separately, by whether the cell is locked (`locked_cell_error`): these
|
||||
/// permissions are the ones that are not about one cell's contents.
|
||||
pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: fn(&gridcraft_model::SheetProtection) -> bool) -> Result<()> {
|
||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pick(pr))) {
|
||||
return Err(EngineError::Other(format!("{what} isn't allowed on this protected sheet (Review › Unprotect Sheet).")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
|
||||
/// Range without a password or one unlocked in this session, else the message to show.
|
||||
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
|
||||
@@ -490,18 +501,53 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
|
||||
/// multi-line text. Rows with a user-set height keep it.
|
||||
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
|
||||
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
|
||||
/// drawn line ends early at the last word that fits. A word wider than the column breaks
|
||||
/// mid-word, the way egui does. (#1969)
|
||||
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
|
||||
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
|
||||
let space_w = word_w(" ");
|
||||
let mut lines = 1.0f32;
|
||||
let mut used = 0.0f32;
|
||||
for word in line.split_whitespace() {
|
||||
let w = word_w(word);
|
||||
if w > width {
|
||||
// wider than the column: egui fills the current line, then breaks it across lines
|
||||
if used > 0.0 {
|
||||
lines += 1.0;
|
||||
}
|
||||
let chunks = (w / width).ceil();
|
||||
lines += chunks - 1.0;
|
||||
used = w - (chunks - 1.0) * width;
|
||||
continue;
|
||||
}
|
||||
let gap = if used > 0.0 { space_w } else { 0.0 };
|
||||
if used + gap + w > width {
|
||||
lines += 1.0;
|
||||
used = w;
|
||||
} else {
|
||||
used += gap + w;
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
|
||||
/// Rows with a user-set height keep it.
|
||||
///
|
||||
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
|
||||
/// a multi-line value shows on one line and AutoFit gives a one-line row.
|
||||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
let Some(sh) = wb.sheet(si) else { return };
|
||||
if sh.rows.get(&row).is_some_and(|i| i.custom) {
|
||||
return;
|
||||
}
|
||||
let default = sh.default_row_height;
|
||||
// Fast path: rows of default-size, unwrapped, single-line content keep the default height.
|
||||
// Fast path: rows of default-size, unwrapped content keep the default height.
|
||||
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
|
||||
let st = wb.styles.get(cell.style);
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap && !cell.value.as_text().is_some_and(|t| t.contains('\n'))
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
|
||||
});
|
||||
if plain {
|
||||
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
|
||||
@@ -519,22 +565,24 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
}
|
||||
let mut need: f32 = default;
|
||||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(cell.style);
|
||||
let size = st.font.size;
|
||||
let line = (size * 1.8).round().max(default * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
|
||||
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
|
||||
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
let mut lines = 1.0f32;
|
||||
if !cell.value.is_empty() || cell.formula.is_some() {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let explicit = text.lines().count().max(1) as f32;
|
||||
lines = if st.align.wrap {
|
||||
// Only a wrapping cell is laid out over several lines: each explicit line break
|
||||
// starts one, and each line folds at the column width.
|
||||
if st.align.wrap {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let w = sh.col_width(c).max(1.0) - 6.0;
|
||||
text.lines()
|
||||
.map(|l| (crate::cmd::format::approx_text_width(l, size, st.font.bold) / w.max(1.0)).ceil().max(1.0))
|
||||
.sum::<f32>()
|
||||
.max(1.0)
|
||||
} else {
|
||||
explicit
|
||||
};
|
||||
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
|
||||
}
|
||||
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1392,7 +1392,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let (sheet, addr) = match t.rsplit_once('!') {
|
||||
Some((sh, a)) => {
|
||||
let sh = sh.trim();
|
||||
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
|
||||
let sh = gridcraft_core::unquote_sheet(sh);
|
||||
(d.wb.sheet_index(&sh), a)
|
||||
}
|
||||
None => (Some(d.wb.active_sheet), t),
|
||||
@@ -2314,4 +2314,53 @@ mod tests {
|
||||
// 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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]}))
|
||||
.unwrap();
|
||||
assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14");
|
||||
// Rows above the report (and below the source) move the report only.
|
||||
s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap();
|
||||
assert_eq!(text(&s, "H12"), "Row Labels");
|
||||
let l = s.execute("pivot.list", json!({})).unwrap();
|
||||
assert_eq!(l[0]["anchor"], "H12");
|
||||
assert_eq!(l[0]["range"], "H12:I16");
|
||||
assert_eq!(l[0]["source"], "Sheet1!A1:E15");
|
||||
// A row above the source moves both.
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert_eq!(p.source, "Sheet1!A2:E16");
|
||||
assert_eq!(p.anchor.a1(), "H13");
|
||||
s.execute("pivot.refresh", json!({})).unwrap();
|
||||
assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17");
|
||||
assert_eq!(text(&s, "H13"), "Row Labels");
|
||||
assert_eq!(val(&s, "I17"), Value::Number(TOTAL));
|
||||
// Deleting a column left of the report moves it left.
|
||||
s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert_eq!(p.anchor.a1(), "G13");
|
||||
assert_eq!(p.last_range.unwrap().a1(), "G13:H17");
|
||||
// Deleting rows above moves it up.
|
||||
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
|
||||
assert_eq!(pt(&s).source, "Sheet1!A1:E15");
|
||||
assert_eq!(pt(&s).anchor.a1(), "G12");
|
||||
s.execute("pivot.refresh", json!({})).unwrap();
|
||||
assert_eq!(text(&s, "G12"), "Row Labels");
|
||||
assert_eq!(val(&s, "H16"), Value::Number(TOTAL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap();
|
||||
let l = s.execute("pivot.list", json!({})).unwrap();
|
||||
assert_eq!(l[0]["source"], "Sheet1!A3:E15");
|
||||
assert_eq!(l[0]["anchor"], "A3");
|
||||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
}
|
||||
+210
-20
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Page Setup…",
|
||||
["Page Layout", "Page Setup"],
|
||||
None,
|
||||
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|Tabloid|Executive|Ledger|B5, margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
|
||||
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
|
||||
has_doc,
|
||||
page_setup
|
||||
),
|
||||
@@ -71,19 +71,36 @@ pub fn paper_size(name: &str) -> (f32, f32) {
|
||||
known_paper(name).unwrap_or((612.0, 792.0))
|
||||
}
|
||||
|
||||
/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its
|
||||
/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as
|
||||
/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too.
|
||||
fn known_paper(name: &str) -> Option<(f32, f32)> {
|
||||
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], "");
|
||||
// Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5`
|
||||
// are one name.
|
||||
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], "");
|
||||
Some(match n.as_str() {
|
||||
"letter" | "usletter" => (612.0, 792.0),
|
||||
"legal" | "uslegal" => (612.0, 1008.0),
|
||||
"a4" => (595.28, 841.89),
|
||||
"a3" => (841.89, 1190.55),
|
||||
"a5" => (419.53, 595.28),
|
||||
"a6" => (297.64, 419.53),
|
||||
"tabloid" | "11x17" => (792.0, 1224.0),
|
||||
"ledger" => (1224.0, 792.0),
|
||||
"executive" => (522.0, 756.0),
|
||||
"b5" => (498.9, 708.66),
|
||||
"b4" => (708.66, 1000.63),
|
||||
"b4" | "b4jis" => (728.50, 1031.81),
|
||||
"b5" | "b5jis" => (515.91, 728.50),
|
||||
"b6" | "b6jis" => (362.83, 515.91),
|
||||
"b4iso" => (708.66, 1000.63),
|
||||
"b5iso" => (498.90, 708.66),
|
||||
"b6iso" => (354.33, 498.90),
|
||||
"japanesepostcard" | "postcard" => (283.46, 419.53),
|
||||
"japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93),
|
||||
"japaneseenvelope#3" | "chou#3" => (340.16, 666.14),
|
||||
// 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out.
|
||||
"japaneseenvelope#4" | "chou#4" => (255.12, 581.10),
|
||||
"japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10),
|
||||
"japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -604,20 +621,6 @@ fn rgb_of(c: Color, wb: &Workbook) -> Option<Rgb> {
|
||||
c.resolve(&wb.theme)
|
||||
}
|
||||
|
||||
fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb {
|
||||
use gridcraft_numfmt::FormatColor as F;
|
||||
match c {
|
||||
F::Black | F::Indexed(_) => [0, 0, 0],
|
||||
F::Blue => [0, 0, 255],
|
||||
F::Cyan => [0, 255, 255],
|
||||
F::Green => [0, 128, 0],
|
||||
F::Magenta => [255, 0, 255],
|
||||
F::Red => [255, 0, 0],
|
||||
F::White => [255, 255, 255],
|
||||
F::Yellow => [255, 255, 0],
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(fg: [u8; 4]) -> Rgb {
|
||||
let a = fg[3] as u16;
|
||||
let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8;
|
||||
@@ -1000,6 +1003,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
struct PendingPdfLink {
|
||||
page: usize,
|
||||
sheet: usize,
|
||||
rect: (f32, f32, f32, f32),
|
||||
target: String,
|
||||
}
|
||||
|
||||
struct PrintedBlock {
|
||||
page: usize,
|
||||
sheet: usize,
|
||||
rows: (u32, u32),
|
||||
cols: (u32, u32),
|
||||
x: f32,
|
||||
y: f32,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
|
||||
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
|
||||
/// pages and receives one annotation on each of them.
|
||||
fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
|
||||
let range = b.range();
|
||||
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
|
||||
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
|
||||
if !range.contains(*cell) {
|
||||
continue;
|
||||
}
|
||||
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
|
||||
let (x, y, w, h) = b.rect(cell_range);
|
||||
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
continue;
|
||||
}
|
||||
let rect = (x0, y0, x1 - x0, y1 - y0);
|
||||
match crate::links::classify(&link.target) {
|
||||
crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri),
|
||||
crate::links::LinkKind::Internal(target) if !link.external => {
|
||||
pending.push(PendingPdfLink { page, sheet: b.si, rect, target });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> {
|
||||
let target = target.trim().trim_start_matches('#').trim_start_matches('=');
|
||||
let (sheet_name, range) = split_sheet(target);
|
||||
let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?;
|
||||
let cell = RangeRef::parse(&range.replace('$', ""))?.start;
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?;
|
||||
let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k;
|
||||
let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k;
|
||||
Some((block.page, x, y))
|
||||
}
|
||||
|
||||
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
|
||||
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
|
||||
let (x, y, w, h) = rect;
|
||||
@@ -1065,7 +1124,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
let avail = (w - 2.0 * pad - indent).max(0.0);
|
||||
let code = st.num_fmt.as_str();
|
||||
let formatted = crate::display::format(&v, code, wb);
|
||||
let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
|
||||
let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
|
||||
if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) {
|
||||
color = rgb_of(st.font.color, wb).unwrap_or(color);
|
||||
}
|
||||
@@ -1467,6 +1526,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
|
||||
let (date, time) = hf_date_time(wb);
|
||||
let mut page_no = 0usize;
|
||||
let mut pending_links = Vec::new();
|
||||
let mut printed_blocks = Vec::new();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
@@ -1481,6 +1542,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * k;
|
||||
let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 };
|
||||
let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 };
|
||||
let page_index = doc.page_count();
|
||||
let pg = doc.add_page(lay.page_w, lay.page_h);
|
||||
let mut y = oy + lay.heading_h * k;
|
||||
let mut body: Option<Block> = None;
|
||||
@@ -1489,6 +1551,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
for (csi, cs) in csegs.iter().enumerate() {
|
||||
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
|
||||
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
|
||||
draw_block_links(pg, &b, page_index, &mut pending_links);
|
||||
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
|
||||
if lay.settings.headings {
|
||||
draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k));
|
||||
}
|
||||
@@ -1519,6 +1583,14 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
|
||||
}
|
||||
}
|
||||
for link in pending_links {
|
||||
if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target)
|
||||
&& let Some(page) = doc.page_mut(link.page)
|
||||
{
|
||||
let (lx, ly, w, h) = link.rect;
|
||||
page.link_page(lx, ly, w, h, target_page, x, y);
|
||||
}
|
||||
}
|
||||
let n = doc.page_count();
|
||||
(doc.finish(), n)
|
||||
}
|
||||
@@ -1764,6 +1836,29 @@ mod tests {
|
||||
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
|
||||
let mut s = s();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap();
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 2);
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert_eq!(text.matches("/Subtype /Link").count(), 2);
|
||||
assert!(text.contains("/URI (https://example.org/report)"));
|
||||
assert!(text.contains("/Dest [") && text.contains("/XYZ "));
|
||||
assert!(!text.contains("/URI (file:"));
|
||||
|
||||
// A link to a cell omitted by the selected print range must not point to a nonexistent
|
||||
// destination. The external link in the selected range remains clickable.
|
||||
let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"}));
|
||||
let cropped = String::from_utf8_lossy(&cropped);
|
||||
assert_eq!(cropped.matches("/Subtype /Link").count(), 1);
|
||||
assert!(!cropped.contains("/Dest ["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_sections_split_join_and_field_names() {
|
||||
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
|
||||
@@ -1899,6 +1994,16 @@ mod tests {
|
||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_prints_cjk_text_instead_of_question_marks() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj");
|
||||
assert!(contains(&pdf, "<7B7E5B576E055355> Tj"));
|
||||
assert!(contains(&pdf, "(2) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_contains_text_and_header_codes_expand() {
|
||||
let mut s = s();
|
||||
@@ -1952,6 +2057,16 @@ mod tests {
|
||||
assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_uses_indexed_number_format_palette_color() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap();
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 1);
|
||||
assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_file() {
|
||||
let mut s = s();
|
||||
@@ -2020,7 +2135,7 @@ mod tests {
|
||||
wb.sheet_mut(0).unwrap().images.push(Image {
|
||||
id,
|
||||
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
|
||||
data: png,
|
||||
data: png.into(),
|
||||
mime: "image/png".into(),
|
||||
alt: String::new(),
|
||||
});
|
||||
@@ -2061,6 +2176,81 @@ mod tests {
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some());
|
||||
}
|
||||
|
||||
/// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the
|
||||
/// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too.
|
||||
#[test]
|
||||
fn japanese_paper_sizes_are_jis() {
|
||||
for (name, w, h) in [
|
||||
("A4", 595.28, 841.89), // unchanged
|
||||
("B4", 728.50, 1031.81),
|
||||
("B5", 515.91, 728.50),
|
||||
("B6", 362.83, 515.91),
|
||||
("B4 (JIS)", 728.50, 1031.81),
|
||||
("b5-jis", 515.91, 728.50),
|
||||
("B4 (ISO)", 708.66, 1000.63),
|
||||
("B5 (ISO)", 498.90, 708.66),
|
||||
("B6 (ISO)", 354.33, 498.90),
|
||||
("A6", 297.64, 419.53),
|
||||
("Japanese Postcard", 283.46, 419.53),
|
||||
("japanese double postcard", 419.53, 566.93),
|
||||
("Japanese Envelope #3", 340.16, 666.14),
|
||||
("chou #4", 255.12, 581.10),
|
||||
("Japanese Envelope Kaku #2", 680.31, 941.10),
|
||||
("kaku #3", 612.28, 785.20),
|
||||
] {
|
||||
let got = paper_size(name);
|
||||
assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}");
|
||||
}
|
||||
// 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the
|
||||
// 225 mm it once had.
|
||||
let chou4 = paper_size("Japanese Envelope #4");
|
||||
assert_eq!((chou4.0, chou4.1), (255.12, 581.10));
|
||||
assert_eq!(chou4, paper_size("chou #4"));
|
||||
// Unknown names keep the old Letter fallback.
|
||||
assert_eq!(paper_size("Napkin"), (612.0, 792.0));
|
||||
}
|
||||
|
||||
/// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is
|
||||
/// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and
|
||||
/// fit-to-page keeps the paper size.
|
||||
#[test]
|
||||
fn page_setup_paper_sizes_reach_the_pdf_page() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap();
|
||||
let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01;
|
||||
for (paper, w, h) in [
|
||||
("A4", 595.28f64, 841.89f64),
|
||||
("B5", 515.91, 728.50),
|
||||
("Japanese Postcard", 283.46, 419.53),
|
||||
("Japanese Double Postcard", 419.53, 566.93),
|
||||
("Japanese Envelope Kaku #2", 680.31, 941.10),
|
||||
] {
|
||||
s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
let page = &pv["pageList"][0];
|
||||
assert_eq!(page["paper"], paper);
|
||||
assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}");
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 1);
|
||||
let want = format!("/MediaBox [0 0 {w} {h}]");
|
||||
assert!(contains(&pdf, &want), "{paper}: wanted {want}");
|
||||
}
|
||||
// Landscape swaps the sides, and fit-to-page does not change the paper.
|
||||
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap();
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 1);
|
||||
assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]"));
|
||||
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]"));
|
||||
// `pageLayout.size` still takes any name, and an unknown one prints on Letter as before.
|
||||
s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0));
|
||||
// Page Setup refuses an unknown name.
|
||||
assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_settings_never_panic_and_cap_pages() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
|
||||
}
|
||||
let b = |k: &str| bool_param(p, k).unwrap_or(false);
|
||||
// Selection rights default to what the sheet has now (a file may forbid selecting locked cells).
|
||||
let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone());
|
||||
let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked));
|
||||
let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked));
|
||||
let prot = SheetProtection {
|
||||
password: None,
|
||||
legacy_password_hash: None,
|
||||
select_locked: true,
|
||||
select_unlocked: true,
|
||||
select_locked,
|
||||
select_unlocked,
|
||||
format_cells: b("formatCells"),
|
||||
format_columns: b("formatColumns"),
|
||||
format_rows: b("formatRows"),
|
||||
@@ -399,6 +403,26 @@ mod tests {
|
||||
assert_eq!(at(&s), (0, "A1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
|
||||
let mut s = book();
|
||||
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
// Locked cells can't be selected; unlocked ones can.
|
||||
assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err());
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
// Arrow keys skip locked cells, and stay put when nothing selectable is left.
|
||||
s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.active.a1(), "B2");
|
||||
// Re-protecting without the flag keeps the sheet's selection rights.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
|
||||
assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked);
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
|
||||
let mut s = book();
|
||||
@@ -426,6 +450,32 @@ mod tests {
|
||||
s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_structure_blocks_rename_move_copy_hide_and_unhide_sheet() {
|
||||
let mut s = book();
|
||||
let names = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
let before = names(&s);
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
assert!(s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).is_err());
|
||||
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1})).is_err());
|
||||
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).is_err());
|
||||
assert!(s.execute("sheet.hide", json!({"sheet": 1})).is_err());
|
||||
assert_eq!(names(&s), before);
|
||||
assert!(s.doc().unwrap().wb.sheets.iter().all(|x| x.visibility == Visibility::Visible));
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 3);
|
||||
s.execute("sheet.hide", json!({"sheet": 1})).unwrap();
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
assert!(s.execute("sheet.unhide", json!({"sheet": 1})).is_err());
|
||||
assert!(s.execute("sheet.unhide", json!({})).is_err());
|
||||
assert_eq!(s.doc().unwrap().wb.sheets[1].visibility, Visibility::Hidden);
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
s.execute("sheet.unhide", json!({"sheet": 1})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_workbook_with_a_password_really_verifies() {
|
||||
let mut s = book();
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
|
||||
let key = if axis == Axis::Rows { "rows" } else { "cols" };
|
||||
let r = match str_param(p, key).or_else(|| str_param(p, "range")) {
|
||||
Some(t) => RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
|
||||
Some(t) => parse_lines(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
|
||||
None => s.doc()?.selection.current(),
|
||||
};
|
||||
let (a, b) = if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) };
|
||||
@@ -211,10 +211,19 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
if let Some(pa) = sh.print.print_area {
|
||||
sh.print.print_area = map_range(pa);
|
||||
}
|
||||
// PivotTables follow their report cells. A deleted anchor line collapses onto the line that
|
||||
// takes its place; a report deleted entirely has no last area until the next refresh.
|
||||
for pt in sh.pivots.iter_mut() {
|
||||
pt.anchor = map_cell(pt.anchor).unwrap_or_else(|| match axis {
|
||||
Axis::Rows => CellRef::new(at, pt.anchor.col),
|
||||
Axis::Cols => CellRef::new(pt.anchor.row, at),
|
||||
});
|
||||
pt.last_range = pt.last_range.and_then(map_range);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
|
||||
fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
super::edit::rewrite_all_formulas(wb, target, e);
|
||||
for si in 0..wb.sheets.len() {
|
||||
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||
@@ -226,6 +235,8 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
}
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
// The source block moves with its cells; one that was deleted is dropped.
|
||||
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
@@ -235,6 +246,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
sp.source = fix(&sp.source);
|
||||
}
|
||||
// PivotTable sources (a range, table or defined name) are only rewritten when the edit
|
||||
// moves them, so table and name sources keep their text.
|
||||
for pt in sh.pivots.iter_mut() {
|
||||
let body = pt.source.trim().trim_start_matches('=');
|
||||
if let Ok(expr) = gridcraft_formula::parse(body) {
|
||||
let before = gridcraft_formula::print(&expr);
|
||||
let after = gridcraft_formula::print(&gridcraft_formula::adjust::adjust(expr, &host, target, e));
|
||||
if after != before {
|
||||
pt.source = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +329,7 @@ fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
|
||||
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
|
||||
t.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
@@ -469,6 +492,15 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
|
||||
check_table_shift(sh, &e)?;
|
||||
// Refuse to push data off the sheet: cells in the last `width`/`height` positions would be dropped.
|
||||
let doomed = if shift == "right" {
|
||||
RangeRef::new(CellRef::new(r.start.row, r.start.col.max(MAX_COLS.saturating_sub(r.width()))), CellRef::new(r.end.row, MAX_COLS - 1))
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
|
||||
};
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
let right = shift == "right";
|
||||
let out = edit(s, |cx| {
|
||||
@@ -750,7 +782,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
*sc -= 1;
|
||||
}
|
||||
// Names referring to the sheet become #REF!.
|
||||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||||
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order);
|
||||
if ne != e {
|
||||
n.formula = gridcraft_formula::print(&ne);
|
||||
@@ -771,6 +803,9 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
let i = target_sheet(s, p)?;
|
||||
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { return Err(bad("sheet.rename", "missing `name`")) };
|
||||
s.doc()?.wb.check_sheet_name(&name, Some(i)).map_err(EngineError::Other)?;
|
||||
@@ -812,7 +847,7 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||||
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
|
||||
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
}
|
||||
}
|
||||
@@ -822,6 +857,9 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
let i = target_sheet(s, p)?;
|
||||
let copy = bool_param(p, "copy").unwrap_or(false);
|
||||
let to = p.get("to").and_then(Json::as_u64).map(|v| v as usize).ok_or_else(|| bad("sheet.move", "missing `to`"))?;
|
||||
@@ -851,7 +889,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.iter()
|
||||
.filter(|nm| nm.scope == Some(i))
|
||||
.map(|nm| {
|
||||
let formula = gridcraft_formula::parse(&nm.formula)
|
||||
let formula = gridcraft_formula::parse_name(&nm.formula)
|
||||
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name)))
|
||||
.unwrap_or_else(|_| nm.formula.clone());
|
||||
gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() }
|
||||
@@ -899,6 +937,9 @@ fn tab_color(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
let i = target_sheet(s, p)?;
|
||||
if s.doc()?.wb.sheets.iter().filter(|s| s.visibility == Visibility::Visible).count() <= 1 {
|
||||
return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into()));
|
||||
@@ -913,6 +954,9 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn unhide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
if p.get("sheet").is_none() {
|
||||
let hidden: Vec<String> = s.doc()?.wb.sheets.iter().filter(|s| s.visibility != Visibility::Visible).map(|s| s.name.clone()).collect();
|
||||
if hidden.is_empty() {
|
||||
@@ -936,17 +980,18 @@ fn activate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"sheet": i, "name": d.wb.active().map(|s| s.name.clone())}))
|
||||
}
|
||||
|
||||
fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
|
||||
fn step_sheet(s: &mut Session, dir: isize) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let n = d.wb.sheets.len() as i64;
|
||||
let mut i = d.wb.active_sheet as i64;
|
||||
for _ in 0..n {
|
||||
i = (i + dir).clamp(0, n - 1);
|
||||
if d.wb.sheet(i as usize).is_some_and(|s| s.visibility == Visibility::Visible) {
|
||||
break;
|
||||
let mut i = d.wb.active_sheet;
|
||||
while let Some(next) = i.checked_add_signed(dir) {
|
||||
let Some(sheet) = d.wb.sheet(next) else { break };
|
||||
if sheet.visibility == Visibility::Visible {
|
||||
s.doc_mut()?.wb_switch_sheet(next);
|
||||
return ok();
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
s.doc_mut()?.wb_switch_sheet(i as usize);
|
||||
// No visible sheet in this direction: keep the current sheet and its selection.
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -967,7 +1012,7 @@ pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
||||
|
||||
/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional
|
||||
/// format, validation, table, filter and print-area ranges, notes, links and sparklines.
|
||||
fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start);
|
||||
sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect();
|
||||
for cf in sh.cond_formats.iter_mut() {
|
||||
|
||||
@@ -97,11 +97,21 @@ fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) {
|
||||
}
|
||||
|
||||
fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// A table named by `table` is found on any sheet, as the other table commands find it, and the slicer goes on that
|
||||
// table's sheet. Looked up on the active sheet only, a named table on another sheet gave "Select a cell inside a
|
||||
// table first, or pass `table`" although `table` was passed.
|
||||
let named = match str_param(p, "table") {
|
||||
Some(name) => Some(s.doc()?.wb.table(name).ok_or_else(|| bad("insert.slicer", format!("no table named `{name}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let sheet = match named {
|
||||
Some((si, _)) => si,
|
||||
None => target_sheet(s, p)?,
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let t = match str_param(p, "table") {
|
||||
Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)),
|
||||
let t = match named {
|
||||
Some((_, ti)) => sh.tables.get(ti),
|
||||
None => sh.table_at(d.selection.active),
|
||||
}
|
||||
.ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?;
|
||||
@@ -119,7 +129,7 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
};
|
||||
let table = t.name.clone();
|
||||
let at = cell_param(s, p, "at")?
|
||||
let at = cell_param_on(s, p, "at", sheet)?
|
||||
.unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
|
||||
let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10);
|
||||
let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32;
|
||||
|
||||
+201
-56
@@ -1863,13 +1863,11 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step;
|
||||
/// the model's sheet stays active. Returns the new sheets' names.
|
||||
fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<String>> {
|
||||
/// Builds every report before the solution or saved model can change.
|
||||
fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
|
||||
if kinds.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
@@ -1886,41 +1884,48 @@ fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Re
|
||||
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
|
||||
built.push((name, lines));
|
||||
}
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// Inserts prepared reports before the model's sheet, which stays active.
|
||||
fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<String> {
|
||||
let names: Vec<String> = built.iter().map(|(n, _)| n.clone()).collect();
|
||||
let at = inp.model.sheet;
|
||||
let count = built.len();
|
||||
edit(s, |cx| {
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
if count == 0 {
|
||||
return names;
|
||||
}
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
names
|
||||
}
|
||||
|
||||
fn insert_report_selections(s: &mut Session, at: usize, count: usize) {
|
||||
if let Ok(d) = s.doc_mut() {
|
||||
for i in 0..count {
|
||||
d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// `data.solverReport`: reports for a solution of the sheet's saved model.
|
||||
@@ -1973,7 +1978,9 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
|
||||
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
|
||||
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
|
||||
let sheets = write_reports(s, &kinds, &inp)?;
|
||||
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
|
||||
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
|
||||
insert_report_selections(s, model.sheet, sheets.len());
|
||||
Ok(json!({"sheets": sheets}))
|
||||
}
|
||||
|
||||
@@ -2068,41 +2075,53 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
|
||||
let changed = keep && solution.is_some();
|
||||
if changed || save {
|
||||
let sheet = model.sheet;
|
||||
let vars = model.vars.clone();
|
||||
let m = model.clone();
|
||||
edit(s, |cx| {
|
||||
if changed && let Some(x) = &solution {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
}
|
||||
for c in &vars {
|
||||
cx.touch(sheet, *c);
|
||||
}
|
||||
}
|
||||
if save {
|
||||
save_model(&mut cx.wb, &m);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let built = match (&reports, &solution) {
|
||||
(Some(kinds), Some(x)) => {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
|
||||
prepare_reports(s.doc()?, kinds, &inp)?
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let count = built.len();
|
||||
let before = s.doc()?.wb.clone();
|
||||
let mut out = json!({
|
||||
"status": status,
|
||||
"message": message,
|
||||
"objective": objective,
|
||||
"values": values,
|
||||
"original": original,
|
||||
"model": model_params(&s.doc()?.wb, &model),
|
||||
"model": model_params(&before, &model),
|
||||
"evaluations": evaluations,
|
||||
"changed": changed,
|
||||
});
|
||||
// Reports describe a solution; they are part of the solve's undo step.
|
||||
if let (Some(kinds), Some(x)) = (reports, solution) {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget };
|
||||
out["reports"] = json!(write_reports(s, &kinds, &inp)?);
|
||||
let names = if changed || save || count > 0 {
|
||||
let sheet = model.sheet;
|
||||
edit(s, |cx| {
|
||||
if changed && let Some(x) = &solution {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in model.vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
}
|
||||
for c in &model.vars {
|
||||
cx.touch(sheet + count, *c);
|
||||
}
|
||||
}
|
||||
if save {
|
||||
save_model(&mut cx.wb, &model);
|
||||
}
|
||||
if count > 0 {
|
||||
// Inserting sheets makes this structural, so retain the variable edit's array check.
|
||||
check_arrays(&before, &cx.wb)?;
|
||||
}
|
||||
Ok(write_reports(cx, sheet, built))
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
insert_report_selections(s, model.sheet, count);
|
||||
if reports.is_some() && solution.is_some() {
|
||||
out["reports"] = json!(names);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -2390,6 +2409,132 @@ mod tests {
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_solver_reports_preserve_the_document_and_history() {
|
||||
for (protected, method, constraints, reports, error) in [
|
||||
(true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"),
|
||||
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"),
|
||||
(false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"),
|
||||
] {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}),
|
||||
)
|
||||
.unwrap();
|
||||
set(&mut s, &[("C1", "redo")]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected;
|
||||
d.saved = d.wb.clone();
|
||||
let before = d.clone();
|
||||
let journal = s.journal.clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method,
|
||||
"reports": reports, "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}");
|
||||
assert!(!d.is_dirty());
|
||||
assert_eq!(d.revision, before.revision);
|
||||
assert_eq!(d.selection, before.selection);
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (a, b) in after.iter().zip(before) {
|
||||
assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb));
|
||||
assert_eq!((&a.label, &a.selection), (&b.label, &b.selection));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_and_optional_solution_and_model_share_one_undo() {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex",
|
||||
"reports": ["answer"], "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out["status"], "optimal");
|
||||
assert_eq!(out["reports"], json!(["Answer Report 1"]));
|
||||
assert_eq!(out["model"]["objective"], "Sheet1!B1");
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
assert_eq!(saved(&s), if save { "solver_opt" } else { "" });
|
||||
assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1);
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1");
|
||||
let r = row_of(&s, "Answer Report 1", "$A$1") + 1;
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_keep_row_autofit_on_the_model_sheet() {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().row_height(0);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.name, "Sheet1");
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0));
|
||||
assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_report_insertion_keeps_variable_edit_guards() {
|
||||
for array in [false, true] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]);
|
||||
let error = if array {
|
||||
s.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
|
||||
"part of an array"
|
||||
} else {
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
"protected sheet"
|
||||
};
|
||||
let before = s.doc().unwrap().clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
|
||||
assert_eq!(d.undo.len(), before.undo.len());
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_report_of_a_nonlinear_model() {
|
||||
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the
|
||||
|
||||
@@ -13,19 +13,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Freeze Panes",
|
||||
["View", "Window", "Freeze Panes"],
|
||||
None,
|
||||
"{cell?: \"B2\" (rows above and columns left of it freeze)}",
|
||||
"{sheet?, cell?: \"B2\" (rows above and columns left of it freeze); without `cell`, unfreezes a frozen sheet}",
|
||||
has_doc,
|
||||
freeze_panes
|
||||
),
|
||||
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, Some((1, 0)))),
|
||||
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(
|
||||
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
|
||||
s,
|
||||
target_sheet(s, p)?,
|
||||
Some((1, 0))
|
||||
)),
|
||||
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
|
||||
s,
|
||||
target_sheet(s, p)?,
|
||||
Some((0, 1))
|
||||
)),
|
||||
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, None)),
|
||||
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
|
||||
s,
|
||||
target_sheet(s, p)?,
|
||||
None
|
||||
)),
|
||||
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
|
||||
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
|
||||
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
|
||||
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
|
||||
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
|
||||
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
|
||||
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
|
||||
@@ -35,7 +45,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
|
||||
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
|
||||
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
|
||||
cmd!(
|
||||
"pageLayout.size",
|
||||
"Size",
|
||||
["Page Layout", "Page Setup"],
|
||||
None,
|
||||
"{paper: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes)}",
|
||||
has_doc,
|
||||
paper
|
||||
),
|
||||
cmd!(
|
||||
"pageLayout.margins",
|
||||
"Margins",
|
||||
@@ -113,23 +131,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
if sh.freeze.is_some() && p.get("cell").is_none() {
|
||||
return set_freeze(s, None);
|
||||
return set_freeze(s, si, None);
|
||||
}
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let at = match cell_param_on(s, p, "cell", si)? {
|
||||
Some(c) => c,
|
||||
None if si == d.wb.active_sheet => d.selection.active,
|
||||
// The selection belongs to the active sheet.
|
||||
None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")),
|
||||
};
|
||||
let top = sh.view_top_left;
|
||||
let rows = at.row.saturating_sub(top.row);
|
||||
let cols = at.col.saturating_sub(top.col);
|
||||
let _ = (rows, cols);
|
||||
let (rows, cols) = (at.row, at.col);
|
||||
set_freeze(s, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
|
||||
set_freeze(s, si, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
|
||||
}
|
||||
|
||||
fn set_freeze(s: &mut Session, f: Option<(u32, u32)>) -> Result<Json> {
|
||||
fn set_freeze(s: &mut Session, si: usize, f: Option<(u32, u32)>) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
cx.sheet_mut(si)?.freeze = f;
|
||||
Ok(json!({"freeze": f}))
|
||||
})
|
||||
@@ -152,6 +175,16 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
|
||||
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use gridcraft_model::ShowObjects;
|
||||
edit(s, |cx| {
|
||||
let on = bool_param(p, "on").unwrap_or(cx.wb.show_objects == ShowObjects::None);
|
||||
cx.wb.show_objects = if on { ShowObjects::All } else { ShowObjects::None };
|
||||
Ok(json!({"on": on}))
|
||||
})
|
||||
}
|
||||
|
||||
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
|
||||
let d = s.doc_mut()?;
|
||||
@@ -232,13 +265,31 @@ fn print_area(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn print_titles(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let rows = str_param(p, "rows").and_then(RangeRef::parse).map(|r| (r.start.row, r.end.row));
|
||||
let cols = str_param(p, "cols").and_then(RangeRef::parse).map(|r| (r.start.col, r.end.col));
|
||||
// An absent key leaves that axis alone, and an empty or null value clears it — the same
|
||||
// reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a
|
||||
// key that was never sent used to wipe the other axis.
|
||||
let axis = |key: &str, is_cols: bool| -> Result<Option<Option<(u32, u32)>>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Json::Null) => Ok(Some(None)),
|
||||
Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
|
||||
Some(Json::String(t)) => match RangeRef::parse(t) {
|
||||
Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))),
|
||||
None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))),
|
||||
},
|
||||
Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))),
|
||||
}
|
||||
};
|
||||
let (rows, cols) = (axis("rows", false)?, axis("cols", true)?);
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let pr = &mut cx.sheet_mut(si)?.print;
|
||||
pr.title_rows = rows;
|
||||
pr.title_cols = cols;
|
||||
if let Some(t) = rows {
|
||||
pr.title_rows = t;
|
||||
}
|
||||
if let Some(t) = cols {
|
||||
pr.title_cols = t;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
+104
-34
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Advanced…",
|
||||
["Data", "Sort & Filter"],
|
||||
None,
|
||||
"{range? (list with headers; default current region), criteria: \"H1:I3\" (header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
|
||||
"{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
|
||||
has_doc,
|
||||
advanced_filter
|
||||
),
|
||||
@@ -488,8 +488,15 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
let input = super::edit::json_to_input(v);
|
||||
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
|
||||
// Stored numbers are values, not typed text: entering them through the parser would
|
||||
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
|
||||
let cell = match v.as_f64() {
|
||||
Some(n) => {
|
||||
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
|
||||
Some(Cell { value: Value::Number(n), formula: None, style })
|
||||
}
|
||||
None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.set_cell(*c, cell),
|
||||
@@ -795,41 +802,45 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
|
||||
}
|
||||
};
|
||||
let (csi, crit) = range_arg(s, p, "criteria", CMD)?;
|
||||
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
|
||||
if list.height() < 1 || list.count() > 50_000_000 {
|
||||
return Err(bad(CMD, "the list range is too large"));
|
||||
}
|
||||
if crit.width() > 1024 || crit.height() > 10_000 {
|
||||
if let Some((_, crit)) = criteria
|
||||
&& (crit.width() > 1024 || crit.height() > 10_000)
|
||||
{
|
||||
return Err(bad(CMD, "the criteria range is too large"));
|
||||
}
|
||||
let wb = &d.wb;
|
||||
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
let headers: Vec<String> =
|
||||
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
|
||||
let first_data = list.start.row + 1;
|
||||
// Criteria rows → conditions.
|
||||
let mut rows: Vec<Vec<Cond>> = Vec::new();
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
if let Some((csi, crit)) = criteria {
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
}
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
let matches_row = |row: u32| -> bool {
|
||||
if rows.is_empty() {
|
||||
@@ -845,7 +856,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Cond::Computed(e, at) => {
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, csi, *at, &shifted);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
|
||||
matches!(v.scalar().to_bool(), Ok(true))
|
||||
}
|
||||
})
|
||||
@@ -867,28 +878,30 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(t) = str_param(p, "copyTo") {
|
||||
let (dsi, dest) = parse_range(wb, t, CMD)?;
|
||||
let dest = dest.start;
|
||||
let extract = RangeRef::new(dest, dest.offset_clamped(list.height() as i64 - 1, list.width() as i64 - 1));
|
||||
if dsi == si && extract.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let mut rows_out: Vec<u32> = vec![list.start.row];
|
||||
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
|
||||
let end =
|
||||
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
|
||||
let out_range = RangeRef::new(dest, end);
|
||||
if dsi == si && out_range.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let cells: Vec<(CellRef, Cell)> = rows_out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(list.start.col..=list.end.col).filter_map(move |c| {
|
||||
(list.start.col..=list.end.col).map(move |c| {
|
||||
let src = CellRef::new(*row, c);
|
||||
let to = dest.offset(i as i64, (c - list.start.col) as i64)?;
|
||||
// The full output footprint was checked above.
|
||||
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
|
||||
let style = sh.style_id(src);
|
||||
Some((to, Cell { value: sh.value(src), formula: None, style }))
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let out_range = RangeRef::new(dest, dest.offset_clamped(rows_out.len() as i64 - 1, list.width() as i64 - 1));
|
||||
edit(s, |cx| {
|
||||
let shm = cx.sheet_mut(dsi)?;
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
|
||||
for c in &old {
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
@@ -1199,6 +1212,17 @@ mod tests {
|
||||
assert!(!r.converged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_show_restores_a_percentage_cells_stored_number() {
|
||||
let mut s = s();
|
||||
s.execute("home.numberFormat", json!({"range": "B2", "format": "Percentage"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "5"})).unwrap();
|
||||
s.execute("data.scenarioAdd", json!({"name": "Low", "changing": "B2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "9"})).unwrap();
|
||||
s.execute("data.scenarioShow", json!({"name": "Low"})).unwrap();
|
||||
assert_eq!(n(&s, "B2"), 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
let mut s = s();
|
||||
@@ -1317,6 +1341,52 @@ mod tests {
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_preserves_cells_beyond_result() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap();
|
||||
assert_eq!(r["range"], "K1:L2");
|
||||
assert_eq!(v(&s, "K2"), Value::from("B"));
|
||||
assert_eq!(v(&s, "K4"), Value::from("Keep me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_rejects_overflow_without_mutation() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
for dest in ["XFD1", "K1048576"] {
|
||||
s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap();
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err());
|
||||
assert_eq!(v(&s, dest), Value::from("Keep me"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap();
|
||||
assert_eq!(r["range"], "B1:C2");
|
||||
assert_eq!(v(&s, "C4"), Value::from("Name"));
|
||||
assert_eq!(v(&s, "C2"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_unique_without_criteria() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap();
|
||||
assert_eq!(r["matched"], 2);
|
||||
assert_eq!(r["range"], "C1:C3");
|
||||
assert_eq!(v(&s, "C2"), Value::from("A"));
|
||||
assert_eq!(v(&s, "C3"), Value::from("B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn criteria_semantics() {
|
||||
use super::criterion_matches as m;
|
||||
|
||||
@@ -33,6 +33,25 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
format_value(v, &parsed(code), wb.date_system)
|
||||
}
|
||||
|
||||
/// Whether typing `t` back into a General cell would not give the text `t` again (it would parse
|
||||
/// as a number, boolean, error, date or formula, or lose a leading apostrophe). Such text is shown
|
||||
/// for editing with a leading `'`, as Excel does for text carrying a quote prefix, so that
|
||||
/// committing the editor unchanged keeps the cell as text.
|
||||
fn text_needs_quote_prefix(t: &str, sys: gridcraft_core::date::DateSystem) -> bool {
|
||||
if t.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut chars = t.chars();
|
||||
let (first, second) = (chars.next(), chars.next());
|
||||
if matches!(first, Some('=' | '\'')) {
|
||||
return true;
|
||||
}
|
||||
if matches!(first, Some('+' | '-')) && second.is_some_and(|c| c.is_ascii_alphabetic() || c == '(') {
|
||||
return true;
|
||||
}
|
||||
!matches!(gridcraft_core::parse::parse_input(t, sys).value, Value::Text(ref x) if x.as_ref() == t)
|
||||
}
|
||||
|
||||
/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
|
||||
/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
|
||||
/// serial. When the formatted text would not read back as the same date (for example
|
||||
@@ -41,6 +60,13 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let raw = sheet.input_text(c);
|
||||
let Some(cell) = sheet.cell(c) else { return raw };
|
||||
if let Value::Text(t) = &cell.value
|
||||
&& cell.formula.is_none()
|
||||
&& wb.styles.get(cell.style).num_fmt.as_str() != "@"
|
||||
&& text_needs_quote_prefix(t, wb.date_system)
|
||||
{
|
||||
return format!("'{raw}");
|
||||
}
|
||||
let Value::Number(n) = &cell.value else { return raw };
|
||||
let code = wb.styles.get(cell.style).num_fmt.as_str();
|
||||
if cell.formula.is_some() || !parsed(code).is_date() {
|
||||
|
||||
+118
-13
@@ -19,7 +19,17 @@ pub enum FillMode {
|
||||
Alternate,
|
||||
Copy,
|
||||
Formats,
|
||||
/// Fill Without Formatting: extends like a plain drag, the targets keep their formats.
|
||||
ValuesOnly,
|
||||
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
|
||||
Extend,
|
||||
}
|
||||
|
||||
impl FillMode {
|
||||
/// The modes that continue the source's series rather than copy it.
|
||||
fn extends_series(self) -> bool {
|
||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in custom lists.
|
||||
@@ -35,6 +45,18 @@ fn lists() -> Vec<Vec<String>> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The most cells one fill may write: the size cap the fill commands put on their ranges.
|
||||
pub const MAX_FILL_CELLS: u64 = 5_000_000;
|
||||
|
||||
/// Whether `target` is a shape Excel's AutoFill accepts for `src`: it contains the source and the
|
||||
/// source sits in one of its corners. Excel refuses a target that doesn't contain the source
|
||||
/// (`2:3` into `A:A`, `H4:H9` into `D:F`) or holds it away from every corner (`H4:H9` into `H:H`).
|
||||
pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
|
||||
target.contains_range(&src)
|
||||
&& (target.start.row == src.start.row || target.end.row == src.end.row)
|
||||
&& (target.start.col == src.start.col || target.end.col == src.end.col)
|
||||
}
|
||||
|
||||
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
|
||||
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
|
||||
let custom = cx.wb.custom_lists.clone();
|
||||
@@ -46,6 +68,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
} else {
|
||||
target.end.col > src.end.col || target.start.col == src.start.col
|
||||
};
|
||||
// Every source lane writes one cell per step past the source: bound that product, not the
|
||||
// source and target separately (whole rows into whole columns was ~17 billion writes).
|
||||
let (lane_count, steps) = if vertical {
|
||||
let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };
|
||||
(src.width(), steps)
|
||||
} else {
|
||||
let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };
|
||||
(src.height(), steps)
|
||||
};
|
||||
if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS {
|
||||
return Err(crate::EngineError::Other("The fill is too large.".into()));
|
||||
}
|
||||
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
|
||||
let src_len = if vertical { src.height() } else { src.width() };
|
||||
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
|
||||
@@ -67,15 +101,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
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 pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
||||
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 }
|
||||
match (mode, detected) {
|
||||
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
|
||||
(_, p) => p,
|
||||
}
|
||||
} 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 {
|
||||
let per_cell: Vec<Pattern> = if mode.extends_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()
|
||||
@@ -93,7 +130,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
writes.push((*t, Some(existing)));
|
||||
continue;
|
||||
}
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
|
||||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||||
if let Some(f) = &cell.formula {
|
||||
if let Some(e) = f.expr() {
|
||||
@@ -165,6 +202,7 @@ enum Pattern {
|
||||
last: f64,
|
||||
first: f64,
|
||||
months: i64,
|
||||
sys: gridcraft_core::DateSystem,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -199,7 +237,7 @@ impl Pattern {
|
||||
&& (a.month != b.month || a.year != b.year)
|
||||
{
|
||||
let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64;
|
||||
return Pattern::Months { last, first, months };
|
||||
return Pattern::Months { last, first, months, sys };
|
||||
}
|
||||
}
|
||||
// Least-squares step for 2+ values (Excel uses a linear trend).
|
||||
@@ -273,12 +311,15 @@ impl Pattern {
|
||||
}
|
||||
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let n = base + step * k * sign;
|
||||
// Text numbers come from input and can sit near i64::MAX (`X 9223372036854775807`):
|
||||
// an overflowing series yields nothing — the cell keeps its source value — rather
|
||||
// than wrapping in release or panicking in debug.
|
||||
let n = step.checked_mul(k)?.checked_mul(sign)?.checked_add(base)?;
|
||||
// `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() };
|
||||
let n = if quarter { n.checked_sub(1)?.rem_euclid(4) + 1 } else { n.checked_abs()? };
|
||||
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
|
||||
}
|
||||
Pattern::List { list, last, first, step, upper, lower } => {
|
||||
@@ -297,9 +338,9 @@ impl Pattern {
|
||||
s
|
||||
}))
|
||||
}
|
||||
Pattern::Months { last, first, months } => {
|
||||
Pattern::Months { last, first, months, sys } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
|
||||
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
|
||||
Some(Value::number(s))
|
||||
}
|
||||
}
|
||||
@@ -442,13 +483,13 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||||
// Header row: skip a first row whose target is text and source looks like a header? Keep simple.
|
||||
let mut examples = Vec::new();
|
||||
let mut todo = Vec::new();
|
||||
// Flash Fill works on the text cells show (a date as formatted, not its serial number).
|
||||
for r in top..=bottom {
|
||||
let src = sh.value(CellRef::new(r, src_col)).display();
|
||||
let dst = sh.value(CellRef::new(r, at.col));
|
||||
if dst.is_empty() {
|
||||
let src = crate::display::cell_text(&cx.wb, sh, CellRef::new(r, src_col));
|
||||
if sh.value(CellRef::new(r, at.col)).is_empty() {
|
||||
todo.push((r, src));
|
||||
} else {
|
||||
examples.push((src, dst.display()));
|
||||
examples.push((src, crate::display::cell_text(&cx.wb, sh, CellRef::new(r, at.col))));
|
||||
}
|
||||
}
|
||||
if examples.is_empty() {
|
||||
@@ -603,6 +644,18 @@ fn synthesize(src: &str, dst: &str) -> Vec<Program> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flash_fill_reads_shown_text() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let values = serde_json::json!([["2024-01-15", "15/01/2024"], ["2023-12-03", null]]);
|
||||
s.execute("range.setValues", serde_json::json!({"range": "A1", "values": values})).unwrap();
|
||||
s.execute("selection.set", serde_json::json!({"cell": "B2"})).unwrap();
|
||||
s.execute("data.flashFill", serde_json::json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_programs() {
|
||||
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
|
||||
@@ -652,4 +705,56 @@ mod tests {
|
||||
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", "", "", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_series_near_i64_max_neither_wraps_nor_panics() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "X 0"})).unwrap();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A2", "input": "X 9223372036854775807"})).unwrap();
|
||||
// The detected step is i64::MAX, so the next value overflows i64. The cell keeps its
|
||||
// source value instead of panicking in debug or wrapping in release.
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "A1:A2", "target": "A1:A3"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::new(2, 0)), "X 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofill_refuses_targets_that_do_not_extend_the_source() {
|
||||
// Whole rows into whole columns once filled every source column down the whole sheet
|
||||
// (~17 billion writes) and aborted the process on a 24-51 GB allocation (fuzz seeds 1064,
|
||||
// 1114, 1775). Excel refuses every one of these shapes.
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", serde_json::json!({"range": "A2", "values": [[1, "Item 1"], [2, "Item 2"]]})).unwrap();
|
||||
s.execute("range.setValues", serde_json::json!({"range": "H4", "values": [[10], [20]]})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().cells.len();
|
||||
for (mode, source, target) in [
|
||||
("series", "2:3", "A:A"),
|
||||
("alternate", "2:3", "A:A"),
|
||||
("series", "8:10", "C:E"),
|
||||
("copy", "24:24", "B:D"),
|
||||
("values", "H4:H9", "D:F"),
|
||||
("series", "H4:H9", "H:H"),
|
||||
("series", "A2:A3", "A1:A5"),
|
||||
("series", "A2:A6", "A2:A4"),
|
||||
("series", "B2:B3", "A2:A3"),
|
||||
] {
|
||||
let r = s.execute("edit.autoFill", serde_json::json!({"mode": mode, "source": source, "target": target}));
|
||||
assert!(r.is_err(), "{source} -> {target} should be refused");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.len(), before, "a refused fill writes nothing");
|
||||
// Targets holding the source in a corner still fill, whole rows and diagonal drags included.
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "2:3", "target": "2:6"})).unwrap();
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "A2:A3", "target": "A1:A3"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let text = |a: &str| crate::display::cell_text(&s.doc().unwrap().wb, sh, CellRef::parse(a).unwrap());
|
||||
assert_eq!([text("A1"), text("A6"), text("B6")], ["0", "5", "Item 5"]);
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B5").unwrap(), RangeRef::parse("B3:B5").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("A:C").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B5:C9").unwrap(), RangeRef::parse("B5:D13").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("A1:B4").unwrap()));
|
||||
assert!(!holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("1:1").unwrap()));
|
||||
}
|
||||
}
|
||||
+244
-13
@@ -153,7 +153,12 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
// 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}")))?;
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
|
||||
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
|
||||
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
|
||||
_ => format!("We can't open '{name}': {e}"),
|
||||
})
|
||||
})?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
match kind {
|
||||
@@ -163,7 +168,14 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
if wb.sheets.is_empty() {
|
||||
wb = Workbook::new();
|
||||
}
|
||||
Ok((wb, vec![]))
|
||||
// The file's band placement and cell count are derived state: rebuild them from the
|
||||
// cells themselves, dropping what is outside the sheet grid (#1008).
|
||||
let dropped: usize = wb.sheets.iter_mut().map(|sh| std::sync::Arc::make_mut(sh).cells.rebuild()).sum();
|
||||
let mut warnings = Vec::new();
|
||||
if dropped > 0 {
|
||||
warnings.push(format!("{name}: {dropped} cells outside the 1,048,576 × 16,384 grid were dropped"));
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
@@ -182,6 +194,25 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
}
|
||||
|
||||
/// What to tell the user when one part of an XLSX package unpacks to more than the reader's
|
||||
/// per-part limit: the sizes, why the limit is there, and what gets the data in. The limit is
|
||||
/// per part, so smaller sheets fit; the shared strings are one part for every sheet, so only a
|
||||
/// CSV, which has no parts, helps there.
|
||||
fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
||||
let limit = gridcraft_xlsx::MAX_PART >> 20;
|
||||
let mb = size >> 20;
|
||||
let size = if mb > limit { format!("{mb} MB") } else { format!("more than {limit} MB") };
|
||||
let remedy = if part.contains("worksheets/") {
|
||||
"To open it, split the data across several sheets, since the limit applies to each part, or save the sheet as CSV, which keeps only its values."
|
||||
} else {
|
||||
"To open it, save the data as CSV, which keeps only its values."
|
||||
};
|
||||
format!(
|
||||
"We can't open '{name}': its part {part} unpacks to {size}, over GridCraft's limit of {limit} MB for one part \
|
||||
(a guard against damaged or malicious files). {remedy}"
|
||||
)
|
||||
}
|
||||
|
||||
/// A CSV opens as a sheet named after the file (like Excel).
|
||||
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
|
||||
@@ -216,7 +247,8 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
// Macros travel in hidden names.
|
||||
FileKind::Xlsx => {
|
||||
let named = crate::macros::with_names(wb)?;
|
||||
gridcraft_xlsx::write_xlsx_as(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path))
|
||||
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xls => {
|
||||
@@ -246,6 +278,12 @@ pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
|
||||
std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// The file at `path` as `CELL("filename")` reports it: absolute, so the folder is the one the
|
||||
/// user sees. A workbook that arrived as bytes has no path and keeps only its name.
|
||||
pub fn source_path(path: &str) -> Option<std::path::PathBuf> {
|
||||
std::path::absolute(path).ok()
|
||||
}
|
||||
|
||||
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
|
||||
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
|
||||
/// transferred to other file formats*): number formats applied, never column-width `#####`.
|
||||
@@ -265,7 +303,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
pub(crate) fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
@@ -419,31 +457,56 @@ 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).
|
||||
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
|
||||
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
|
||||
let fixed = !(sh.images.is_empty() && sh.charts.is_empty());
|
||||
let used = sh.used_range();
|
||||
let mut out = String::new();
|
||||
let _ = write!(
|
||||
out,
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt}}td{{border:1px solid #d4d4d4;padding:2px 4px;white-space:nowrap}}</style></head><body><table{}>",
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt{}}}td{{border:1px solid #d4d4d4;padding:{};white-space:nowrap{}}}</style></head><body>",
|
||||
esc(&sh.name),
|
||||
table_dir(sh)
|
||||
if fixed { ";table-layout:fixed" } else { "" },
|
||||
if fixed { "0 4px" } else { "2px 4px" },
|
||||
if fixed { ";overflow:hidden;line-height:1" } else { "" }
|
||||
);
|
||||
if let Some(r) = sh.used_range() {
|
||||
if fixed {
|
||||
out.push_str("<div style=\"position:relative\">");
|
||||
}
|
||||
let _ = write!(out, "<table{}", table_dir(sh));
|
||||
if let (true, Some(r)) = (fixed, used) {
|
||||
let width: f32 = (r.start.col..=r.end.col).map(|c| sh.col_width(c)).sum();
|
||||
let _ = write!(out, " style=\"width:{width}px\"");
|
||||
}
|
||||
out.push('>');
|
||||
if let Some(r) = used {
|
||||
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()
|
||||
)));
|
||||
}
|
||||
if fixed {
|
||||
out.push_str("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
let _ = write!(out, "<col style=\"width:{}px\">", sh.col_width(col));
|
||||
}
|
||||
out.push_str("</colgroup>");
|
||||
}
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
}
|
||||
out.push_str("<tr>");
|
||||
if fixed {
|
||||
let _ = write!(out, "<tr style=\"height:{}px\">", sh.row_height(row));
|
||||
} else {
|
||||
out.push_str("<tr>");
|
||||
}
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) {
|
||||
continue;
|
||||
}
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
@@ -453,14 +516,52 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
.merges
|
||||
.iter()
|
||||
.find(|m| m.start == c)
|
||||
.map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()))
|
||||
.map(|m| {
|
||||
if fixed {
|
||||
let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count();
|
||||
let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count();
|
||||
format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
|
||||
} else {
|
||||
format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
|
||||
}
|
||||
out.push_str("</tr>");
|
||||
}
|
||||
}
|
||||
out.push_str("</table></body></html>");
|
||||
out.push_str("</table>");
|
||||
if fixed {
|
||||
// Sheet units are CSS pixels; the table starts at the used range, not at A1.
|
||||
let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row)));
|
||||
let place = |a: &gridcraft_model::Anchor| {
|
||||
format!(
|
||||
"position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
|
||||
sh.col_left(a.cell.col) - left + f64::from(a.dx),
|
||||
sh.row_top(a.cell.row) - top + f64::from(a.dy),
|
||||
a.width.max(0.0),
|
||||
a.height.max(0.0)
|
||||
)
|
||||
};
|
||||
for im in &sh.images {
|
||||
let _ = write!(
|
||||
out,
|
||||
"<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
|
||||
esc(&im.mime),
|
||||
base64_encode(&im.data),
|
||||
esc(&im.alt),
|
||||
place(&im.anchor)
|
||||
);
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) {
|
||||
out.push_str(&svg);
|
||||
}
|
||||
}
|
||||
out.push_str("</div>");
|
||||
}
|
||||
out.push_str("</body></html>");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -583,6 +684,88 @@ mod tests {
|
||||
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_embeds_images_as_data_uris() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image {
|
||||
id: 1,
|
||||
anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() },
|
||||
data: vec![1, 2, 3].into(),
|
||||
mime: "image/png".into(),
|
||||
alt: "a \"logo\"".into(),
|
||||
});
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
assert!(
|
||||
html.contains("<img src=\"data:image/png;base64,AQID\" alt=\"a "logo"\" style=\"position:absolute;left:0px;top:0px;width:72px;height:36px\"></div></body>"),
|
||||
"{html}"
|
||||
);
|
||||
assert!(html.contains("<div style=\"position:relative\"><table"), "{html}");
|
||||
assert!(!to_html(&Workbook::new(), 0).unwrap().contains("<img"), "no images, no <img>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_places_images_at_their_anchor_using_sheet_geometry() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
|
||||
sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
|
||||
sh.images.push(gridcraft_model::Image {
|
||||
id: 1,
|
||||
anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() },
|
||||
data: vec![1].into(),
|
||||
mime: "image/png".into(),
|
||||
alt: String::new(),
|
||||
});
|
||||
sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0));
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
// The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on.
|
||||
assert!(html.contains("<col style=\"width:100px\">"), "{html}");
|
||||
assert!(html.contains("<tr style=\"height:30px\">"), "{html}");
|
||||
assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}");
|
||||
}
|
||||
|
||||
fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0));
|
||||
sh.images.push(gridcraft_model::Image {
|
||||
id: 1,
|
||||
anchor: gridcraft_model::Anchor {
|
||||
cell: CellRef::parse(anchor).unwrap(),
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
mode: Default::default(),
|
||||
},
|
||||
data: vec![1].into(),
|
||||
mime: "image/png".into(),
|
||||
alt: String::new(),
|
||||
});
|
||||
wb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_places_images_past_hidden_columns_and_rows() {
|
||||
let mut wb = book_with_picture("C3", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0));
|
||||
sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
|
||||
sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
// Column B and row 2 take no room: C3 is one default column and row from A1.
|
||||
assert_eq!(html.matches("<col ").count(), 2, "{html}");
|
||||
assert_eq!(html.matches("<tr ").count(), 2, "{html}");
|
||||
assert!(html.contains("left:64px;top:20px"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_places_images_above_and_left_of_the_table() {
|
||||
let html = to_html(&book_with_picture("A1", (2, 2)), 0).unwrap();
|
||||
// The table starts at C3, so a picture at A1 sits before it instead of failing.
|
||||
assert!(html.contains("left:-128px;top:-40px"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_marks_right_to_left_sheets() {
|
||||
use gridcraft_core::Value;
|
||||
@@ -684,7 +867,7 @@ mod tests {
|
||||
#[test]
|
||||
fn macros_are_saved_only_into_xlsm() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.vba_project = Some(b"vba".to_vec());
|
||||
wb.vba_project = Some(b"vba".to_vec().into());
|
||||
let has_macros = |path: &str| {
|
||||
let bytes = save_bytes(&wb, path).unwrap();
|
||||
gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some()
|
||||
@@ -892,6 +1075,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Where `part`'s entry starts in the central directory of `package`.
|
||||
fn central_entry(package: &[u8], part: &str) -> usize {
|
||||
let mut at = 0;
|
||||
while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) {
|
||||
let entry = at + i;
|
||||
let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize;
|
||||
if &package[entry + 46..entry + 46 + len] == part.as_bytes() {
|
||||
return entry;
|
||||
}
|
||||
at = entry + 46;
|
||||
}
|
||||
panic!("{part} is not in the package");
|
||||
}
|
||||
|
||||
/// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the
|
||||
/// declared size, so no real 512 MB part has to be built.
|
||||
fn declaring(mut package: Vec<u8>, part: &str, size: u32) -> Vec<u8> {
|
||||
let at = central_entry(&package, part);
|
||||
package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes());
|
||||
package
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_over_the_size_limit_says_how_to_get_the_data_in() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big"));
|
||||
let package = save_bytes(&wb, "big.xlsx").unwrap();
|
||||
// The size reported in storytold/gridcraft#1242.
|
||||
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string();
|
||||
assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}");
|
||||
assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}");
|
||||
assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}");
|
||||
assert!(!msg.contains("bytes"), "{msg}");
|
||||
// Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps.
|
||||
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string();
|
||||
assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}");
|
||||
assert!(!msg.contains("several sheets"), "{msg}");
|
||||
// Just over the limit never reads as a size equal to it.
|
||||
let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string();
|
||||
assert!(msg.contains("more than 512 MB"), "{msg}");
|
||||
// A package that fails for another reason keeps its own error, oversized part or not.
|
||||
let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731);
|
||||
let at = central_entry(&broken, "xl/workbook.xml");
|
||||
broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x';
|
||||
let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string();
|
||||
assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_wins_over_a_mismatched_extension() {
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
+109
-12
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod catalog;
|
||||
pub mod cf;
|
||||
mod chart_svg;
|
||||
mod clip_html;
|
||||
pub mod cmd;
|
||||
pub mod display;
|
||||
@@ -69,6 +70,8 @@ pub struct HistoryEntry {
|
||||
pub label: String,
|
||||
pub wb: Arc<Workbook>,
|
||||
pub selection: Selection,
|
||||
/// Estimate of storage this snapshot retains beyond the next workbook version.
|
||||
pub retained_bytes: usize,
|
||||
}
|
||||
|
||||
/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`]
|
||||
@@ -186,6 +189,61 @@ pub struct DocState {
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
const UNDO_LIMIT: usize = 100;
|
||||
/// Past this many entries the journal drops its oldest half.
|
||||
const JOURNAL_MAX_ENTRIES: usize = 10_000;
|
||||
/// Past this many bytes (entries serialized as JSON) the journal drops its oldest entries until
|
||||
/// it is under half. Entries keep their full params so they replay (recorded scripts,
|
||||
/// `engine.journal`), and a `file.open` holds the whole file as base64: without a size cap a
|
||||
/// few opens of a large workbook keep tens of MB alive.
|
||||
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
|
||||
|
||||
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
|
||||
let mut bytes = std::mem::size_of::<Workbook>();
|
||||
for (index, sheet) in older.sheets.iter().enumerate() {
|
||||
// Moving or inserting sheets must not charge unchanged sheets at a new index.
|
||||
if newer.sheets.iter().any(|next| Arc::ptr_eq(sheet, next)) {
|
||||
continue;
|
||||
}
|
||||
bytes = bytes.saturating_add(std::mem::size_of::<gridcraft_model::Sheet>());
|
||||
let empty = gridcraft_model::CellStore::new();
|
||||
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
|
||||
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn push_history(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
|
||||
push_history_with_budget(history, label, wb, next, selection, UNDO_BYTES_LIMIT);
|
||||
}
|
||||
|
||||
fn push_history_with_budget(
|
||||
history: &mut Vec<HistoryEntry>,
|
||||
label: String,
|
||||
wb: Arc<Workbook>,
|
||||
next: &Workbook,
|
||||
selection: Selection,
|
||||
byte_budget: usize,
|
||||
) {
|
||||
push_entry(history, label, wb, next, selection);
|
||||
trim_to_budget(history, byte_budget);
|
||||
}
|
||||
|
||||
/// Adds a step without applying the limits: inside a command they wait for the outermost
|
||||
/// command to collapse its steps (see [`Session::trim_history`]).
|
||||
fn push_entry(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
|
||||
let retained_bytes = history_retained_bytes(&wb, next);
|
||||
history.push(HistoryEntry { label, wb, selection, retained_bytes });
|
||||
}
|
||||
|
||||
/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one
|
||||
/// (even if that one edit exceeds the budget).
|
||||
fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
|
||||
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
|
||||
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
|
||||
total = total.saturating_sub(history.remove(0).retained_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
|
||||
@@ -299,6 +357,13 @@ pub enum UiRequest {
|
||||
OpenUrl(String),
|
||||
}
|
||||
|
||||
/// Width in points of one line of text drawn in a cell font, measured by a front end that has
|
||||
/// the font the grid draws with. With `exact` false the front end may answer with a cheaper
|
||||
/// measure that is never narrower (AutoFit asks for it on columns with many distinct values).
|
||||
/// Commands call it while they run, so a front end must not run commands while holding a lock
|
||||
/// the measure takes (egui's: see `theme::cell_text_width` in the UI crate).
|
||||
pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
docs: Vec<DocState>,
|
||||
@@ -308,6 +373,9 @@ pub struct Session {
|
||||
pub mode: Mode,
|
||||
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
|
||||
pub journal: Vec<(String, Json)>,
|
||||
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
|
||||
journal_sizes: Vec<usize>,
|
||||
journal_bytes: usize,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Format painter source style (sticky when double-clicked).
|
||||
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
|
||||
@@ -347,6 +415,9 @@ pub struct Session {
|
||||
group_epoch: u64,
|
||||
/// The step the last group made: document, its undo length after the step, and the epoch.
|
||||
group_step: Option<(u64, usize, u64)>,
|
||||
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
|
||||
/// [`cmd::format::approx_text_width`].
|
||||
pub text_width: Option<TextWidth>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -401,6 +472,38 @@ impl Session {
|
||||
self.add_document(DocState::new(wb, None, format!("Book{n}")))
|
||||
}
|
||||
|
||||
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
|
||||
/// keeps pointing at the same entry (the steps the trim dropped are lost).
|
||||
fn push_journal(&mut self, id: &str, params: Json) {
|
||||
let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len());
|
||||
// `journal` is public: if it was changed from outside, measure it again.
|
||||
if self.journal_sizes.len() != self.journal.len() {
|
||||
self.journal_sizes = self.journal.iter().map(size).collect();
|
||||
self.journal_bytes = self.journal_sizes.iter().sum();
|
||||
}
|
||||
let entry = (id.to_string(), params);
|
||||
let entry_size = size(&entry);
|
||||
self.journal.push(entry);
|
||||
self.journal_sizes.push(entry_size);
|
||||
self.journal_bytes = self.journal_bytes.saturating_add(entry_size);
|
||||
let len = self.journal.len();
|
||||
let mut n = if len > JOURNAL_MAX_ENTRIES { len - JOURNAL_MAX_ENTRIES / 2 } else { 0 };
|
||||
let mut kept = self.journal_bytes.saturating_sub(self.journal_sizes.iter().take(n).sum());
|
||||
if self.journal_bytes > JOURNAL_MAX_BYTES {
|
||||
// The newest entry stays even when it is larger than the cap on its own.
|
||||
while kept > JOURNAL_MAX_BYTES / 2 && n + 1 < len {
|
||||
kept = kept.saturating_sub(self.journal_sizes.get(n).copied().unwrap_or(0));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
self.journal.drain(..n);
|
||||
self.journal_sizes.drain(..n);
|
||||
self.journal_bytes = kept;
|
||||
self.recording = self.recording.map(|r| r.saturating_sub(n));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
}
|
||||
@@ -475,10 +578,7 @@ impl Session {
|
||||
}
|
||||
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
|
||||
}
|
||||
self.journal.push((id.to_string(), cmd::journal_params(¶ms)));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..5_000);
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
// Inside an undo group, or when the command ended one that was open before it, the group
|
||||
// owns the step. A command that opened and closed its own groups makes one step of its
|
||||
@@ -507,7 +607,7 @@ impl Session {
|
||||
}
|
||||
d.undo.truncate(len);
|
||||
}
|
||||
d.undo.push(HistoryEntry { label, wb, selection: sel });
|
||||
push_entry(&mut d.undo, label, wb, &d.wb, sel);
|
||||
d.redo.clear();
|
||||
}
|
||||
// Inside a group the redo history waits for the group: `group_end` clears it when the
|
||||
@@ -574,7 +674,7 @@ impl Session {
|
||||
d.undo.truncate(g.start);
|
||||
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
|
||||
if changed && !outer {
|
||||
d.undo.push(HistoryEntry { label: g.label, wb: g.wb, selection: g.selection });
|
||||
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
|
||||
self.group_step = Some((g.doc, d.undo.len(), self.group_epoch));
|
||||
d.redo.clear();
|
||||
d.revision += 1;
|
||||
@@ -586,15 +686,12 @@ impl Session {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Drops the oldest undo steps past [`UNDO_LIMIT`]. A document with an open group waits for
|
||||
/// the group to end, because the group remembers where its steps start.
|
||||
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
|
||||
/// an open group waits for the group to end, because the group remembers where its steps start.
|
||||
fn trim_history(&mut self) {
|
||||
let groups = &self.groups;
|
||||
for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) {
|
||||
let excess = d.undo.len().saturating_sub(UNDO_LIMIT);
|
||||
if excess > 0 {
|
||||
d.undo.drain(..excess);
|
||||
}
|
||||
trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ fn scripts_and_vba_stay_as_they_are() {
|
||||
let mut wb = Workbook::new();
|
||||
let scripts = json!({"Fill": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]});
|
||||
wb.names.push(hidden("_sc_scripts", &scripts.to_string()));
|
||||
wb.vba_project = Some(b"\xd0\xcf\x11\xe0 synthetic vbaProject".to_vec());
|
||||
wb.vba_project = Some(std::sync::Arc::new(b"\xd0\xcf\x11\xe0 synthetic vbaProject".to_vec()));
|
||||
wb.macros.push(sample_macro("Totals"));
|
||||
let bytes = crate::io::save_bytes(&wb, "book.xlsm").unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("book.xlsm", &bytes).unwrap();
|
||||
|
||||
@@ -9,7 +9,8 @@ use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gridcraft_core::date::datetime_from_serial;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
pub use gridcraft_core::quote_sheet;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
@@ -47,26 +48,6 @@ impl Source {
|
||||
}
|
||||
}
|
||||
|
||||
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
|
||||
fn split_sheet(r: &str) -> (Option<String>, &str) {
|
||||
match r.rsplit_once('!') {
|
||||
Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body),
|
||||
None => (None, r),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quotes a sheet name for a reference when needed.
|
||||
pub fn quote_sheet(name: &str) -> String {
|
||||
if !name.is_empty()
|
||||
&& name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.')
|
||||
&& !name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
{
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("'{}'", name.replace('\'', "''"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute reference text for a range on a sheet: `Sheet1!$A$1:$E$20`.
|
||||
pub fn range_text(sheet_name: &str, r: RangeRef) -> String {
|
||||
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
|
||||
@@ -1511,7 +1492,7 @@ pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
|
||||
};
|
||||
let (sheet, name) = t.rsplit_once('!')?;
|
||||
let sheet = sheet.trim();
|
||||
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
|
||||
let sheet = gridcraft_core::unquote_sheet(sheet);
|
||||
let name = name.trim();
|
||||
if sheet.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
@@ -1567,6 +1548,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
|
||||
@@ -108,6 +108,7 @@ fn sales(s: &mut Session) -> bool {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
|
||||
@@ -63,9 +63,13 @@ impl Selection {
|
||||
pub fn a1(&self) -> String {
|
||||
self.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
/// Expands areas so merged cells are included whole.
|
||||
/// Expands areas so merged cells are included whole. Whole columns and whole rows stay as
|
||||
/// they are: clicking column B's header selects just B even when B1:C1 is merged, as in Excel.
|
||||
pub fn expand_merges(&mut self, sheet: &Sheet) {
|
||||
for r in self.ranges.iter_mut() {
|
||||
if r.is_full_cols() || r.is_full_rows() {
|
||||
continue;
|
||||
}
|
||||
let mut cur = *r;
|
||||
loop {
|
||||
let mut grown = cur;
|
||||
|
||||
+1116
-1
File diff suppressed because it is too large.
Load diff
@@ -100,3 +100,26 @@ fn autofit_filter_width_is_undoable_without_changing_selection() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_measures_each_distinct_text_once_and_quickly_past_the_limit() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
// 2,500 distinct texts, each in two cells.
|
||||
let values: Vec<_> = (0..5000).map(|i| json!([format!("v{}", i % 2500)])).collect();
|
||||
s.execute("range.setValues", json!({"range":"A1", "values": values})).unwrap();
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = calls.clone();
|
||||
s.text_width = Some(Arc::new(move |text: &str, _: &gridcraft_engine::model::Font, exact: bool| {
|
||||
seen.lock().unwrap().push((text.to_owned(), exact));
|
||||
if text == "v2499" { 300.0 } else { 10.0 }
|
||||
}));
|
||||
s.execute("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap();
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 2500, "one measure per distinct text");
|
||||
assert_eq!(calls.iter().filter(|(_, exact)| *exact).count(), 2000);
|
||||
assert!(calls.iter().skip(2000).all(|(_, exact)| !exact));
|
||||
// The quick measures still size the column.
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 306.0);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! #1999: a conditional-format rule whose formula doesn't parse must be rejected at
|
||||
//! creation (Excel's object model refuses it too), not saved and poison the file.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cf_formulas_are_rejected() {
|
||||
for rule in [
|
||||
// exact repros from the issue's measured table
|
||||
json!({"type": "expression", "formula": "=A1>", "preset": "lightRedFill"}),
|
||||
json!({"type": "cellIs", "operator": "greaterThan", "value": "=1+", "preset": "lightRedFill"}),
|
||||
json!({"type": "dataBar", "min": {"type": "formula", "value": "1+"}}),
|
||||
// more garbage that the parser rejects
|
||||
json!({"type": "expression", "formula": "=IF(A1", "preset": "lightRedFill"}),
|
||||
json!({"type": "cellIs", "operator": "equal", "value": "=((A1", "preset": "lightRedFill"}),
|
||||
// multiple leading '=' on a stored-as-typed threshold: the writer strips one,
|
||||
// the rest must still parse (#1999 review)
|
||||
json!({"type": "dataBar", "min": {"type": "formula", "value": "===A1"}}),
|
||||
json!({"type": "dataBar", "max": {"type": "formula", "value": "==1+"}}),
|
||||
] {
|
||||
let err = session()
|
||||
.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": rule.clone()}))
|
||||
.expect_err("invalid formula {rule} must be rejected");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.to_lowercase().contains("formula"), "error should mention the formula: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cf_formulas_still_work() {
|
||||
let mut s = session();
|
||||
// bare comparison without '=' (the issue: this one saves fine today)
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "expression", "formula": "A1>5", "preset": "lightRedFill"}}))
|
||||
.unwrap();
|
||||
// leading '=' is accepted and stripped
|
||||
s.execute("home.conditionalFormat", json!({"range": "B1:B5", "rule": {"type": "expression", "formula": "=B1>5", "preset": "lightRedFill"}}))
|
||||
.unwrap();
|
||||
// cellIs numeric and text values are not formulas, must pass untouched
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "C1:C5", "rule": {"type": "cellIs", "operator": "greaterThan", "value": "5", "preset": "lightRedFill"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "D1:D5", "rule": {"type": "cellIs", "operator": "equal", "value": "hello there", "preset": "lightRedFill"}}),
|
||||
)
|
||||
.unwrap();
|
||||
// dataBar formula thresholds that parse
|
||||
s.execute("home.conditionalFormat", json!({"range": "E1:E5", "rule": {"type": "dataBar", "min": {"type": "formula", "value": "MIN(E1:E5)"}, "max": {"type": "formula", "value": "MAX(E1:E5)"}}}))
|
||||
.unwrap();
|
||||
// non-formula rule types are untouched
|
||||
s.execute("home.conditionalFormat", json!({"range": "F1:F5", "rule": {"type": "duplicate", "preset": "lightRedFill"}})).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Chart series that point at workbook-scoped defined names. Excel writes these as `[0]!Name`
|
||||
//! in `<c:f>`; they used to read back verbatim and evaluate to `#NAME?`, so a chart
|
||||
//! driven by dynamic (OFFSET) names opened blank with a single `#NAME?` category.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use gridcraft_engine::{Session, io};
|
||||
use gridcraft_model::Workbook;
|
||||
use serde_json::json;
|
||||
|
||||
const TASKS: usize = 6;
|
||||
|
||||
/// A plan table, OFFSET names over its structured references (and names built on names; one
|
||||
/// scoped to the sheet), and a stacked bar chart whose series name those names.
|
||||
fn plan() -> Workbook {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut rows = vec![json!(["Task", "Start", "Done", "Left"])];
|
||||
for i in 0..TASKS {
|
||||
rows.push(json!([format!("Task {}", i + 1), (45_200 + 7 * i).to_string(), (i + 1).to_string(), (TASKS - i).to_string()]));
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
let range = format!("A1:D{}", TASKS + 1);
|
||||
s.execute("insert.table", json!({"range": range, "header": true, "name": "Plan"})).unwrap();
|
||||
for (name, refers, scope) in [
|
||||
("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)", "Workbook"),
|
||||
("TaskStart", "=OFFSET(TaskNames,0,1)", "Workbook"),
|
||||
("TaskDone", "=OFFSET(TaskNames,0,2)", "Workbook"),
|
||||
("TaskLeft", "=OFFSET(TaskNames,0,3)", "Sheet1"),
|
||||
("LeftTitle", "=Sheet1!$D$1", "Workbook"),
|
||||
("DoneTitle", "=Sheet1!$C$1", "Sheet1"),
|
||||
] {
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
|
||||
}
|
||||
s.execute("insert.chart", json!({"range": range, "type": "bar", "subtype": "stacked"})).unwrap();
|
||||
let mut wb = Workbook::clone(&s.doc().unwrap().wb);
|
||||
let chart = &mut wb.sheet_mut(0).unwrap().charts[0];
|
||||
assert_eq!(chart.series.len(), 3);
|
||||
for (ser, (name, values)) in
|
||||
chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!DoneTitle", "TaskDone"), ("LeftTitle", "TaskLeft")])
|
||||
{
|
||||
ser.name = Some(name.into());
|
||||
ser.categories = Some("TaskNames".into());
|
||||
ser.values = values.into();
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
fn chart_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut f = z.by_name("xl/charts/chart1.xml").unwrap();
|
||||
let mut xml = String::new();
|
||||
f.read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to its chart part.
|
||||
fn edit_chart(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if name == "xl/charts/chart1.xml" {
|
||||
data = edit(String::from_utf8(data).unwrap()).into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn reopen(bytes: &[u8]) -> Workbook {
|
||||
io::open_bytes("plan.xlsx", bytes).unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_names_in_chart_series_resolve_after_save_and_reopen() {
|
||||
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
|
||||
// Qualified, as Excel needs them: it draws a chart with a bare name in `<c:f>` blank.
|
||||
let xml = chart_xml(&bytes);
|
||||
for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "Sheet1!TaskLeft", "[0]!LeftTitle"] {
|
||||
assert!(xml.contains(&format!("<c:f>{f}</c:f>")), "{f} missing from {xml}");
|
||||
}
|
||||
assert!(!xml.contains("<c:f>Task"), "a bare name in {xml}");
|
||||
// Series names that are defined names stay references, qualified or not.
|
||||
assert!(xml.contains("<c:tx><c:strRef><c:f>Sheet1!DoneTitle</c:f>"), "{xml}");
|
||||
// A series name that is a defined name stays a reference, not literal text.
|
||||
assert!(!xml.contains("<c:v>LeftTitle</c:v>"));
|
||||
|
||||
let wb = reopen(&bytes);
|
||||
let chart = &wb.sheet(0).unwrap().charts[0];
|
||||
assert_eq!(chart.series[0].categories.as_deref(), Some("TaskNames"));
|
||||
assert_eq!(chart.series[0].values, "TaskStart");
|
||||
assert_eq!(chart.series[2].values, "Sheet1!TaskLeft");
|
||||
assert_eq!(chart.series[2].name.as_deref(), Some("LeftTitle"));
|
||||
|
||||
let data = gridcraft_chart::resolve(&wb, 0, chart);
|
||||
let tasks: Vec<String> = (1..=TASKS).map(|i| format!("Task {i}")).collect();
|
||||
assert_eq!(data.categories, tasks);
|
||||
let names: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Start", "Done", "Left"]);
|
||||
let start: Vec<Option<f64>> = (0..TASKS).map(|i| Some((45_200 + 7 * i) as f64)).collect();
|
||||
assert_eq!(data.series[0].values, start);
|
||||
let left: Vec<Option<f64>> = (0..TASKS).map(|i| Some((TASKS - i) as f64)).collect();
|
||||
assert_eq!(data.series[2].values, left);
|
||||
|
||||
// A second save and reopen gives the same chart data.
|
||||
let again = reopen(&io::save_bytes(&wb, "plan.xlsx").unwrap());
|
||||
assert_eq!(gridcraft_chart::resolve(&again, 0, &again.sheet(0).unwrap().charts[0]), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_workbook_name_resolves_to_no_values_without_panicking() {
|
||||
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
|
||||
let bytes = edit_chart(&bytes, |x| x.replace("Sheet1!TaskLeft", "[0]!NoSuchName"));
|
||||
let wb = reopen(&bytes);
|
||||
let chart = &wb.sheet(0).unwrap().charts[0];
|
||||
assert_eq!(chart.series[2].values, "NoSuchName");
|
||||
let data = gridcraft_chart::resolve(&wb, 0, chart);
|
||||
assert!(data.series[2].values.iter().all(Option::is_none), "{:?}", data.series[2].values);
|
||||
// The other series still plot.
|
||||
assert_eq!(data.series[0].values.len(), TASKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_workbook_refs_in_chart_series_are_kept_as_they_were() {
|
||||
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
|
||||
let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskDone", "[1]Sheet1!$C$2:$C$7"));
|
||||
let wb = reopen(&bytes);
|
||||
assert_eq!(wb.sheet(0).unwrap().charts[0].series[1].values, "[1]Sheet1!$C$2:$C$7");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A formula opened with dynamic-array metadata keeps it through edits that only move or adjust
|
||||
//! it, and loses it when it is retyped (the writer then decides from the formula's shape).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_engine::model::{Cell, Formula, Workbook};
|
||||
use gridcraft_engine::{DocState, Session, io};
|
||||
use serde_json::json;
|
||||
|
||||
fn at(s: &str) -> CellRef {
|
||||
CellRef::parse(s).unwrap()
|
||||
}
|
||||
|
||||
/// A workbook saved by GridCraft whose D1 formula came from a file with `cm`.
|
||||
fn opened() -> Session {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for r in 0..3 {
|
||||
sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r + 1)));
|
||||
sh.set_value(CellRef::new(r, 2), Value::Number(f64::from(r + 2)));
|
||||
}
|
||||
let mut f = Formula::new("SUMPRODUCT(B1:B3*C1:C3)");
|
||||
f.dynamic = true;
|
||||
sh.cells.set(at("D1"), Cell::formula(f));
|
||||
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
|
||||
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.add_document(DocState::new(back, None, "dynamic".into()));
|
||||
s
|
||||
}
|
||||
|
||||
fn formula(s: &Session, cell: &str) -> Arc<Formula> {
|
||||
s.doc().unwrap().wb.sheet(0).unwrap().cell(at(cell)).unwrap().formula.clone().unwrap()
|
||||
}
|
||||
|
||||
/// Whether `cell` of the saved file carries dynamic-array metadata.
|
||||
fn saved_dynamic(s: &Session, cell: &str) -> bool {
|
||||
let bytes = io::save_bytes(&s.doc().unwrap().wb, "dynamic.xlsx").unwrap();
|
||||
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
|
||||
back.sheet(0).unwrap().cell(at(cell)).unwrap().formula.as_ref().unwrap().dynamic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_dynamic_formula_keeps_its_metadata_on_save() {
|
||||
let s = opened();
|
||||
assert!(formula(&s, "D1").dynamic);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(at("D1")), Value::Number(20.0));
|
||||
assert!(saved_dynamic(&s, "D1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_rows_and_moving_keep_the_mark() {
|
||||
let mut s = opened();
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
let f = formula(&s, "D2");
|
||||
assert!(f.dynamic);
|
||||
assert_eq!(f.text, "SUMPRODUCT(B2:B4*C2:C4)");
|
||||
assert!(saved_dynamic(&s, "D2"));
|
||||
s.execute("selection.set", json!({"range": "D2"})).unwrap();
|
||||
s.execute("edit.cut", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "F2"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!(formula(&s, "F2").dynamic);
|
||||
assert!(saved_dynamic(&s, "F2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyped_formula_drops_the_mark() {
|
||||
let mut s = opened();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=SUMPRODUCT(B1:B3*C1:C3)"})).unwrap();
|
||||
assert!(!formula(&s, "D1").dynamic);
|
||||
// SUMPRODUCT evaluates its arguments as arrays anyway: saved as a legacy formula.
|
||||
assert!(!saved_dynamic(&s, "D1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mark_is_serialized_only_when_set() {
|
||||
let s = opened();
|
||||
let dynamic = serde_json::to_value(&*formula(&s, "D1")).unwrap();
|
||||
assert_eq!(dynamic["dynamic"], json!(true));
|
||||
let plain = serde_json::to_value(Formula::new("SUM(A1:A3)")).unwrap();
|
||||
assert!(plain.get("dynamic").is_none(), "{plain}");
|
||||
// Documents written before the field existed still load.
|
||||
let old: Formula = serde_json::from_value(json!({"text": "SUM(A1:A3)", "array": null})).unwrap();
|
||||
assert!(!old.dynamic);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Sheet navigation must never activate hidden sheets when it reaches a workbook boundary.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_engine::{Session, model::Visibility};
|
||||
use serde_json::json;
|
||||
|
||||
fn workbook(hidden: &[usize], active: usize) -> Session {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
for name in ["Second", "Third", "Fourth"] {
|
||||
session.execute("home.insertSheet", json!({"name": name})).unwrap();
|
||||
}
|
||||
for sheet in hidden {
|
||||
session.execute("sheet.hide", json!({"sheet": sheet})).unwrap();
|
||||
}
|
||||
session.execute("sheet.activate", json!({"sheet": active})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "C7"})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_stays_on_the_last_visible_sheet() {
|
||||
let mut session = workbook(&[2, 3], 1);
|
||||
for _ in 0..2 {
|
||||
session.execute("sheet.next", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 1);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_stays_on_the_first_visible_sheet() {
|
||||
let mut session = workbook(&[0, 1], 2);
|
||||
for _ in 0..2 {
|
||||
session.execute("sheet.previous", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 2);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_skips_hidden_sheets_in_both_directions() {
|
||||
let mut session = workbook(&[1, 2], 0);
|
||||
session.execute("sheet.next", json!({})).unwrap();
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet, 3);
|
||||
session.execute("sheet.previous", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 0);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_with_one_visible_sheet_is_a_noop() {
|
||||
let mut session = workbook(&[0, 2, 3], 1);
|
||||
for command in ["sheet.previous", "sheet.next"] {
|
||||
session.execute(command, json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 1);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() {
|
||||
let mut session = workbook(&[1, 3], 0);
|
||||
// VeryHidden is an imported XLSX state; sheet.hide only creates Hidden sheets.
|
||||
let wb = Arc::make_mut(&mut session.active_mut().unwrap().wb);
|
||||
for index in [1, 3] {
|
||||
Arc::make_mut(&mut wb.sheets[index]).visibility = Visibility::VeryHidden;
|
||||
}
|
||||
for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] {
|
||||
session.execute(command, json!({})).unwrap();
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Flat operator chains as long as Excel allows (#916). `=1+1+…` with 4,096 operands is
|
||||
//! exactly 8,192 characters, Excel's formula limit, and builds a tree 4,095 levels deep on its
|
||||
//! left. Entering, editing, saving and reopening such a formula must neither overflow the stack
|
||||
//! nor fall back to an error: Excel computes it.
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_engine::{DocState, Session, io};
|
||||
use serde_json::json;
|
||||
|
||||
/// Excel's longest flat chain of literals: 4,096 operands, 8,192 characters with the `=`.
|
||||
fn literal_chain(op: &str) -> String {
|
||||
format!("={}", vec!["1"; 4096].join(op))
|
||||
}
|
||||
|
||||
/// `=A1+A2+…+A1500`: 7,893 characters, inside Excel's limit.
|
||||
fn reference_chain() -> String {
|
||||
format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+"))
|
||||
}
|
||||
|
||||
fn value(session: &Session, cell: &str) -> Value {
|
||||
let at = CellRef::parse(cell).expect("cell");
|
||||
session.doc().expect("doc").wb.sheet(0).expect("sheet").value(at)
|
||||
}
|
||||
|
||||
/// Runs `f` on a 1 MiB stack, the size of the Windows main thread the desktop app runs on.
|
||||
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new().stack_size(1 << 20).spawn(f).expect("spawn").join().expect("long chains must not overflow the stack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_flat_chains_survive_editing_saving_and_reopening() {
|
||||
on_small_stack(|| {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1:A1500", "values": vec![[1]; 1500]})).expect("values");
|
||||
session.execute("cell.set", json!({"cell": "C1", "input": literal_chain("+")})).expect("plus chain");
|
||||
session.execute("cell.set", json!({"cell": "C2", "input": literal_chain("-")})).expect("minus chain");
|
||||
session.execute("cell.set", json!({"cell": "C3", "input": literal_chain("&")})).expect("concat chain");
|
||||
session.execute("cell.set", json!({"cell": "C4", "input": reference_chain()})).expect("reference chain");
|
||||
assert_eq!(value(&session, "C1"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "C2"), Value::Number(-4094.0));
|
||||
assert_eq!(value(&session, "C3"), Value::text("1".repeat(4096)));
|
||||
assert_eq!(value(&session, "C4"), Value::Number(1500.0));
|
||||
|
||||
// Precedents of a long reference chain still drive recalculation.
|
||||
session.execute("cell.set", json!({"cell": "A750", "input": "101"})).expect("edit precedent");
|
||||
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
|
||||
|
||||
// Evaluate Formula walks every operator of the chain.
|
||||
let steps = session.execute("formulas.evaluateFormula", json!({"cell": "C4"})).expect("evaluate formula");
|
||||
assert_eq!(steps["steps"].as_array().map(Vec::len), Some(200));
|
||||
assert_eq!(steps["result"], json!(1600.0));
|
||||
assert_eq!(session.execute("formulas.evaluate", json!({"formula": literal_chain("+")})).expect("evaluate"), json!(4096.0));
|
||||
|
||||
// Copy and paste move every reference (Excel: the pasted C4 sums A2:A1501).
|
||||
session.execute("edit.copy", json!({"range": "C4"})).expect("copy");
|
||||
session.execute("edit.paste", json!({"at": "C5"})).expect("paste");
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1599.0));
|
||||
session.execute("edit.undo", json!({})).expect("undo paste");
|
||||
|
||||
// Fill and structural edits rewrite every reference in the chain (Excel: an inserted
|
||||
// row above shifts A1:A1500 to A2:A1501; the sum is unchanged).
|
||||
session.execute("edit.fillRight", json!({"range": "C1:D4"})).expect("fill right");
|
||||
assert_eq!(value(&session, "D1"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "D4"), Value::Number(0.0));
|
||||
session.execute("home.insertRows", json!({"rows": "1:1"})).expect("insert row");
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
|
||||
session.execute("home.insertColumns", json!({"cols": "A:A"})).expect("insert column");
|
||||
assert_eq!(value(&session, "D5"), Value::Number(1600.0));
|
||||
session.execute("edit.undo", json!({})).expect("undo");
|
||||
session.execute("edit.undo", json!({})).expect("undo");
|
||||
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
|
||||
session.execute("edit.redo", json!({})).expect("redo");
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
|
||||
|
||||
// XLSX round trip keeps the formulas and their values.
|
||||
let wb = &session.doc().expect("doc").wb;
|
||||
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
|
||||
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
|
||||
let mut session = Session::new();
|
||||
session.add_document(DocState::new(reopened, None, "chains".into()));
|
||||
session.execute("formulas.calculateNow", json!({})).expect("recalc");
|
||||
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Typing a bare number into a Percentage-formatted cell enters it as a percent (issue #1967).
|
||||
|
||||
use gridcraft_engine::display::cell_text;
|
||||
use serde_json::json;
|
||||
|
||||
fn enter(input: &str) -> String {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "format": "Percentage"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
cell_text(&d.wb, d.wb.active().unwrap(), gridcraft_engine::core::CellRef::new(0, 0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_number_in_percent_cell_is_a_percent() {
|
||||
assert_eq!(enter("3"), "3.00%");
|
||||
assert_eq!(enter("0.03"), "3.00%");
|
||||
assert_eq!(enter("3%"), "3.00%");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! #1447: a PivotChart is bound to its PivotTable behind the workbook's name.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) -> Json {
|
||||
s.execute(command, params).unwrap()
|
||||
}
|
||||
|
||||
fn source() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
run(
|
||||
&mut s,
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["Region ", " Sales ", "Qty"], ["East", 10, 1], ["West", 20, 2], ["East", 5, 3]]}),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_link_names_the_workbook_in_brackets() {
|
||||
let mut s = source();
|
||||
run(&mut s, "selection.set", json!({"range": "A1"}));
|
||||
run(&mut s, "insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]}));
|
||||
run(&mut s, "insert.pivotChart", json!({}));
|
||||
let bytes = gridcraft_engine::io::save_bytes(&s.doc().unwrap().wb, "Q1 Report.xlsx").unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut chart = String::new();
|
||||
zip.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut chart).unwrap();
|
||||
assert!(chart.contains("<c:name>[Q1 Report.xlsx]Sheet2!PivotTable1</c:name>"), "{chart}");
|
||||
|
||||
// Without a file name, a placeholder workbook is named; reading drops the brackets again.
|
||||
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
|
||||
let mut reopened = Session::new();
|
||||
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
|
||||
let link = reopened.doc().unwrap().wb.sheets.iter().flat_map(|sh| sh.charts.iter()).find_map(|c| c.pivot.clone());
|
||||
assert_eq!(link.as_deref(), Some("Sheet2!PivotTable1"));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! #1458: sheet names are quoted in references the way Excel quotes them, in formulas, saved
|
||||
//! files, PivotTable sources and ADDRESS.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) -> Json {
|
||||
s.execute(command, params).unwrap()
|
||||
}
|
||||
|
||||
fn formula(s: &Session, cell: &str) -> String {
|
||||
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.as_ref().unwrap().text.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_keep_the_quotes_excel_needs_through_a_save() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for name in [".x", "R1C1", "A1", "Bob's"] {
|
||||
run(&mut s, "home.insertSheet", json!({"name": name}));
|
||||
}
|
||||
run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"}));
|
||||
let input = "='.x'!A1+'R1C1'!A1+'A1'!B1+'Bob''s'!A1";
|
||||
run(&mut s, "cell.set", json!({"cell": "A1", "input": input}));
|
||||
assert_eq!(formula(&s, "A1"), input.trim_start_matches('='));
|
||||
|
||||
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
|
||||
let mut reopened = Session::new();
|
||||
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
|
||||
run(&mut reopened, "sheet.activate", json!({"sheet": "Sheet1"}));
|
||||
assert_eq!(formula(&reopened, "A1"), input.trim_start_matches('='));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_and_pivot_source_quote_a_sheet_named_like_a_cell() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
run(&mut s, "home.insertSheet", json!({"name": "A1"}));
|
||||
run(&mut s, "sheet.activate", json!({"sheet": "A1"}));
|
||||
for (cell, input) in [("A1", "K"), ("B1", "V"), ("A2", "a"), ("B2", "1"), ("A3", "b"), ("B3", "2")] {
|
||||
run(&mut s, "cell.set", json!({"cell": cell, "input": input}));
|
||||
}
|
||||
run(&mut s, "cell.set", json!({"cell": "D1", "input": "=ADDRESS(1,1,1,TRUE,\"A1\")"}));
|
||||
let d1 = s.doc().unwrap().wb.active().unwrap().value(CellRef::parse("D1").unwrap());
|
||||
assert_eq!(d1, Value::from("'A1'!$A$1"));
|
||||
|
||||
run(&mut s, "selection.set", json!({"range": "A2"}));
|
||||
let pivot = run(&mut s, "insert.pivotTable", json!({"rows": ["K"], "values": ["V"]}));
|
||||
assert_eq!(pivot["source"], json!("'A1'!$A$1:$B$3"));
|
||||
}
|
||||
@@ -27,3 +27,30 @@ fn resize_to_overlapping_range_still_works() {
|
||||
s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap();
|
||||
assert_eq!(table_range(&s), "B1:C2");
|
||||
}
|
||||
|
||||
fn two_tables() -> Session {
|
||||
let mut s = table();
|
||||
s.execute("range.setValues", json!({"range":"D1", "values":[["P", "Q"], ["y", "8"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range":"D1:E2", "header":true, "name":"U"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn ranges(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.range.a1()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_over_another_table_is_rejected() {
|
||||
let mut s = two_tables();
|
||||
assert!(s.execute("table.resize", json!({"table":"T", "range":"A1:E2"})).is_err());
|
||||
assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]);
|
||||
assert!(s.execute("table.resize", json!({"table":"U", "range":"A1:E2"})).is_err());
|
||||
assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_next_to_another_table_still_works() {
|
||||
let mut s = two_tables();
|
||||
s.execute("table.resize", json!({"table":"T", "range":"A1:C3"})).unwrap();
|
||||
assert_eq!(ranges(&s), ["A1:C3", "D1:E2"]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! #1969: a wrapped cell's row must fit WORD-wrapped lines, not the character-fit
|
||||
//! estimate (words can't split, so each drawn line ends early and narrow columns
|
||||
//! need more lines than width/estimate suggests).
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn row_height(s: &mut Session, row: u32) -> f32 {
|
||||
s.doc().unwrap().wb.active().unwrap().row_height(row)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_row_fits_word_wrapped_lines() {
|
||||
let mut s = session();
|
||||
// Column fits ~10 characters; six-character words mean only one word per line.
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 10})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "aaaaaa bbbbbb cccccc dddddd"})).unwrap();
|
||||
let plain = row_height(&mut s, 0);
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let wrapped = row_height(&mut s, 0);
|
||||
// Word wrap: 4 lines (one word each). The old estimate: ceil(28/10) = 3 lines.
|
||||
// Line height at 11pt is ~19.8, so 4 lines need ~79 vs ~59 for 3.
|
||||
assert!(wrapped >= plain + 3.0 * 19.0, "4 word-wrapped lines must grow the row by ~4 line heights: plain={plain}, wrapped={wrapped}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_repro_nasa_sentence() {
|
||||
let mut s = session();
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Since the 1960s, NASA has left hardware across the solar system\u{2013}on the Moon, on Mars, and in deep space."}))
|
||||
.unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let h = row_height(&mut s, 0);
|
||||
// Excel shows this at ~10 lines in a 12-char column; the character estimate undercounts.
|
||||
assert!(h >= 9.0 * 19.0, "NASA sentence in a 12-char column needs ~10 lines, got height {h}");
|
||||
}
|
||||
+110
-19
@@ -182,7 +182,13 @@ impl BinOp {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
/// A formula's syntax tree.
|
||||
///
|
||||
/// A flat operator chain such as `=A1+A2+…` nests one [`Expr::Binary`] per operator on its
|
||||
/// left, so a formula as long as Excel allows (8,192 characters) is about 4,000 levels deep.
|
||||
/// `Clone`, `PartialEq`, [`Expr::walk`] and [`Expr::map`] therefore follow that left spine in a
|
||||
/// loop and recurse only into right-hand operands and real nesting, which the parser bounds.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Expr {
|
||||
Number(f64),
|
||||
Text(Arc<str>),
|
||||
@@ -206,36 +212,52 @@ pub enum Expr {
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
/// Visits every node depth-first.
|
||||
/// Visits every node depth-first, parents before children, left to right.
|
||||
pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Expr)) {
|
||||
f(self);
|
||||
match self {
|
||||
Expr::Array(rows) => rows.iter().flatten().for_each(|e| e.walk(f)),
|
||||
Expr::Unary(_, e) | Expr::Paren(e) => e.walk(f),
|
||||
Expr::Binary(_, a, b) => {
|
||||
a.walk(f);
|
||||
b.walk(f);
|
||||
// An explicit stack: a long operator chain must not recurse once per operand.
|
||||
let mut pending = vec![self];
|
||||
while let Some(e) = pending.pop() {
|
||||
f(e);
|
||||
match e {
|
||||
Expr::Array(rows) => pending.extend(rows.iter().flatten().rev()),
|
||||
Expr::Unary(_, e) | Expr::Paren(e) => pending.push(e),
|
||||
Expr::Binary(_, a, b) => {
|
||||
pending.push(b);
|
||||
pending.push(a);
|
||||
}
|
||||
Expr::Call(_, args) => pending.extend(args.iter().rev()),
|
||||
Expr::Invoke(c, args) => {
|
||||
pending.extend(args.iter().rev());
|
||||
pending.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Expr::Call(_, args) => args.iter().for_each(|e| e.walk(f)),
|
||||
Expr::Invoke(c, args) => {
|
||||
c.walk(f);
|
||||
args.iter().for_each(|e| e.walk(f));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Rewrites every node bottom-up.
|
||||
/// Rewrites every node bottom-up, children left to right before their parent.
|
||||
pub fn map(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr {
|
||||
let e = match self {
|
||||
// Unwind the left spine of a long operator chain into a list, then rebuild it from the
|
||||
// innermost operand outwards, in the order a recursive rewrite would visit the nodes.
|
||||
let mut spine = Vec::new();
|
||||
let mut node = self;
|
||||
while let Expr::Binary(op, a, b) = node {
|
||||
spine.push((op, b));
|
||||
node = *a;
|
||||
}
|
||||
let e = match node {
|
||||
Expr::Array(rows) => Expr::Array(rows.into_iter().map(|r| r.into_iter().map(|e| e.map(f)).collect()).collect()),
|
||||
Expr::Unary(op, e) => Expr::Unary(op, Box::new(e.map(f))),
|
||||
Expr::Paren(e) => Expr::Paren(Box::new(e.map(f))),
|
||||
Expr::Binary(op, a, b) => Expr::Binary(op, Box::new(a.map(f)), Box::new(b.map(f))),
|
||||
Expr::Call(n, args) => Expr::Call(n, args.into_iter().map(|e| e.map(f)).collect()),
|
||||
Expr::Invoke(c, args) => Expr::Invoke(Box::new(c.map(f)), args.into_iter().map(|e| e.map(f)).collect()),
|
||||
other => other,
|
||||
};
|
||||
f(e)
|
||||
let mut acc = f(e);
|
||||
while let Some((op, b)) = spine.pop() {
|
||||
let b = b.map(f);
|
||||
acc = f(Expr::Binary(op, Box::new(acc), Box::new(b)));
|
||||
}
|
||||
acc
|
||||
}
|
||||
/// All references in the formula.
|
||||
pub fn references(&self) -> Vec<&Reference> {
|
||||
@@ -259,6 +281,75 @@ impl Expr {
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Expr {
|
||||
fn clone(&self) -> Self {
|
||||
// Copy the left spine of an operator chain in a loop (see [`Expr`]).
|
||||
let mut spine = Vec::new();
|
||||
let mut node = self;
|
||||
while let Expr::Binary(op, a, b) = node {
|
||||
spine.push((*op, b));
|
||||
node = a;
|
||||
}
|
||||
let mut acc = match node {
|
||||
Expr::Number(n) => Expr::Number(*n),
|
||||
Expr::Text(t) => Expr::Text(t.clone()),
|
||||
Expr::Bool(b) => Expr::Bool(*b),
|
||||
Expr::Error(e) => Expr::Error(*e),
|
||||
Expr::Array(rows) => Expr::Array(rows.clone()),
|
||||
Expr::Ref(r) => Expr::Ref(r.clone()),
|
||||
Expr::Name(n) => Expr::Name(n.clone()),
|
||||
Expr::Struct(s) => Expr::Struct(s.clone()),
|
||||
Expr::Unary(op, e) => Expr::Unary(*op, e.clone()),
|
||||
Expr::Binary(op, a, b) => Expr::Binary(*op, a.clone(), b.clone()),
|
||||
Expr::Call(n, args) => Expr::Call(n.clone(), args.clone()),
|
||||
Expr::Invoke(c, args) => Expr::Invoke(c.clone(), args.clone()),
|
||||
Expr::Missing => Expr::Missing,
|
||||
Expr::Paren(e) => Expr::Paren(e.clone()),
|
||||
};
|
||||
while let Some((op, b)) = spine.pop() {
|
||||
acc = Expr::Binary(op, Box::new(acc), b.clone());
|
||||
}
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Expr {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Compare the left spines of operator chains in a loop (see [`Expr`]). The right-hand
|
||||
// operands are compared only once the whole spine matches, so a mismatch on the left
|
||||
// returns before any (possibly deep) right operand is walked.
|
||||
let (mut x, mut y) = (self, other);
|
||||
let mut right_operands = Vec::new();
|
||||
let equal = loop {
|
||||
match (x, y) {
|
||||
(Expr::Binary(o1, a1, b1), Expr::Binary(o2, a2, b2)) => {
|
||||
if o1 != o2 {
|
||||
return false;
|
||||
}
|
||||
right_operands.push((b1, b2));
|
||||
x = a1;
|
||||
y = a2;
|
||||
}
|
||||
(Expr::Number(a), Expr::Number(b)) => break a == b,
|
||||
(Expr::Text(a), Expr::Text(b)) => break a == b,
|
||||
(Expr::Bool(a), Expr::Bool(b)) => break a == b,
|
||||
(Expr::Error(a), Expr::Error(b)) => break a == b,
|
||||
(Expr::Array(a), Expr::Array(b)) => break a == b,
|
||||
(Expr::Ref(a), Expr::Ref(b)) => break a == b,
|
||||
(Expr::Name(a), Expr::Name(b)) => break a == b,
|
||||
(Expr::Struct(a), Expr::Struct(b)) => break a == b,
|
||||
(Expr::Unary(o1, a), Expr::Unary(o2, b)) => break o1 == o2 && a == b,
|
||||
(Expr::Call(n1, a), Expr::Call(n2, b)) => break n1 == n2 && a == b,
|
||||
(Expr::Invoke(c1, a), Expr::Invoke(c2, b)) => break c1 == c2 && a == b,
|
||||
(Expr::Missing, Expr::Missing) => break true,
|
||||
(Expr::Paren(a), Expr::Paren(b)) => break a == b,
|
||||
_ => break false,
|
||||
}
|
||||
};
|
||||
equal && right_operands.into_iter().rev().all(|(b1, b2)| b1 == b2)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clamp_anchor(row: i64, col: i64) -> Option<(u32, u32)> {
|
||||
if (0..MAX_ROWS as i64).contains(&row) && (0..MAX_COLS as i64).contains(&col) { Some((row as u32, col as u32)) } else { None }
|
||||
}
|
||||
@@ -20,6 +20,12 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
("COUNTIF", "CONTAR.SI"),
|
||||
("VLOOKUP", "BUSCARV"),
|
||||
("TODAY", "HOY"),
|
||||
// Financial block (#1986): the loan-schedule and investment staples.
|
||||
("NPV", "VNA"),
|
||||
("IRR", "TIR"),
|
||||
("PMT", "PAGO"),
|
||||
("PV", "VA"),
|
||||
("FV", "VF"),
|
||||
("TRUE", "VERDADERO"),
|
||||
("FALSE", "FALSO"),
|
||||
];
|
||||
@@ -63,8 +69,9 @@ pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -
|
||||
/// Prints canonical expressions for editing. Name collisions retain the canonical spelling
|
||||
/// so displaying then committing a formula never changes a defined-name reference.
|
||||
pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<String, ParseError> {
|
||||
// Check before cloning/printing: a flat operator chain can produce a deep AST even
|
||||
// though the parser itself did not recurse deeply.
|
||||
// Check before cloning/printing. A flat operator chain such as A1+A2+… nests on its left
|
||||
// without nesting in the text: cloning, printing and `normalize` follow that left spine in
|
||||
// a loop, so it does not count (Excel accepts 4,096 operands, #916). Other nesting does.
|
||||
let mut pending = vec![(expr, 0usize)];
|
||||
while let Some((node, depth)) = pending.pop() {
|
||||
if depth > parser::MAX_DEPTH {
|
||||
@@ -73,7 +80,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str)
|
||||
match node {
|
||||
Expr::Unary(_, child) | Expr::Paren(child) => pending.push((child, depth + 1)),
|
||||
Expr::Binary(_, left, right) => {
|
||||
pending.push((left, depth + 1));
|
||||
pending.push((left, depth));
|
||||
pending.push((right, depth + 1));
|
||||
}
|
||||
Expr::Call(_, args) => pending.extend(args.iter().map(|e| (e, depth + 1))),
|
||||
@@ -128,7 +135,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
|
||||
if depth > parser::MAX_DEPTH {
|
||||
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
|
||||
}
|
||||
let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| n.eq_ignore_ascii_case(name)) || known_name(name);
|
||||
let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name);
|
||||
match expr {
|
||||
Expr::Bool(value) if display => {
|
||||
let alias = if *value { "VERDADERO" } else { "FALSO" };
|
||||
@@ -185,9 +192,15 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
|
||||
}
|
||||
}
|
||||
Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?,
|
||||
Expr::Binary(_, left, right) => {
|
||||
normalize(left, known_name, bindings, depth + 1, display)?;
|
||||
normalize(right, known_name, bindings, depth + 1, display)?;
|
||||
Expr::Binary(..) => {
|
||||
// Follow the left spine of a flat chain (A1+A2+…) in a loop: it is not nesting.
|
||||
// Each call leaves `bindings` as it found them, so the order does not matter.
|
||||
let mut node = expr;
|
||||
while let Expr::Binary(_, left, right) = node {
|
||||
normalize(right, known_name, bindings, depth + 1, display)?;
|
||||
node = left;
|
||||
}
|
||||
normalize(node, known_name, bindings, depth, display)?;
|
||||
}
|
||||
Expr::Array(rows) if display => {
|
||||
for row in rows {
|
||||
|
||||
@@ -260,6 +260,9 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
|
||||
.replace(locale.decimal_separator(), ".")
|
||||
.parse()
|
||||
.map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?;
|
||||
if !v.is_finite() {
|
||||
return Err(LexError { msg: format!("number too large `{text}`"), pos: start });
|
||||
}
|
||||
push(&mut out, Tok::Number(v), j);
|
||||
i = j;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,30 @@ pub mod printer;
|
||||
|
||||
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
||||
pub use input::{FormulaLocale, parse_input, print_input};
|
||||
pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse};
|
||||
pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse, parse_closing_parens, parse_name};
|
||||
pub use printer::{print, print_r1c1, quote_sheet};
|
||||
|
||||
/// Whether two LET/LAMBDA names are the same: case-insensitive in any script, as in Excel
|
||||
/// (`dług` = `DŁUG`, `ß` = `ẞ`). Close to Unicode's default case folding: lower case, plus the
|
||||
/// folds lower-casing misses for letters a name may hold (`ß`/`ẞ` -> `ss`, `ſ` -> `s`, `ς` -> `σ`).
|
||||
/// Dotless `ı` stays distinct from `i`, as outside Turkic languages.
|
||||
pub fn same_name(a: &str, b: &str) -> bool {
|
||||
if a.is_ascii() && b.is_ascii() {
|
||||
return a.eq_ignore_ascii_case(b);
|
||||
}
|
||||
let fold = |s: &str| -> String {
|
||||
s.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.flat_map(|c| match c {
|
||||
'ß' => ['s', 's'].into_iter().take(2),
|
||||
'ſ' => ['s', 's'].into_iter().take(1),
|
||||
'ς' => ['σ', 'σ'].into_iter().take(1),
|
||||
c => [c, c].into_iter().take(1),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
a == b || fold(a) == fold(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -28,7 +28,38 @@ pub fn parse(src: &str) -> Result<Expr, ParseError> {
|
||||
parse_syntax(src, crate::FormulaLocale::En)
|
||||
}
|
||||
|
||||
/// Parses a defined name's formula. Unlike a cell formula, a comma at the top level is the
|
||||
/// union operator: Excel stores and shows a name over several ranges as `Sheet1!$A$1,Sheet1!$C$1`.
|
||||
pub fn parse_name(src: &str) -> Result<Expr, ParseError> {
|
||||
parse_top(src, crate::FormulaLocale::En, true)
|
||||
}
|
||||
|
||||
/// Most closing parentheses [`parse_closing_parens`] adds.
|
||||
const MAX_CLOSED_PARENS: usize = 8;
|
||||
|
||||
/// Parses formula text as typed into a cell with `parse`, closing up to eight missing `)` at the
|
||||
/// end as Excel does (`SUM(1,2` enters `SUM(1,2)`). When no number of `)` makes it parse, the
|
||||
/// error describes the text as typed: `1+` is "unexpected end of formula", not "unexpected `)`".
|
||||
pub fn parse_closing_parens<T>(src: &str, parse: impl Fn(&str) -> Result<T, ParseError>) -> Result<T, ParseError> {
|
||||
let typed = match parse(src) {
|
||||
Ok(parsed) => return Ok(parsed),
|
||||
Err(e) => e,
|
||||
};
|
||||
let mut text = src.to_string();
|
||||
for _ in 0..MAX_CLOSED_PARENS {
|
||||
text.push(')');
|
||||
if let Ok(parsed) = parse(&text) {
|
||||
return Ok(parsed);
|
||||
}
|
||||
}
|
||||
Err(typed)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Expr, ParseError> {
|
||||
parse_top(src, locale, false)
|
||||
}
|
||||
|
||||
fn parse_top(src: &str, locale: crate::FormulaLocale, allow_union: bool) -> Result<Expr, ParseError> {
|
||||
let body = src.strip_prefix('=').unwrap_or(src);
|
||||
if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN {
|
||||
return Err(ParseError { msg: format!("formula is longer than {MAX_FORMULA_LEN} characters"), pos: 0 });
|
||||
@@ -36,7 +67,7 @@ pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Ex
|
||||
let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let toks = significant_spaces(toks);
|
||||
let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };
|
||||
let e = p.expr(0, false)?;
|
||||
let e = p.expr(0, allow_union)?;
|
||||
match p.peek() {
|
||||
Tok::Eof => Ok(e),
|
||||
t => Err(p.err(format!("unexpected {}", describe(t)))),
|
||||
|
||||
@@ -6,22 +6,7 @@ use gridcraft_core::{CellRef, col_to_letters, number_to_text};
|
||||
|
||||
use crate::ast::*;
|
||||
|
||||
/// Whether a sheet name needs quotes in a reference.
|
||||
pub fn needs_quotes(name: &str) -> bool {
|
||||
if name.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let first = name.chars().next().unwrap_or('_');
|
||||
if first.is_ascii_digit() || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) {
|
||||
return true;
|
||||
}
|
||||
// Looks like a cell ref (`A1`, `R1C1`) or a boolean.
|
||||
gridcraft_core::CellRef::parse(name).is_some() || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE")
|
||||
}
|
||||
|
||||
pub fn quote_sheet(name: &str) -> String {
|
||||
if needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() }
|
||||
}
|
||||
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
|
||||
|
||||
fn sheet_prefix(s: &SheetSel) -> String {
|
||||
match s {
|
||||
|
||||
@@ -401,3 +401,105 @@ fn formulas_longer_than_excel_allows_are_refused() {
|
||||
assert!(text.len() > crate::MAX_FORMULA_LEN);
|
||||
assert!(parse(&text).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_names_excel_quotes_stay_quoted() {
|
||||
// Excel quotes R1C1-looking names, a leading `.`, cells within the sheet and booleans (#1458).
|
||||
for name in ["R", "C", "RC", "R1C", "RC1", "R1C1", "r2c3", "R1", "C1", "A1", "XFD1", "AB1048576", ".x", "TRUE", "false"] {
|
||||
assert_eq!(rt(&format!("'{name}'!A1")), format!("'{name}'!A1"), "{name}");
|
||||
}
|
||||
// Names Excel leaves bare.
|
||||
for name in ["XFE1", "AB1048577", "Sheet.1", "_x", "A1A", "\u{3a3}"] {
|
||||
assert_eq!(rt(&format!("'{name}'!A1")), format!("{name}!A1"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() {
|
||||
// `=1+1+…` with 4,096 operands is 8,192 characters, Excel's formula limit, and nests 4,095
|
||||
// levels on its left. On a 1 MiB stack (the Windows main thread) the derived, recursive
|
||||
// Clone overflowed when the engine stored such a formula (#916).
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let plus = vec!["1"; 4096].join("+");
|
||||
let e = parse(&plus).expect("parse");
|
||||
let copy = e.clone();
|
||||
assert!(copy == e);
|
||||
let other = parse(&format!("{}-1", vec!["1"; 4095].join("+"))).expect("parse");
|
||||
assert!(other != e);
|
||||
let mut nodes = 0;
|
||||
e.walk(&mut |_| nodes += 1);
|
||||
assert_eq!(nodes, 2 * 4096 - 1);
|
||||
assert_eq!(print(©.map(&mut |x| x)), plus);
|
||||
// A chain of references: walk finds them in order, adjust rewrites every one.
|
||||
let refs = (1..=1000).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+");
|
||||
let e = parse(&refs).expect("parse");
|
||||
let found: Vec<String> = e.references().iter().map(|r| printer::reference_a1(r)).collect();
|
||||
assert_eq!(found.first().map(String::as_str), Some("A1"));
|
||||
assert_eq!(found.last().map(String::as_str), Some("A1000"));
|
||||
assert_eq!(found.len(), 1000);
|
||||
let ins = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 };
|
||||
let moved = print(&adjust(e.clone(), "S", "S", &ins));
|
||||
assert_eq!(moved, (2..=1001).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+"));
|
||||
assert_eq!(print(&shift_relative(e, 0, 1)), (1..=1000).map(|r| format!("B{r}")).collect::<Vec<_>>().join("+"));
|
||||
})
|
||||
.expect("spawn");
|
||||
handle.join().expect("long chains must not overflow the stack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() {
|
||||
// The leftmost operand of a flat chain is not nested, so `(((…1)))+1` is accepted whenever
|
||||
// `(((…1)))` is, in both the pre-check and `normalize`.
|
||||
let wrap = |n: usize| {
|
||||
let mut e = Expr::Number(1.0);
|
||||
for _ in 0..n {
|
||||
e = Expr::Paren(Box::new(e));
|
||||
}
|
||||
e
|
||||
};
|
||||
let es = |e: &Expr| crate::print_input(e, FormulaLocale::Es, |_| false);
|
||||
let deepest = (0..400).take_while(|&n| es(&wrap(n)).is_ok()).last().expect("depth 0 is accepted");
|
||||
assert!(deepest > 1 && deepest < 400);
|
||||
let chain = Expr::Binary(BinOp::Add, Box::new(wrap(deepest)), Box::new(Expr::Number(1.0)));
|
||||
assert!(es(&chain).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_is_preorder_and_map_is_postorder_left_to_right() {
|
||||
// `(A1+B1)+SUM(C1,D1)-E1`: a chain with a call on its right. walk visits parents before
|
||||
// children, map rewrites children (left to right) before their parent.
|
||||
let e = parse("(A1+B1)+SUM(C1,D1)-E1").unwrap();
|
||||
let mut walked = Vec::new();
|
||||
e.walk(&mut |n| walked.push(print(n)));
|
||||
assert_eq!(walked, ["(A1+B1)+SUM(C1,D1)-E1", "(A1+B1)+SUM(C1,D1)", "(A1+B1)", "A1+B1", "A1", "B1", "SUM(C1,D1)", "C1", "D1", "E1"]);
|
||||
let mut mapped = Vec::new();
|
||||
let out = e.map(&mut |n| {
|
||||
mapped.push(print(&n));
|
||||
n
|
||||
});
|
||||
assert_eq!(mapped, ["A1", "B1", "A1+B1", "(A1+B1)", "C1", "D1", "SUM(C1,D1)", "(A1+B1)+SUM(C1,D1)", "E1", "(A1+B1)+SUM(C1,D1)-E1"]);
|
||||
assert_eq!(print(&out), "(A1+B1)+SUM(C1,D1)-E1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() {
|
||||
// #2030: `1+` reported "unexpected `)`", the error of the last retry with `)` appended.
|
||||
let entry = |src: &str| parse_closing_parens(src, parse);
|
||||
assert_eq!(entry("SUM(1,2").map(|e| print(&e)), Ok("SUM(1,2)".to_string()));
|
||||
assert_eq!(entry("((1+2").map(|e| print(&e)), Ok("((1+2))".to_string()));
|
||||
assert_eq!(entry("1+2").map(|e| print(&e)), Ok("1+2".to_string()));
|
||||
for src in ["1+", "--", "SUM(1,+", "1+*2", &format!("{}1", "(".repeat(9))] {
|
||||
assert_eq!(entry(src).unwrap_err(), parse(src).unwrap_err(), "{src}");
|
||||
}
|
||||
assert_eq!(entry("1+").unwrap_err().msg, "unexpected end of formula");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_literal_too_large_for_f64_is_a_parse_error() {
|
||||
for bad in ["1E+400", "-1E+400", "1E+400*A1", "SUM(1E309,1)", "9".repeat(400).as_str()] {
|
||||
assert!(parse(bad).is_err(), "{bad} should fail");
|
||||
}
|
||||
assert!(parse("1E+308").is_ok());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input};
|
||||
use gridcraft_formula::{Expr, FormulaLocale, parse, parse_input, print, print_input};
|
||||
|
||||
fn input(src: &str) -> String {
|
||||
print(&parse_input(src, FormulaLocale::Es, |_| false).unwrap())
|
||||
@@ -15,6 +15,11 @@ fn spanish_aliases_and_argument_separators() {
|
||||
("CONTAR.SI(A1:A10;\">0\")", "COUNTIF(A1:A10,\">0\")"),
|
||||
("BUSCARV(A2;D:E;2;FALSO)", "VLOOKUP(A2,D:E,2,FALSE)"),
|
||||
("HOY()", "TODAY()"),
|
||||
("VNA(0,1;A1:A5)", "NPV(0.1,A1:A5)"),
|
||||
("TIR(A1:A5;0,1)", "IRR(A1:A5,0.1)"),
|
||||
("PAGO(0,08/12;10;10000)", "PMT(0.08/12,10,10000)"),
|
||||
("VA(0,05;12;100)", "PV(0.05,12,100)"),
|
||||
("VF(0,05;12;100)", "FV(0.05,12,100)"),
|
||||
("=verdadero", "TRUE"),
|
||||
("=falso", "FALSE"),
|
||||
("VERDADERO()", "TRUE()"),
|
||||
@@ -45,10 +50,31 @@ fn spanish_input_preserves_reference_and_array_syntax() {
|
||||
assert_eq!(print(&parse("SUMA(1,2)+FALSO").unwrap()), "SUMA(1,2)+FALSO");
|
||||
assert!(parse("SUM(1;2)").is_err());
|
||||
assert!(parse("{VERDADERO}").is_err());
|
||||
// Pratt parsing can produce a deep left-hand AST without deep parser recursion.
|
||||
let long_chain = "FALSO+".repeat(300) + "VERDADERO";
|
||||
assert!(parse_input(&long_chain, FormulaLocale::Es, |_| false).is_err());
|
||||
assert!(print_input(&parse(&long_chain).unwrap(), FormulaLocale::Es, |_| false).is_err());
|
||||
// Real nesting past the parser's limit is still refused.
|
||||
let nested = "(".repeat(300) + "1" + &")".repeat(300);
|
||||
assert!(parse_input(&nested, FormulaLocale::Es, |_| false).is_err());
|
||||
let deep = (0..300).fold(Expr::Number(1.0), |e, _| Expr::Paren(Box::new(e)));
|
||||
assert!(print_input(&deep, FormulaLocale::Es, |_| false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_input_accepts_flat_chains_as_long_as_excel() {
|
||||
// A flat chain nests on its left without nesting in the text. Excel accepts 4,096
|
||||
// operands (8,192 characters); Spanish entry refused more than 256 (#916). On a 1 MiB
|
||||
// stack, as on the Windows main thread.
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let long_chain = "FALSO+".repeat(1360) + "VERDADERO";
|
||||
let english = "FALSE+".repeat(1360) + "TRUE";
|
||||
assert_eq!(input(&long_chain), english);
|
||||
assert_eq!(print_input(&parse(&english).unwrap(), FormulaLocale::Es, |_| false).unwrap(), long_chain);
|
||||
let sums = vec!["SUMA(1;2)"; 800].join("+");
|
||||
assert_eq!(input(&sums), vec!["SUM(1,2)"; 800].join("+"));
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -98,3 +124,17 @@ fn spanish_display_round_trips_text_containing_separators() {
|
||||
assert_eq!(shown, r#"SI(A1="a,b;c";CONCAT("1.5";",";";");SUMA(0,25;A1:A3 B2))"#);
|
||||
assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_names_match_aliases_in_any_case_like_the_evaluator() {
|
||||
// `ſi` (long s) is `SI` case-insensitively, so the call is the LAMBDA, not IF.
|
||||
assert_eq!(input("LET(ſi;LAMBDA(x;x*2);SI(3))"), "LET(ſi,LAMBDA(x,x*2),SI(3))");
|
||||
assert!(gridcraft_formula::same_name("ß", "ẞ"));
|
||||
assert!(gridcraft_formula::same_name("dług", "DŁUG"));
|
||||
assert!(gridcraft_formula::same_name("ſi", "SI"));
|
||||
assert!(gridcraft_formula::same_name("σοφός", "ΣΟΦΌΣ"));
|
||||
// Dotless ı is its own letter outside Turkic languages.
|
||||
assert!(!gridcraft_formula::same_name("ı", "i"));
|
||||
assert!(!gridcraft_formula::same_name("ı", "I"));
|
||||
assert!(!gridcraft_formula::same_name("a", "b"));
|
||||
}
|
||||
@@ -13,16 +13,16 @@ const ND: &[bool] = &[true, true, false];
|
||||
const NDI: &[bool] = &[true, true, true, false];
|
||||
|
||||
/// A date serial argument (truncated to whole days when `whole`). Out of range → `#NUM!`.
|
||||
fn serial_arg(a: &[Arg], i: usize) -> R<f64> {
|
||||
let n = num(a, i)?;
|
||||
fn serial_arg(a: &[Arg], i: usize, sys: DateSystem) -> R<f64> {
|
||||
let n = scalar(arg(a, i)?).to_number_in(sys)?;
|
||||
if !(0.0..MAX_SERIAL + 1.0).contains(&n) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn day_arg(a: &[Arg], i: usize) -> R<i64> {
|
||||
Ok(serial_arg(a, i)?.floor() as i64)
|
||||
fn day_arg(a: &[Arg], i: usize, sys: DateSystem) -> R<i64> {
|
||||
Ok(serial_arg(a, i, sys)?.floor() as i64)
|
||||
}
|
||||
|
||||
fn dt(sys: DateSystem, serial: f64) -> R<DateTime> {
|
||||
@@ -66,8 +66,9 @@ fn time(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn part(a: &[Arg], c: &mut dyn Ctx, f: fn(&DateTime) -> f64) -> R<Value> {
|
||||
let s = serial_arg(a, 0)?;
|
||||
num_val(f(&dt(c.date_system(), s.floor())?))
|
||||
let sys = c.date_system();
|
||||
let s = serial_arg(a, 0, sys)?;
|
||||
num_val(f(&dt(sys, s.floor())?))
|
||||
}
|
||||
|
||||
fn year(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -81,20 +82,20 @@ fn day(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
/// Seconds into the day, rounded to the nearest second.
|
||||
fn day_seconds(a: &[Arg]) -> R<i64> {
|
||||
let s = serial_arg(a, 0)?;
|
||||
fn day_seconds(a: &[Arg], sys: DateSystem) -> R<i64> {
|
||||
let s = serial_arg(a, 0, sys)?;
|
||||
let secs = ((s - s.floor()) * 86400.0).round() as i64;
|
||||
Ok(if secs >= 86400 { 0 } else { secs })
|
||||
}
|
||||
|
||||
fn hour(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((day_seconds(a)? / 3600) as f64)
|
||||
fn hour(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((day_seconds(a, c.date_system())? / 3600) as f64)
|
||||
}
|
||||
fn minute(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((day_seconds(a)? / 60 % 60) as f64)
|
||||
fn minute(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((day_seconds(a, c.date_system())? / 60 % 60) as f64)
|
||||
}
|
||||
fn second(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((day_seconds(a)? % 60) as f64)
|
||||
fn second(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((day_seconds(a, c.date_system())? % 60) as f64)
|
||||
}
|
||||
|
||||
fn now(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -105,9 +106,10 @@ fn today(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = day_arg(a, 0)?;
|
||||
let sys = c.date_system();
|
||||
let s = day_arg(a, 0, sys)?;
|
||||
let t = to_int(opt_num(a, 1, 1.0)?)?;
|
||||
let wd = weekday0(c.date_system(), s) as i64;
|
||||
let wd = weekday0(sys, s) as i64;
|
||||
let r = match t {
|
||||
1 | 17 => wd + 1,
|
||||
2 | 11 => (wd + 6) % 7 + 1,
|
||||
@@ -128,7 +130,7 @@ fn isoweek(sys: DateSystem, s: i64) -> R<i64> {
|
||||
|
||||
fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let s = day_arg(a, 0)?;
|
||||
let s = day_arg(a, 0, sys)?;
|
||||
let t = to_int(opt_num(a, 1, 1.0)?)?;
|
||||
let start = match t {
|
||||
1 | 17 => 0,
|
||||
@@ -144,7 +146,8 @@ fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn isoweeknum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(isoweek(c.date_system(), day_arg(a, 0)?)? as f64)
|
||||
let sys = c.date_system();
|
||||
num_val(isoweek(sys, day_arg(a, 0, sys)?)? as f64)
|
||||
}
|
||||
|
||||
fn months_arg(a: &[Arg], i: usize) -> R<i64> {
|
||||
@@ -157,7 +160,7 @@ fn months_arg(a: &[Arg], i: usize) -> R<i64> {
|
||||
|
||||
fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let d = dt(sys, day_arg(a, 0)? as f64)?;
|
||||
let d = dt(sys, day_arg(a, 0, sys)? as f64)?;
|
||||
let m = months_arg(a, 1)?;
|
||||
let total = d.year as i64 * 12 + d.month as i64 - 1 + m;
|
||||
let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1);
|
||||
@@ -172,7 +175,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
|
||||
fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let d = dt(sys, day_arg(a, 0)? as f64)?;
|
||||
let d = dt(sys, day_arg(a, 0, sys)? as f64)?;
|
||||
let m = months_arg(a, 1)?;
|
||||
let total = d.year as i64 * 12 + d.month as i64 - 1 + m;
|
||||
let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1);
|
||||
@@ -185,8 +188,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
|
||||
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let s = day_arg(a, 0)?;
|
||||
let e = day_arg(a, 1)?;
|
||||
let s = day_arg(a, 0, sys)?;
|
||||
let e = day_arg(a, 1, sys)?;
|
||||
let unit = text(a, 2)?.to_ascii_uppercase();
|
||||
if s > e {
|
||||
return Err(CellError::Num);
|
||||
@@ -247,8 +250,9 @@ fn timevalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(n - n.floor())
|
||||
}
|
||||
|
||||
fn days(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((day_arg(a, 0)? - day_arg(a, 1)?) as f64)
|
||||
fn days(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
num_val((day_arg(a, 0, sys)? - day_arg(a, 1, sys)?) as f64)
|
||||
}
|
||||
|
||||
fn last_of_feb(d: &DateTime) -> bool {
|
||||
@@ -257,8 +261,8 @@ fn last_of_feb(d: &DateTime) -> bool {
|
||||
|
||||
fn days360(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let d1 = dt(sys, day_arg(a, 0)? as f64)?;
|
||||
let d2 = dt(sys, day_arg(a, 1)? as f64)?;
|
||||
let d1 = dt(sys, day_arg(a, 0, sys)? as f64)?;
|
||||
let d2 = dt(sys, day_arg(a, 1, sys)? as f64)?;
|
||||
let european = opt_bool(a, 2, false)?;
|
||||
num_val(days360_core(&d1, &d2, european) as f64)
|
||||
}
|
||||
@@ -286,7 +290,10 @@ fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> {
|
||||
Value::Empty if !arg.from_ref => Ok([true, false, false, false, false, false, true]),
|
||||
Value::Text(t) => {
|
||||
let b = t.as_bytes();
|
||||
if b.len() != 7 || !b.iter().all(|c| *c == b'0' || *c == b'1') || b.iter().all(|c| *c == b'1') {
|
||||
// All seven days marked as the weekend is a valid string: Microsoft documents
|
||||
// NETWORKDAYS.INTL as "Using 1111111 will always return 0" and WORKDAY.INTL as
|
||||
// rejecting it, which `workday_core`'s `per_week == 0` guard already implements.
|
||||
if b.len() != 7 || !b.iter().all(|c| *c == b'0' || *c == b'1') {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
// String order is Monday..Sunday.
|
||||
@@ -313,7 +320,7 @@ fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> {
|
||||
}
|
||||
}
|
||||
|
||||
fn holidays(v: Option<&Arg>) -> R<Vec<i64>> {
|
||||
fn holidays(v: Option<&Arg>, sys: DateSystem) -> R<Vec<i64>> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(a) = v {
|
||||
for x in as_array(&a.value).iter() {
|
||||
@@ -321,7 +328,7 @@ fn holidays(v: Option<&Arg>) -> R<Vec<i64>> {
|
||||
Value::Empty => {}
|
||||
Value::Error(e) => return Err(*e),
|
||||
other => {
|
||||
let n = other.to_number()?;
|
||||
let n = other.to_number_in(sys)?;
|
||||
if !(0.0..MAX_SERIAL + 1.0).contains(&n) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -353,9 +360,9 @@ fn count_workdays(sys: DateSystem, a: i64, b: i64, mask: &[bool; 7]) -> i64 {
|
||||
|
||||
fn networkdays_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let s = day_arg(a, 0)?;
|
||||
let e = day_arg(a, 1)?;
|
||||
let hs = holidays(hol)?;
|
||||
let s = day_arg(a, 0, sys)?;
|
||||
let e = day_arg(a, 1, sys)?;
|
||||
let hs = holidays(hol, sys)?;
|
||||
let (lo, hi, sign) = if s <= e { (s, e, 1) } else { (e, s, -1) };
|
||||
let mut n = count_workdays(sys, lo, hi, &mask);
|
||||
n -= hs.iter().filter(|&&h| h >= lo && h <= hi && !mask[weekday0(sys, h)]).count() as i64;
|
||||
@@ -387,13 +394,13 @@ fn advance(sys: DateSystem, start: i64, n: i64, dir: i64, mask: &[bool; 7], per_
|
||||
|
||||
fn workday_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let start = day_arg(a, 0)?;
|
||||
let start = day_arg(a, 0, sys)?;
|
||||
let days = num(a, 1)?.trunc();
|
||||
if days.abs() > 1e7 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let days = days as i64;
|
||||
let hs = holidays(hol)?;
|
||||
let hs = holidays(hol, sys)?;
|
||||
let per_week = mask.iter().filter(|w| !**w).count() as i64;
|
||||
if per_week == 0 {
|
||||
return Err(CellError::Value);
|
||||
@@ -476,10 +483,11 @@ fn feb29_between(sys: DateSystem, s: i64, e: i64, d1: &DateTime, d2: &DateTime)
|
||||
}
|
||||
|
||||
fn yearfrac(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = day_arg(a, 0)?;
|
||||
let e = day_arg(a, 1)?;
|
||||
let sys = c.date_system();
|
||||
let s = day_arg(a, 0, sys)?;
|
||||
let e = day_arg(a, 1, sys)?;
|
||||
let basis = if has(a, 2) { to_int(num(a, 2)?)? } else { 0 };
|
||||
num_val(yearfrac_core(c.date_system(), s, e, basis)?)
|
||||
num_val(yearfrac_core(sys, s, e, basis)?)
|
||||
}
|
||||
|
||||
pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
@@ -766,7 +774,10 @@ mod tests {
|
||||
close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), n(7.0)]), 24.0);
|
||||
close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), t("0000110")]), 24.0);
|
||||
close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 1.0, 31.0)), n(11.0)]), 26.0);
|
||||
is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), t("1111111")]), CellError::Value);
|
||||
// An all-weekend string is valid: no day is a workday, so the count is 0.
|
||||
close(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), t("1111111")]), 0.0);
|
||||
// WORKDAY.INTL rejects it (there is no working day to move to).
|
||||
is_err(ev("WORKDAY.INTL", vec![n(1.0), n(10.0), t("1111111")]), CellError::Value);
|
||||
is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), n(8.0)]), CellError::Num);
|
||||
// WORKDAY
|
||||
close(ev("WORKDAY", vec![n(d(2008.0, 10.0, 1.0)), n(151.0)]), d(2009.0, 4.0, 30.0));
|
||||
@@ -832,4 +843,15 @@ mod tests {
|
||||
close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0);
|
||||
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_text_arguments_use_the_workbook_date_system() {
|
||||
let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
|
||||
close(crate::call(crate::lookup("YEAR").unwrap(), &[t("1/1/2020")], &mut ctx), 2020.0);
|
||||
close(crate::call(crate::lookup("MONTH").unwrap(), &[t("2/29/1904")], &mut ctx), 2.0);
|
||||
close(crate::call(crate::lookup("DAY").unwrap(), &[t("2/29/1904")], &mut ctx), 29.0);
|
||||
close(crate::call(crate::lookup("DAYS").unwrap(), &[t("1/2/2020"), t("1/1/2020")], &mut ctx), 1.0);
|
||||
let networkdays = crate::lookup("NETWORKDAYS").unwrap();
|
||||
close(crate::call(networkdays, &[t("1/6/2020"), t("1/10/2020"), t("1/8/2020")], &mut ctx), 4.0);
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,11 @@ fn key_of(v: &Value) -> String {
|
||||
fn row_key(r: &[Value]) -> String {
|
||||
let mut s = String::new();
|
||||
for v in r {
|
||||
s.push_str(&key_of(v));
|
||||
s.push('\u{1}');
|
||||
let k = key_of(v);
|
||||
// Length prefix keeps the encoding unambiguous even when a text value contains the separator.
|
||||
s.push_str(&k.len().to_string());
|
||||
s.push(':');
|
||||
s.push_str(&k);
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -573,6 +576,23 @@ fn parse_agg(v: &Value) -> R<Agg> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The function argument of GROUPBY/PIVOTBY: a built-in aggregation, or `None` for a LAMBDA (called
|
||||
/// through the context).
|
||||
fn agg_arg(args: &[Arg], i: usize, c: &dyn Ctx) -> R<Option<Agg>> {
|
||||
if c.has_lambda() { Ok(None) } else { parse_agg(&arg(args, i)?.value).map(Some) }
|
||||
}
|
||||
|
||||
/// Aggregates one group with `f`, or with the LAMBDA (given the group's values as a column) when `None`.
|
||||
fn aggregate_with(f: Option<Agg>, vals: &[&Value], c: &mut dyn Ctx) -> Value {
|
||||
match f {
|
||||
Some(f) => aggregate(f, vals),
|
||||
None => {
|
||||
let col = Value::from(Array::column(vals.iter().map(|v| (*v).clone()).collect()));
|
||||
c.call_lambda(col).unwrap_or(Value::Error(CellError::Calc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aggregate(f: Agg, vals: &[&Value]) -> Value {
|
||||
if f == Agg::CountA {
|
||||
return Value::Number(vals.iter().filter(|v| !matches!(v, Value::Empty)).count() as f64);
|
||||
@@ -732,10 +752,10 @@ fn opt_arr(args: &[Arg], i: usize) -> R<Option<Array>> {
|
||||
if has(args, i) { Ok(Some(arr_arg(args, i)?)) } else { Ok(None) }
|
||||
}
|
||||
|
||||
fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let rf = arr_arg(args, 0)?;
|
||||
let vals = arr_arg(args, 1)?;
|
||||
let f = parse_agg(&arg(args, 2)?.value)?;
|
||||
let f = agg_arg(args, 2, c)?;
|
||||
let fh = opt_count(args, 3)?;
|
||||
let total = opt_count(args, 4)?.unwrap_or(1);
|
||||
let sort = opt_count(args, 5)?.unwrap_or(1);
|
||||
@@ -748,11 +768,11 @@ fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
let sort_in_keys = if sort.unsigned_abs() as usize <= rf.cols { sort } else { 0 };
|
||||
let mut ordered = distinct_keys(&keys, sort_in_keys);
|
||||
let agg_row = |rows: &[usize]| -> Vec<Value> {
|
||||
let mut agg_row = |rows: &[usize]| -> Vec<Value> {
|
||||
(0..vals.cols)
|
||||
.map(|c| {
|
||||
let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, c)).collect();
|
||||
aggregate(f, &vs)
|
||||
.map(|col| {
|
||||
let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, col)).collect();
|
||||
aggregate_with(f, &vs, c)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -798,11 +818,11 @@ fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols:
|
||||
(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> {
|
||||
fn pivotby(args: &[Arg], ctx: &mut dyn Ctx) -> R<Value> {
|
||||
let rf = arr_arg(args, 0)?;
|
||||
let cf = arr_arg(args, 1)?;
|
||||
let vals = arr_arg(args, 2)?;
|
||||
let f = parse_agg(&arg(args, 3)?.value)?;
|
||||
let f = agg_arg(args, 3, ctx)?;
|
||||
let fh = opt_count(args, 4)?;
|
||||
let row_total = opt_count(args, 5)?.unwrap_or(1);
|
||||
let row_sort = opt_count(args, 6)?.unwrap_or(1);
|
||||
@@ -818,12 +838,12 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
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);
|
||||
}
|
||||
let agg = |rows: &[usize], c: usize| -> Value {
|
||||
let mut agg = |rows: &[usize], c: usize| -> Value {
|
||||
if rows.is_empty() {
|
||||
return Value::Empty;
|
||||
}
|
||||
let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, c)).collect();
|
||||
aggregate(f, &vs)
|
||||
aggregate_with(f, &vs, ctx)
|
||||
};
|
||||
let rows_for = |r: Option<&Vec<Value>>, c: Option<&Vec<Value>>| -> Vec<usize> {
|
||||
let rkey = r.map(|k| row_key(k));
|
||||
@@ -873,7 +893,7 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
let body_line = |r: Option<&Vec<Value>>| -> Vec<Value> {
|
||||
let mut body_line = |r: Option<&Vec<Value>>| -> Vec<Value> {
|
||||
let mut line: Vec<Value> = match r {
|
||||
Some(k) => k.clone(),
|
||||
None => {
|
||||
@@ -1102,6 +1122,12 @@ mod tests {
|
||||
is_err(ev("SORTBY", vec![av(col(&[1.0, 2.0])), av(col(&[1.0, 2.0])), n(0.0)]), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_keeps_rows_containing_separator_apart() {
|
||||
let m = arr(vec![vec![tv("a"), tv("b\u{1}tc")], vec![tv("a\u{1}tb"), tv("c")]]);
|
||||
assert_eq!(rows_of(&ev("UNIQUE", vec![av(m)])).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique() {
|
||||
let v = arr(vec![vec![tv("a")], vec![tv("B")], vec![tv("A")], vec![tv("c")], vec![tv("b")]]);
|
||||
|
||||
@@ -374,14 +374,21 @@ pub(crate) fn series(values: &Value, timeline: &Value, opts: &Options, sys: Date
|
||||
/// value at either end).
|
||||
fn interpolate(slots: &[Option<f64>]) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(slots.len());
|
||||
// Keep the next known point until it is reached, scanning each slot only once for lookahead.
|
||||
let mut known = slots.iter().enumerate().filter_map(|(j, s)| {
|
||||
#[cfg(test)]
|
||||
tests::INTERPOLATION_VISITS.with(|visits| visits.set(visits.get() + 1));
|
||||
s.map(|v| (j, v))
|
||||
});
|
||||
let mut next = known.next();
|
||||
let mut prev: Option<(usize, f64)> = None;
|
||||
for (i, s) in slots.iter().enumerate() {
|
||||
if let Some(v) = s {
|
||||
out.push(*v);
|
||||
prev = Some((i, *v));
|
||||
next = known.next();
|
||||
continue;
|
||||
}
|
||||
let next = slots.iter().enumerate().skip(i + 1).find_map(|(j, s)| s.map(|v| (j, v)));
|
||||
out.push(match (prev, next) {
|
||||
(Some((a, va)), Some((b, vb))) => va + (vb - va) * (i - a) as f64 / (b - a) as f64,
|
||||
(Some((_, v)), None) | (None, Some((_, v))) => v,
|
||||
@@ -925,6 +932,72 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::util::testutil::*;
|
||||
|
||||
thread_local! {
|
||||
pub(super) static INTERPOLATION_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_preserves_gaps_and_endpoint_values() {
|
||||
for (slots, expected) in [
|
||||
(
|
||||
vec![None, Some(4.0), None, None, Some(13.0), Some(-2.0), None, Some(8.0), None, None],
|
||||
vec![4.0, 4.0, 7.0, 10.0, 13.0, -2.0, 3.0, 8.0, 8.0, 8.0],
|
||||
),
|
||||
(vec![Some(-7.0), None, Some(4.0)], vec![-7.0, -1.5, 4.0]),
|
||||
(vec![Some(3.0), Some(-7.0), Some(2.0)], vec![3.0, -7.0, 2.0]),
|
||||
(vec![None, None, None], vec![0.0, 0.0, 0.0]),
|
||||
(vec![Some(6.0)], vec![6.0]),
|
||||
(vec![], vec![]),
|
||||
] {
|
||||
assert_eq!(interpolate(&slots), expected, "{slots:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_lookahead_work_is_linear() {
|
||||
let mut measured = Vec::new();
|
||||
for len in [2_000, 4_000] {
|
||||
let missing = len * 3 / 10;
|
||||
let gap = (len - missing) / 2..(len + missing) / 2;
|
||||
let expected: Vec<f64> = (0..len).map(|i| (3 * i + 5) as f64).collect();
|
||||
let slots: Vec<Option<f64>> = expected.iter().enumerate().map(|(i, &v)| (!gap.contains(&i)).then_some(v)).collect();
|
||||
INTERPOLATION_VISITS.with(|visits| visits.set(0));
|
||||
let actual = interpolate(&slots);
|
||||
let visits = INTERPOLATION_VISITS.with(|visits| visits.get());
|
||||
assert_eq!(actual, expected);
|
||||
measured.push((len, visits));
|
||||
}
|
||||
// Record both sizes before asserting the work bound, including on the old algorithm.
|
||||
eprintln!("interpolation lookahead visits (slots, visits): {measured:?}");
|
||||
for (len, visits) in measured {
|
||||
assert!(visits > 0 && visits <= 2 * len, "{len} slots required {visits} lookahead visits");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_completes_a_long_gap_at_the_missing_point_limit() {
|
||||
let len = 2_000;
|
||||
let gap = 700..1_300; // Exactly 30% of the completed timeline.
|
||||
let mut times: Vec<f64> = (0..len).filter(|i| !gap.contains(i)).map(|i| (i + 1) as f64).collect();
|
||||
let mut values: Vec<f64> = times.iter().map(|t| 3.0 * t + 2.0).collect();
|
||||
let options = Options::default();
|
||||
let completed = series(&col(&values), &col(×), &options, DateSystem::D1900).unwrap();
|
||||
let expected: Vec<f64> = (0..len).map(|i| (3 * i + 5) as f64).collect();
|
||||
assert_eq!(completed.y, expected);
|
||||
assert_eq!(completed.step, 1.0);
|
||||
assert_eq!(completed.horizon((len + 1) as f64), Ok(1.0));
|
||||
|
||||
let zeroes = series(&col(&values), &col(×), &Options { interpolate: false, ..options }, DateSystem::D1900).unwrap();
|
||||
for (i, &value) in zeroes.y.iter().enumerate() {
|
||||
assert_eq!(value, if gap.contains(&i) { 0.0 } else { expected[i] });
|
||||
}
|
||||
|
||||
// Remove the first known point after the gap without changing either timeline endpoint.
|
||||
times.remove(gap.start);
|
||||
values.remove(gap.start);
|
||||
assert!(matches!(series(&col(&values), &col(×), &options, DateSystem::D1900), Err(CellError::Num)));
|
||||
}
|
||||
|
||||
fn range(xs: &[f64]) -> Arg {
|
||||
rf(col(xs))
|
||||
}
|
||||
|
||||
+154
-132
@@ -1,9 +1,9 @@
|
||||
//! Financial functions: time value of money, cash-flow analysis, depreciation and securities.
|
||||
//!
|
||||
//! Sign convention follows Excel: money paid out is negative, money received is positive.
|
||||
//! Date arguments are serial numbers (truncated) in the 1900 date system.
|
||||
//! Date arguments are serial numbers (truncated) in the workbook's date system.
|
||||
|
||||
use gridcraft_core::date::{datetime_from_serial, days_in_month, serial_from_ymd};
|
||||
use gridcraft_core::date::{datetime_from_serial, days_in_month, is_leap, max_serial, serial_from_ymd};
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
|
||||
use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num, round_half_away, text, to_sig_digits};
|
||||
@@ -526,13 +526,12 @@ fn vdb(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?);
|
||||
let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?);
|
||||
let _ = c;
|
||||
if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let full = cost * rate;
|
||||
let limit = cost - salvage;
|
||||
let dep0 = (full * year_frac(purchased, first, basis)?).min(limit);
|
||||
let dep0 = (full * year_frac(c.date_system(), purchased, first, basis)?).min(limit);
|
||||
if period == 0.0 {
|
||||
return num_val(dep0);
|
||||
}
|
||||
@@ -543,7 +542,6 @@ fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?);
|
||||
let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?);
|
||||
let _ = c;
|
||||
if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased || period > 1e6 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -559,7 +557,7 @@ fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
};
|
||||
let r = rate * coef;
|
||||
let mut book = cost;
|
||||
let mut dep = (year_frac(purchased, first, basis)? * r * cost).round();
|
||||
let mut dep = (year_frac(c.date_system(), purchased, first, basis)? * r * cost).round();
|
||||
if period == 0.0 {
|
||||
return num_val(dep);
|
||||
}
|
||||
@@ -592,13 +590,13 @@ fn date(a: &[Arg], i: usize) -> R<f64> {
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn ymd(s: f64) -> R<(i32, u32, u32)> {
|
||||
let dt = datetime_from_serial(DateSystem::D1900, s.trunc()).ok_or(CellError::Num)?;
|
||||
fn ymd(sys: DateSystem, s: f64) -> R<(i32, u32, u32)> {
|
||||
let dt = datetime_from_serial(sys, s.trunc()).ok_or(CellError::Num)?;
|
||||
Ok((dt.year, dt.month, dt.day.max(1)))
|
||||
}
|
||||
|
||||
fn serial(y: i32, m: u32, d: u32) -> R<f64> {
|
||||
serial_from_ymd(DateSystem::D1900, y as i64, m as i64, d as i64).ok_or(CellError::Num)
|
||||
fn serial(sys: DateSystem, y: i32, m: u32, d: u32) -> R<f64> {
|
||||
serial_from_ymd(sys, y as i64, m as i64, d as i64).ok_or(CellError::Num)
|
||||
}
|
||||
|
||||
fn basis_arg(a: &[Arg], i: usize) -> R<u32> {
|
||||
@@ -617,14 +615,10 @@ fn freq_arg(a: &[Arg], i: usize) -> R<f64> {
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
fn is_leap(y: i32) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
}
|
||||
|
||||
/// 30/360 day count (US/NASD when `us`, European otherwise).
|
||||
fn days360(s1: f64, s2: f64, us: bool) -> R<f64> {
|
||||
let (y1, m1, mut d1) = ymd(s1)?;
|
||||
let (y2, m2, mut d2) = ymd(s2)?;
|
||||
fn days360(sys: DateSystem, s1: f64, s2: f64, us: bool) -> R<f64> {
|
||||
let (y1, m1, mut d1) = ymd(sys, s1)?;
|
||||
let (y2, m2, mut d2) = ymd(sys, s2)?;
|
||||
if us {
|
||||
let feb_end1 = m1 == 2 && d1 >= days_in_month(y1, 2);
|
||||
let feb_end2 = m2 == 2 && d2 >= days_in_month(y2, 2);
|
||||
@@ -648,25 +642,25 @@ fn days360(s1: f64, s2: f64, us: bool) -> R<f64> {
|
||||
}
|
||||
|
||||
/// Days between two dates under a basis.
|
||||
fn day_count(s1: f64, s2: f64, basis: u32) -> R<f64> {
|
||||
fn day_count(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R<f64> {
|
||||
match basis {
|
||||
0 => days360(s1, s2, true),
|
||||
4 => days360(s1, s2, false),
|
||||
0 => days360(sys, s1, s2, true),
|
||||
4 => days360(sys, s1, s2, false),
|
||||
_ => Ok(s2 - s1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Year fraction between two dates under a basis (YEARFRAC semantics).
|
||||
fn year_frac(s1: f64, s2: f64, basis: u32) -> R<f64> {
|
||||
fn year_frac(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R<f64> {
|
||||
let (s1, s2) = if s1 <= s2 { (s1, s2) } else { (s2, s1) };
|
||||
match basis {
|
||||
0 => Ok(days360(s1, s2, true)? / 360.0),
|
||||
0 => Ok(days360(sys, s1, s2, true)? / 360.0),
|
||||
2 => Ok((s2 - s1) / 360.0),
|
||||
3 => Ok((s2 - s1) / 365.0),
|
||||
4 => Ok(days360(s1, s2, false)? / 360.0),
|
||||
4 => Ok(days360(sys, s1, s2, false)? / 360.0),
|
||||
_ => {
|
||||
let (y1, m1, d1) = ymd(s1)?;
|
||||
let (y2, m2, d2) = ymd(s2)?;
|
||||
let (y1, m1, d1) = ymd(sys, s1)?;
|
||||
let (y2, m2, d2) = ymd(sys, s2)?;
|
||||
let days = s2 - s1;
|
||||
if y1 == y2 {
|
||||
return Ok(days / if is_leap(y1) { 366.0 } else { 365.0 });
|
||||
@@ -678,8 +672,8 @@ fn year_frac(s1: f64, s2: f64, basis: u32) -> R<f64> {
|
||||
let denom = if feb29_1 || feb29_2 { 366.0 } else { 365.0 };
|
||||
return Ok(days / denom);
|
||||
}
|
||||
let start = serial(y1, 1, 1)?;
|
||||
let end = serial(y2 + 1, 1, 1).unwrap_or(2_958_466.0);
|
||||
let start = serial(sys, y1, 1, 1)?;
|
||||
let end = serial(sys, y2 + 1, 1, 1).unwrap_or(max_serial(sys) as f64 + 1.0);
|
||||
let avg = (end - start) / (y2 - y1 + 1) as f64;
|
||||
Ok(days / avg)
|
||||
}
|
||||
@@ -687,12 +681,12 @@ fn year_frac(s1: f64, s2: f64, basis: u32) -> R<f64> {
|
||||
}
|
||||
|
||||
/// Days in a year for discount securities.
|
||||
fn year_basis(basis: u32, s: f64) -> R<f64> {
|
||||
fn year_basis(sys: DateSystem, basis: u32, s: f64) -> R<f64> {
|
||||
Ok(match basis {
|
||||
0 | 2 | 4 => 360.0,
|
||||
3 => 365.0,
|
||||
_ => {
|
||||
if is_leap(ymd(s)?.0) {
|
||||
if is_leap(ymd(sys, s)?.0) {
|
||||
366.0
|
||||
} else {
|
||||
365.0
|
||||
@@ -702,7 +696,7 @@ fn year_basis(basis: u32, s: f64) -> R<f64> {
|
||||
}
|
||||
|
||||
/// Date `months` months before/after an anchor date, keeping end-of-month alignment.
|
||||
fn shift_months(anchor: (i32, u32, u32), months: i64, eom: bool) -> R<f64> {
|
||||
fn shift_months(sys: DateSystem, anchor: (i32, u32, u32), months: i64, eom: bool) -> R<f64> {
|
||||
let (y, m, d) = anchor;
|
||||
let total = y as i64 * 12 + (m as i64 - 1) + months;
|
||||
let ny = total.div_euclid(12);
|
||||
@@ -712,7 +706,7 @@ fn shift_months(anchor: (i32, u32, u32), months: i64, eom: bool) -> R<f64> {
|
||||
}
|
||||
let dim = days_in_month(ny as i32, nm);
|
||||
let nd = if eom { dim } else { d.min(dim) };
|
||||
serial(ny as i32, nm, nd)
|
||||
serial(sys, ny as i32, nm, nd)
|
||||
}
|
||||
|
||||
struct Coupon {
|
||||
@@ -721,27 +715,27 @@ struct Coupon {
|
||||
num: f64,
|
||||
}
|
||||
|
||||
fn coupons(settle: f64, mat: f64, freq: f64) -> R<Coupon> {
|
||||
fn coupons(sys: DateSystem, settle: f64, mat: f64, freq: f64) -> R<Coupon> {
|
||||
if settle >= mat {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let anchor = ymd(mat)?;
|
||||
let anchor = ymd(sys, mat)?;
|
||||
let eom = anchor.2 == days_in_month(anchor.0, anchor.1);
|
||||
let step = (12.0 / freq) as i64;
|
||||
// Estimate the count from the span, then adjust.
|
||||
let mut k = (((mat - settle) / 365.25 * freq).floor() as i64).max(1);
|
||||
while k > 1 && shift_months(anchor, -(k - 1) * step, eom)? <= settle {
|
||||
while k > 1 && shift_months(sys, anchor, -(k - 1) * step, eom)? <= settle {
|
||||
k -= 1;
|
||||
}
|
||||
let mut guard = 0;
|
||||
while shift_months(anchor, -k * step, eom)? > settle {
|
||||
while shift_months(sys, anchor, -k * step, eom)? > settle {
|
||||
k += 1;
|
||||
guard += 1;
|
||||
if guard > 100_000 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
}
|
||||
Ok(Coupon { pcd: shift_months(anchor, -k * step, eom)?, ncd: shift_months(anchor, -(k - 1) * step, eom)?, num: k as f64 })
|
||||
Ok(Coupon { pcd: shift_months(sys, anchor, -k * step, eom)?, ncd: shift_months(sys, anchor, -(k - 1) * step, eom)?, num: k as f64 })
|
||||
}
|
||||
|
||||
fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 {
|
||||
@@ -752,56 +746,57 @@ fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn coup_daybs(c: &Coupon, settle: f64, basis: u32) -> R<f64> {
|
||||
day_count(c.pcd, settle, basis)
|
||||
fn coup_daybs(sys: DateSystem, c: &Coupon, settle: f64, basis: u32) -> R<f64> {
|
||||
day_count(sys, c.pcd, settle, basis)
|
||||
}
|
||||
|
||||
fn coup_daysnc(c: &Coupon, settle: f64, freq: f64, basis: u32) -> R<f64> {
|
||||
fn coup_daysnc(sys: DateSystem, c: &Coupon, settle: f64, freq: f64, basis: u32) -> R<f64> {
|
||||
match basis {
|
||||
0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(c, settle, basis)?),
|
||||
0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(sys, c, settle, basis)?),
|
||||
_ => Ok(c.ncd - settle),
|
||||
}
|
||||
}
|
||||
|
||||
/// settlement, maturity, frequency, basis at positions 0..=3 for the COUP* functions.
|
||||
fn coup_args(a: &[Arg]) -> R<(f64, f64, f64, u32, Coupon)> {
|
||||
fn coup_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, u32, Coupon)> {
|
||||
let (s, m, f, b) = (date(a, 0)?, date(a, 1)?, freq_arg(a, 2)?, basis_arg(a, 3)?);
|
||||
let c = coupons(s, m, f)?;
|
||||
let c = coupons(sys, s, m, f)?;
|
||||
Ok((s, m, f, b, c))
|
||||
}
|
||||
|
||||
fn coupdaybs(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, _, _, b, c) = coup_args(a)?;
|
||||
num_val(coup_daybs(&c, s, b)?)
|
||||
fn coupdaybs(a: &[Arg], cx: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, _, _, b, c) = coup_args(a, cx.date_system())?;
|
||||
num_val(coup_daybs(cx.date_system(), &c, s, b)?)
|
||||
}
|
||||
|
||||
fn coupdays(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (_, _, f, b, c) = coup_args(a)?;
|
||||
fn coupdays(a: &[Arg], cx: &mut dyn Ctx) -> R<Value> {
|
||||
let (_, _, f, b, c) = coup_args(a, cx.date_system())?;
|
||||
num_val(coup_days(&c, f, b))
|
||||
}
|
||||
|
||||
fn coupdaysnc(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, _, f, b, c) = coup_args(a)?;
|
||||
num_val(coup_daysnc(&c, s, f, b)?)
|
||||
fn coupdaysnc(a: &[Arg], cx: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, _, f, b, c) = coup_args(a, cx.date_system())?;
|
||||
num_val(coup_daysnc(cx.date_system(), &c, s, f, b)?)
|
||||
}
|
||||
|
||||
fn coupncd(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(coup_args(a)?.4.ncd)
|
||||
fn coupncd(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(coup_args(a, c.date_system())?.4.ncd)
|
||||
}
|
||||
|
||||
fn couppcd(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(coup_args(a)?.4.pcd)
|
||||
fn couppcd(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(coup_args(a, c.date_system())?.4.pcd)
|
||||
}
|
||||
|
||||
fn coupnum(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(coup_args(a)?.4.num)
|
||||
fn coupnum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(coup_args(a, c.date_system())?.4.num)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Securities
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn accrint(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let sys = c.date_system();
|
||||
let (issue, first, settle, rate) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?);
|
||||
let par = if has(a, 4) { num(a, 4)? } else { 1000.0 };
|
||||
let freq = freq_arg(a, 5)?;
|
||||
@@ -810,10 +805,10 @@ fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
if issue >= settle || rate <= 0.0 || par <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let anchor = ymd(first)?;
|
||||
let anchor = ymd(sys, first)?;
|
||||
let eom = anchor.2 == days_in_month(anchor.0, anchor.1);
|
||||
let step = (12.0 / freq) as i64;
|
||||
let cd = |j: i64| shift_months(anchor, j * step, eom);
|
||||
let cd = |j: i64| shift_months(sys, anchor, j * step, eom);
|
||||
// Coupon index of the period containing a date: cd(j) <= date < cd(j+1).
|
||||
let index_of = |d: f64| -> R<i64> {
|
||||
let mut j = (((d - first) / 365.25 * freq).floor() as i64).clamp(-200_000, 200_000);
|
||||
@@ -851,7 +846,7 @@ fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
3 => 365.0 / freq,
|
||||
_ => 360.0 / freq,
|
||||
};
|
||||
sum += day_count(from, to, basis)? / nl;
|
||||
sum += day_count(sys, from, to, basis)? / nl;
|
||||
}
|
||||
j += 1;
|
||||
guard += 1;
|
||||
@@ -862,14 +857,14 @@ fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(par * rate / freq * sum)
|
||||
}
|
||||
|
||||
fn accrintm(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?);
|
||||
let par = if has(a, 3) { num(a, 3)? } else { 1000.0 };
|
||||
let basis = basis_arg(a, 4)?;
|
||||
if issue >= settle || rate <= 0.0 || par <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(par * rate * year_frac(issue, settle, basis)?)
|
||||
num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?)
|
||||
}
|
||||
|
||||
/// settlement, maturity, value, value, [basis] for discount securities.
|
||||
@@ -881,55 +876,55 @@ fn disc_args(a: &[Arg]) -> R<(f64, f64, f64, f64, u32)> {
|
||||
Ok((s, m, x, y, b))
|
||||
}
|
||||
|
||||
fn disc_frac(s: f64, m: f64, b: u32) -> R<f64> {
|
||||
Ok(day_count(s, m, b)? / year_basis(b, s)?)
|
||||
fn disc_frac(sys: DateSystem, s: f64, m: f64, b: u32) -> R<f64> {
|
||||
Ok(day_count(sys, s, m, b)? / year_basis(sys, b, s)?)
|
||||
}
|
||||
|
||||
fn disc(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn disc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, m, pr, red, b) = disc_args(a)?;
|
||||
num_val((1.0 - pr / red) / disc_frac(s, m, b)?)
|
||||
num_val((1.0 - pr / red) / disc_frac(c.date_system(), s, m, b)?)
|
||||
}
|
||||
|
||||
fn pricedisc(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn pricedisc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, m, d, red, b) = disc_args(a)?;
|
||||
num_val(red - d * red * disc_frac(s, m, b)?)
|
||||
num_val(red - d * red * disc_frac(c.date_system(), s, m, b)?)
|
||||
}
|
||||
|
||||
fn yielddisc(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn yielddisc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, m, pr, red, b) = disc_args(a)?;
|
||||
num_val((red / pr - 1.0) / disc_frac(s, m, b)?)
|
||||
num_val((red / pr - 1.0) / disc_frac(c.date_system(), s, m, b)?)
|
||||
}
|
||||
|
||||
fn intrate(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn intrate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, m, inv, red, b) = disc_args(a)?;
|
||||
num_val((red - inv) / inv / disc_frac(s, m, b)?)
|
||||
num_val((red - inv) / inv / disc_frac(c.date_system(), s, m, b)?)
|
||||
}
|
||||
|
||||
fn received(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn received(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, m, inv, d, b) = disc_args(a)?;
|
||||
let denom = 1.0 - d * disc_frac(s, m, b)?;
|
||||
let denom = 1.0 - d * disc_frac(c.date_system(), s, m, b)?;
|
||||
if denom <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(inv / denom)
|
||||
}
|
||||
|
||||
fn tbill_args(a: &[Arg]) -> R<(f64, f64)> {
|
||||
fn tbill_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64)> {
|
||||
let (s, m, x) = (date(a, 0)?, date(a, 1)?, num(a, 2)?);
|
||||
if s >= m || x <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
// At most one year after settlement.
|
||||
let (y, mo, d) = ymd(s)?;
|
||||
let limit = shift_months((y, mo, d), 12, false)?;
|
||||
let (y, mo, d) = ymd(sys, s)?;
|
||||
let limit = shift_months(sys, (y, mo, d), 12, false)?;
|
||||
if m > limit {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok((m - s, x))
|
||||
}
|
||||
|
||||
fn tbillprice(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dsm, d) = tbill_args(a)?;
|
||||
fn tbillprice(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dsm, d) = tbill_args(a, c.date_system())?;
|
||||
let p = 100.0 * (1.0 - d * dsm / 360.0);
|
||||
if p <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
@@ -937,13 +932,13 @@ fn tbillprice(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(p)
|
||||
}
|
||||
|
||||
fn tbillyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dsm, pr) = tbill_args(a)?;
|
||||
fn tbillyield(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dsm, pr) = tbill_args(a, c.date_system())?;
|
||||
num_val((100.0 - pr) / pr * 360.0 / dsm)
|
||||
}
|
||||
|
||||
fn tbilleq(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dsm, d) = tbill_args(a)?;
|
||||
fn tbilleq(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dsm, d) = tbill_args(a, c.date_system())?;
|
||||
if dsm <= 182.0 {
|
||||
return num_val(365.0 * d / (360.0 - d * dsm));
|
||||
}
|
||||
@@ -960,24 +955,24 @@ fn tbilleq(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
/// settlement, maturity, issue, rate, value, [basis].
|
||||
fn mat_terms(a: &[Arg]) -> R<(f64, f64, f64, f64, f64)> {
|
||||
fn mat_terms(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, f64, f64)> {
|
||||
let (s, m, issue, rate, x, b) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?, num(a, 4)?, basis_arg(a, 5)?);
|
||||
if s >= m || issue >= s || rate < 0.0 || x < 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let dim = year_frac(issue, m, b)?;
|
||||
let dsm = year_frac(s, m, b)?;
|
||||
let ai = year_frac(issue, s, b)?;
|
||||
let dim = year_frac(sys, issue, m, b)?;
|
||||
let dsm = year_frac(sys, s, m, b)?;
|
||||
let ai = year_frac(sys, issue, s, b)?;
|
||||
Ok((dim, dsm, ai, rate, x))
|
||||
}
|
||||
|
||||
fn pricemat(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dim, dsm, ai, rate, yld) = mat_terms(a)?;
|
||||
fn pricemat(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dim, dsm, ai, rate, yld) = mat_terms(a, c.date_system())?;
|
||||
num_val((100.0 + dim * rate * 100.0) / (1.0 + dsm * yld) - ai * rate * 100.0)
|
||||
}
|
||||
|
||||
fn yieldmat(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dim, dsm, ai, rate, pr) = mat_terms(a)?;
|
||||
fn yieldmat(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (dim, dsm, ai, rate, pr) = mat_terms(a, c.date_system())?;
|
||||
if pr <= 0.0 || dsm == 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -993,11 +988,11 @@ struct Bond {
|
||||
freq: f64,
|
||||
}
|
||||
|
||||
fn bond(settle: f64, mat: f64, freq: f64, basis: u32) -> R<Bond> {
|
||||
let c = coupons(settle, mat, freq)?;
|
||||
fn bond(sys: DateSystem, settle: f64, mat: f64, freq: f64, basis: u32) -> R<Bond> {
|
||||
let c = coupons(sys, settle, mat, freq)?;
|
||||
let e = coup_days(&c, freq, basis);
|
||||
let a = coup_daybs(&c, settle, basis)?;
|
||||
let dsc = coup_daysnc(&c, settle, freq, basis)?;
|
||||
let a = coup_daybs(sys, &c, settle, basis)?;
|
||||
let dsc = coup_daysnc(sys, &c, settle, freq, basis)?;
|
||||
if e <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -1022,23 +1017,23 @@ fn bond_price(b: &Bond, rate: f64, yld: f64, red: f64) -> f64 {
|
||||
sum - accrued
|
||||
}
|
||||
|
||||
fn price(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn price(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, m, rate, yld, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?);
|
||||
let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?);
|
||||
if rate < 0.0 || yld < 0.0 || red <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let b = bond(s, m, f, basis)?;
|
||||
let b = bond(c.date_system(), s, m, f, basis)?;
|
||||
num_val(bond_price(&b, rate, yld, red))
|
||||
}
|
||||
|
||||
fn yield_(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn yield_(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (s, m, rate, pr, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?);
|
||||
let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?);
|
||||
if rate < 0.0 || pr <= 0.0 || red <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let b = bond(s, m, f, basis)?;
|
||||
let b = bond(c.date_system(), s, m, f, basis)?;
|
||||
if b.n == 1.0 {
|
||||
let base = pr / 100.0 + b.a / b.e * rate / f;
|
||||
return num_val(((red / 100.0 + rate / f) - base) / base * f * b.e / b.dsc);
|
||||
@@ -1065,13 +1060,13 @@ fn yield_(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(0.5 * (lo + hi))
|
||||
}
|
||||
|
||||
fn duration_calc(a: &[Arg]) -> R<(f64, f64, f64)> {
|
||||
fn duration_calc(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64)> {
|
||||
let (s, m, coupon, yld) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?);
|
||||
let (f, basis) = (freq_arg(a, 4)?, basis_arg(a, 5)?);
|
||||
if coupon < 0.0 || yld < 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let b = bond(s, m, f, basis)?;
|
||||
let b = bond(sys, s, m, f, basis)?;
|
||||
let c = coupon * 100.0 / f;
|
||||
let y = 1.0 + yld / f;
|
||||
let frac = b.dsc / b.e;
|
||||
@@ -1091,12 +1086,12 @@ fn duration_calc(a: &[Arg]) -> R<(f64, f64, f64)> {
|
||||
Ok((wsum / psum / f, yld, f))
|
||||
}
|
||||
|
||||
fn duration(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(duration_calc(a)?.0)
|
||||
fn duration(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(duration_calc(a, c.date_system())?.0)
|
||||
}
|
||||
|
||||
fn mduration(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (d, y, f) = duration_calc(a)?;
|
||||
fn mduration(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (d, y, f) = duration_calc(a, c.date_system())?;
|
||||
num_val(d / (1.0 + y / f))
|
||||
}
|
||||
|
||||
@@ -1122,8 +1117,8 @@ fn normal_length(start: f64, end: f64, freq: f64, basis: u32) -> f64 {
|
||||
}
|
||||
|
||||
/// Days of `[from, to]` under a basis, or 0 when the span is empty.
|
||||
fn span(from: f64, to: f64, basis: u32) -> R<f64> {
|
||||
if to > from { day_count(from, to, basis) } else { Ok(0.0) }
|
||||
fn span(sys: DateSystem, from: f64, to: f64, basis: u32) -> R<f64> {
|
||||
if to > from { day_count(sys, from, to, basis) } else { Ok(0.0) }
|
||||
}
|
||||
|
||||
/// The odd-first-period terms that ODDFPRICE and ODDFYIELD share.
|
||||
@@ -1141,14 +1136,14 @@ struct OddFirst {
|
||||
freq: f64,
|
||||
}
|
||||
|
||||
fn odd_first(settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R<OddFirst> {
|
||||
fn odd_first(sys: DateSystem, settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R<OddFirst> {
|
||||
if !(mat > first && first > settle && settle > issue) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let anchor = ymd(first)?;
|
||||
let anchor = ymd(sys, first)?;
|
||||
let eom = anchor.2 == days_in_month(anchor.0, anchor.1);
|
||||
let step = (12.0 / freq) as i64;
|
||||
let quasi = |k: i64| shift_months(anchor, -k * step, eom);
|
||||
let quasi = |k: i64| shift_months(sys, anchor, -k * step, eom);
|
||||
let (mut dc, mut acc) = (0.0, 0.0);
|
||||
let (mut nq, mut frac) = (None, 0.0);
|
||||
// Walk back from the first coupon: period k runs from quasi(k + 1) to quasi(k).
|
||||
@@ -1163,11 +1158,11 @@ fn odd_first(settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u3
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let from = start.max(issue);
|
||||
dc += span(from, end, basis)? / nl;
|
||||
acc += span(from, settle.min(end), basis)? / nl;
|
||||
dc += span(sys, from, end, basis)? / nl;
|
||||
acc += span(sys, from, settle.min(end), basis)? / nl;
|
||||
if start <= settle && settle < end {
|
||||
nq = Some(k as f64);
|
||||
frac = span(settle, end, basis)? / nl;
|
||||
frac = span(sys, settle, end, basis)? / nl;
|
||||
}
|
||||
if start <= issue {
|
||||
break;
|
||||
@@ -1175,7 +1170,7 @@ fn odd_first(settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u3
|
||||
k += 1;
|
||||
}
|
||||
let nq = nq.ok_or(CellError::Num)?;
|
||||
let n = coupons(first, mat, freq)?.num;
|
||||
let n = coupons(sys, first, mat, freq)?.num;
|
||||
Ok(OddFirst { n, nq, frac, dc, acc, freq })
|
||||
}
|
||||
|
||||
@@ -1193,26 +1188,26 @@ fn odd_first_price(t: &OddFirst, rate: f64, yld: f64, red: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// settlement, maturity, issue, first_coupon, rate, yld/pr, redemption, frequency, [basis].
|
||||
fn odd_first_args(a: &[Arg]) -> R<(OddFirst, f64, f64, f64)> {
|
||||
fn odd_first_args(a: &[Arg], sys: DateSystem) -> R<(OddFirst, f64, f64, f64)> {
|
||||
let (s, m, issue, first) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, date(a, 3)?);
|
||||
let (rate, x, red) = (num(a, 4)?, num(a, 5)?, num(a, 6)?);
|
||||
let (f, basis) = (freq_arg(a, 7)?, basis_arg(a, 8)?);
|
||||
if rate < 0.0 || red <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok((odd_first(s, m, issue, first, f, basis)?, rate, x, red))
|
||||
Ok((odd_first(sys, s, m, issue, first, f, basis)?, rate, x, red))
|
||||
}
|
||||
|
||||
fn oddfprice(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (t, rate, yld, red) = odd_first_args(a)?;
|
||||
fn oddfprice(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (t, rate, yld, red) = odd_first_args(a, c.date_system())?;
|
||||
if yld < 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(odd_first_price(&t, rate, yld, red))
|
||||
}
|
||||
|
||||
fn oddfyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (t, rate, pr, red) = odd_first_args(a)?;
|
||||
fn oddfyield(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (t, rate, pr, red) = odd_first_args(a, c.date_system())?;
|
||||
if pr <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -1240,11 +1235,11 @@ fn oddfyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
/// Σ DCi/NLi, Σ Ai/NLi and Σ DSCi/NLi over an odd last period.
|
||||
fn odd_last(settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> {
|
||||
fn odd_last(sys: DateSystem, settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> {
|
||||
if !(mat > settle && settle > last) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let anchor = ymd(last)?;
|
||||
let anchor = ymd(sys, last)?;
|
||||
let eom = anchor.2 == days_in_month(anchor.0, anchor.1);
|
||||
let step = (12.0 / freq) as i64;
|
||||
let (mut dc, mut acc, mut dsc) = (0.0, 0.0, 0.0);
|
||||
@@ -1255,15 +1250,15 @@ fn odd_last(settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64,
|
||||
if k > MAX_QUASI {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let end = shift_months(anchor, k * step, eom)?;
|
||||
let end = shift_months(sys, anchor, k * step, eom)?;
|
||||
let nl = normal_length(start, end, freq, basis);
|
||||
if nl <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let stop = end.min(mat);
|
||||
dc += span(start, stop, basis)? / nl;
|
||||
acc += span(start, settle.min(stop), basis)? / nl;
|
||||
dsc += span(settle.max(start), stop, basis)? / nl;
|
||||
dc += span(sys, start, stop, basis)? / nl;
|
||||
acc += span(sys, start, settle.min(stop), basis)? / nl;
|
||||
dsc += span(sys, settle.max(start), stop, basis)? / nl;
|
||||
if end >= mat {
|
||||
break;
|
||||
}
|
||||
@@ -1274,18 +1269,18 @@ fn odd_last(settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64,
|
||||
}
|
||||
|
||||
/// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis].
|
||||
fn odd_last_args(a: &[Arg]) -> R<((f64, f64, f64), f64, f64, f64, f64)> {
|
||||
fn odd_last_args(a: &[Arg], sys: DateSystem) -> R<((f64, f64, f64), f64, f64, f64, f64)> {
|
||||
let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?);
|
||||
let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?);
|
||||
let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?);
|
||||
if rate < 0.0 || red <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok((odd_last(s, m, last, f, basis)?, rate, x, red, f))
|
||||
Ok((odd_last(sys, s, m, last, f, basis)?, rate, x, red, f))
|
||||
}
|
||||
|
||||
fn oddlprice(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a)?;
|
||||
fn oddlprice(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a, c.date_system())?;
|
||||
if yld < 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -1293,8 +1288,8 @@ fn oddlprice(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon)
|
||||
}
|
||||
|
||||
fn oddlyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a)?;
|
||||
fn oddlyield(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a, c.date_system())?;
|
||||
if pr <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -2019,4 +2014,31 @@ mod tests {
|
||||
is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value);
|
||||
is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn securities_read_dates_in_the_1904_system() {
|
||||
let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap());
|
||||
let ev4 = |name: &str, args: Vec<Arg>| {
|
||||
let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() };
|
||||
crate::call(crate::lookup(name).unwrap(), &args, &mut ctx)
|
||||
};
|
||||
let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30));
|
||||
close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0);
|
||||
close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0);
|
||||
close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0);
|
||||
let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)];
|
||||
close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9);
|
||||
// Results that already matched Excel keep matching.
|
||||
let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]);
|
||||
close(semi("COUPPCD"), 39050.0);
|
||||
close(semi("COUPDAYS"), 180.0);
|
||||
close(semi("COUPDAYBS"), 105.0);
|
||||
close(semi("COUPDAYSNC"), 75.0);
|
||||
close(semi("COUPNUM"), 2.0);
|
||||
let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)];
|
||||
close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9);
|
||||
close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9);
|
||||
let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)];
|
||||
close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,17 @@ fn na(_a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn n_fn(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
match first(a) {
|
||||
// N(range) reads the range's first cell, but a computed array (N(A1:A3>0), N({...})) is
|
||||
// converted element by element, so N can stand in for `--` inside SUMPRODUCT.
|
||||
if let Some(Arg { value: Value::Array(arr), from_ref: false }) = a.first() {
|
||||
let data = arr.data.iter().map(|v| n_of(v.clone()).unwrap_or_else(Value::Error)).collect();
|
||||
return Array::new(arr.rows, arr.cols, data).map(Value::from).ok_or(CellError::Calc);
|
||||
}
|
||||
n_of(first(a))
|
||||
}
|
||||
|
||||
fn n_of(v: Value) -> R<Value> {
|
||||
match v {
|
||||
Value::Number(n) => Ok(Value::Number(n)),
|
||||
Value::Bool(b) => Ok(Value::Number(if b { 1.0 } else { 0.0 })),
|
||||
Value::Error(e) => Err(e),
|
||||
@@ -536,7 +546,9 @@ mod tests {
|
||||
close(ev("N", vec![b(true)]), 1.0);
|
||||
close(ev("N", vec![t("7")]), 0.0);
|
||||
is_err(ev("N", vec![e(CellError::Num)]), CellError::Num);
|
||||
close(ev("N", vec![av(row(&[3.0, 4.0]))]), 3.0);
|
||||
close(ev("N", vec![rf(row(&[3.0, 4.0]))]), 3.0);
|
||||
let lifted = ev("N", vec![av(arr(vec![vec![Value::Bool(true), tv("x")], vec![Value::Error(CellError::Div0), nv(4.0)]]))]);
|
||||
assert_eq!(rows_of(&lifted), vec![vec![nv(1.0), nv(0.0)], vec![Value::Error(CellError::Div0), nv(4.0)]]);
|
||||
is_text(ev("INFO", vec![t("RECALC")]), "Automatic");
|
||||
is_err(ev("INFO", vec![t("bogus")]), CellError::Value);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,15 @@ pub trait Ctx {
|
||||
fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
/// For GROUPBY/PIVOTBY: whether their function argument is a LAMBDA (its value is then not used).
|
||||
fn has_lambda(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Calls that LAMBDA with `arg` (one group's values) and returns its single result; `None` when
|
||||
/// there is no lambda.
|
||||
fn call_lambda(&mut self, _arg: Value) -> Option<Value> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value;
|
||||
|
||||
@@ -413,15 +413,15 @@ fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sheet text for ADDRESS, quoted when it needs to be. A `[Book]Sheet` prefix is judged by its
|
||||
/// Sheet text for ADDRESS, quoted when Excel quotes it. A `[Book]Sheet` prefix is judged by its
|
||||
/// parts (Microsoft's ADDRESS examples: `"[Book1]Sheet1"` stays bare, `"EXCEL SHEET"` becomes
|
||||
/// `'EXCEL SHEET'`).
|
||||
/// `'EXCEL SHEET'`). Unlike in a formula, `TRUE` and `FALSE` stay bare.
|
||||
fn quote_sheet(s: &str) -> String {
|
||||
let word = |p: &str| p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.');
|
||||
let bare_sheet = |p: &str| !p.is_empty() && word(p) && !p.chars().next().is_some_and(|c| c.is_ascii_digit());
|
||||
let bare = |p: &str| p.eq_ignore_ascii_case("TRUE") || p.eq_ignore_ascii_case("FALSE") || !gridcraft_core::sheet_needs_quotes(p);
|
||||
let word = |p: &str| !p.is_empty() && p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.');
|
||||
let plain = match s.strip_prefix('[').and_then(|r| r.split_once(']')) {
|
||||
Some((book, sheet)) => !book.is_empty() && word(book) && bare_sheet(sheet),
|
||||
None => bare_sheet(s),
|
||||
Some((book, sheet)) => word(book) && bare(sheet),
|
||||
None => bare(s),
|
||||
};
|
||||
if plain { s.to_string() } else { format!("'{}'", s.replace('\'', "''")) }
|
||||
}
|
||||
@@ -791,6 +791,13 @@ mod tests {
|
||||
for (r, c, abs, a1, sheet, want) in cases {
|
||||
is_text(ev("ADDRESS", vec![n(r), n(c), n(abs), b(a1), t(sheet)]), want);
|
||||
}
|
||||
// Sheet names that read as a cell or an R1C1 reference, or start with a dot, are quoted;
|
||||
// off-sheet cells and booleans are not (#1458).
|
||||
for (sheet, want) in
|
||||
[("A1", "'A1'!$A$1"), ("R1C1", "'R1C1'!$A$1"), ("RC", "'RC'!$A$1"), (".x", "'.x'!$A$1"), ("XFE1", "XFE1!$A$1"), ("TRUE", "TRUE!$A$1")]
|
||||
{
|
||||
is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t(sheet)]), want);
|
||||
}
|
||||
is_text(ev("ADDRESS", vec![n(1048576.0), n(16384.0)]), "$XFD$1048576");
|
||||
is_err(ev("ADDRESS", vec![n(0.0), n(1.0)]), CellError::Value);
|
||||
is_err(ev("ADDRESS", vec![n(1.0), n(16385.0)]), CellError::Value);
|
||||
|
||||
@@ -534,6 +534,25 @@ fn strict_num(v: &Value) -> R<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a
|
||||
/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated
|
||||
/// gigabytes and could abort the process.
|
||||
const MAX_REGRESSORS: usize = 255;
|
||||
/// Most multiply-adds (rows × k²) a regression may spend building its normal equations.
|
||||
const MAX_REGRESSION_WORK: usize = 1_000_000_000;
|
||||
|
||||
/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`,
|
||||
/// more than [`MAX_REGRESSION_WORK`] multiply-adds is `#NUM!`.
|
||||
fn check_regression_size(n: usize, k: usize) -> R<()> {
|
||||
if k > MAX_REGRESSORS {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
match n.checked_mul(k).and_then(|nk| nk.checked_mul(k)) {
|
||||
Some(work) if work <= MAX_REGRESSION_WORK => Ok(()),
|
||||
_ => Err(CellError::Num),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_xy(y: &Value, x: Option<&Value>) -> R<Xy> {
|
||||
let ya = as_array(y);
|
||||
let n = ya.data.len();
|
||||
@@ -546,12 +565,14 @@ fn parse_xy(y: &Value, x: Option<&Value>) -> R<Xy> {
|
||||
if xa.rows == ya.rows && xa.cols == ya.cols {
|
||||
(xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::<R<Vec<_>>>()?, 1)
|
||||
} else if !y_is_row && xa.rows == n {
|
||||
check_regression_size(n, xa.cols)?;
|
||||
let mut rows = Vec::with_capacity(n);
|
||||
for r in 0..n {
|
||||
rows.push((0..xa.cols).map(|c| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::<R<Vec<_>>>()?);
|
||||
}
|
||||
(rows, xa.cols)
|
||||
} else if y_is_row && xa.cols == n {
|
||||
check_regression_size(n, xa.rows)?;
|
||||
let mut rows = Vec::with_capacity(n);
|
||||
for c in 0..n {
|
||||
rows.push((0..xa.rows).map(|r| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::<R<Vec<_>>>()?);
|
||||
@@ -638,6 +659,7 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
|
||||
if n == 0 || x.len() != n {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
check_regression_size(n, k)?;
|
||||
let nf = n as f64;
|
||||
let (xm, ym) = if constant {
|
||||
let xm: Vec<f64> = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::<f64>() / nf).collect();
|
||||
@@ -1243,6 +1265,10 @@ fn binom_inv(a: &[Arg], _: C) -> R<Value> {
|
||||
}
|
||||
while hi - lo > 1.0 {
|
||||
let mid = ((lo + hi) / 2.0).floor();
|
||||
// Above 2^53 adjacent f64 values are more than 1 apart, so the gap can stop shrinking.
|
||||
if mid <= lo || mid >= hi {
|
||||
break;
|
||||
}
|
||||
if binom_cdf(mid, n, p) >= alpha {
|
||||
hi = mid;
|
||||
} else {
|
||||
@@ -2245,7 +2271,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{aggregate_values, aggregate_values_k};
|
||||
use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size};
|
||||
use crate::util::testutil::*;
|
||||
use gridcraft_core::{CellError, Value};
|
||||
|
||||
@@ -2531,6 +2557,28 @@ mod tests {
|
||||
is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref);
|
||||
}
|
||||
|
||||
// #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³)
|
||||
// inversion; a wider TOROW aborted the process. It is now refused before allocating.
|
||||
#[test]
|
||||
fn linest_family_caps_regressor_count() {
|
||||
let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()]));
|
||||
let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect()));
|
||||
for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] {
|
||||
// y is one cell, so every column of x is a predictor.
|
||||
is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value);
|
||||
// y is a row, so every row of x is a predictor.
|
||||
is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value);
|
||||
}
|
||||
is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value);
|
||||
// At the cap the fit still runs (one observation: coefficients are zero, not an error).
|
||||
assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1);
|
||||
// The rows × k² work bound.
|
||||
assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(()));
|
||||
assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num));
|
||||
assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num));
|
||||
assert_eq!(check_regression_size(10_000_000, 1), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequency_prob() {
|
||||
let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]);
|
||||
@@ -2624,6 +2672,24 @@ mod tests {
|
||||
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binom_inv_huge_trials_returns() {
|
||||
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
|
||||
let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)];
|
||||
for (name, trials, p, alpha) in cases {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let r = ev(name, vec![n(trials), n(p), n(alpha)]);
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {}
|
||||
Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"),
|
||||
Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discrete() {
|
||||
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
|
||||
|
||||
@@ -409,10 +409,11 @@ const THAI_DIGITS: [&str; 10] = ["ศูนย์", "หนึ่ง", "สอ
|
||||
const THAI_PLACES: [&str; 6] = ["", "สิบ", "ร้อย", "พัน", "หมื่น", "แสน"];
|
||||
|
||||
/// Thai words for a group of up to six decimal digits (most significant first); empty for zero.
|
||||
/// One in the tens is สิบ, two is ยี่สิบ, and a final one after higher digits is เอ็ด.
|
||||
fn thai_group(digits: &[u8]) -> String {
|
||||
/// One in the tens is สิบ, two is ยี่สิบ, and a final one after higher digits is เอ็ด; `after`
|
||||
/// says the number already has a non-zero digit in a higher group (so 1,000,001 ends in เอ็ด).
|
||||
fn thai_group(digits: &[u8], after: bool) -> String {
|
||||
let n = digits.len();
|
||||
let higher = digits.iter().take(n.saturating_sub(1)).any(|d| *d != 0);
|
||||
let higher = after || digits.iter().take(n.saturating_sub(1)).any(|d| *d != 0);
|
||||
let mut out = String::new();
|
||||
for (i, &d) in digits.iter().enumerate() {
|
||||
let place = n - 1 - i;
|
||||
@@ -445,7 +446,7 @@ fn thai_number(digits: &str) -> String {
|
||||
let mut start = 0;
|
||||
let mut len = first;
|
||||
while let Some(group) = d.get(start..start + len) {
|
||||
out.push_str(&thai_group(group));
|
||||
out.push_str(&thai_group(group, start > 0));
|
||||
start += len;
|
||||
len = 6;
|
||||
if start < d.len() {
|
||||
@@ -976,6 +977,17 @@ mod tests {
|
||||
is_text(bt(1.999), "สองบาทถ้วน");
|
||||
is_text(bt(0.005), "หนึ่งสตางค์");
|
||||
is_text(bt(-0.001), "ศูนย์บาทถ้วน");
|
||||
// A final one is เอ็ด after higher digits in any group, millions included (#1234).
|
||||
is_text(bt(21_000_001.0), "ยี่สิบเอ็ดล้านเอ็ดบาทถ้วน");
|
||||
is_text(bt(1_000_001.0), "หนึ่งล้านเอ็ดบาทถ้วน");
|
||||
is_text(bt(1_000_001_000_000.0), "หนึ่งล้านเอ็ดล้านบาทถ้วน");
|
||||
is_text(bt(1001.0), "หนึ่งพันเอ็ดบาทถ้วน");
|
||||
is_text(bt(11.0), "สิบเอ็ดบาทถ้วน");
|
||||
is_text(bt(1.0), "หนึ่งบาทถ้วน");
|
||||
is_text(bt(0.01), "หนึ่งสตางค์");
|
||||
is_text(bt(0.21), "ยี่สิบเอ็ดสตางค์");
|
||||
is_text(bt(1250.5), "หนึ่งพันสองร้อยห้าสิบบาทห้าสิบสตางค์");
|
||||
is_text(bt(0.25), "ยี่สิบห้าสตางค์");
|
||||
is_err(ev("BAHTTEXT", vec![t("abc")]), CellError::Value);
|
||||
// Huge numbers give text (or #NUM! where rounding overflows), never a panic.
|
||||
assert!(matches!(bt(-1e300), Value::Text(_)));
|
||||
|
||||
@@ -466,7 +466,6 @@ fn other_sheet_prefix_is_error() {
|
||||
ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}}));
|
||||
fails(&mut s, "set_cell", json!({"cell": "Nope!B2", "input": "x"}));
|
||||
// A command that cannot honour a sheet prefix must not drop it and use the active sheet.
|
||||
cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"}));
|
||||
cmd_fails(&mut s, "formulas.tracePrecedents", json!({"cell": "Other!B2"}));
|
||||
ok(&mut s, "set_cell", json!({"cell": "Sheet1!B2", "input": "here"}));
|
||||
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "here");
|
||||
@@ -498,7 +497,10 @@ fn cross_sheet_cell_refs_resolve() {
|
||||
// The active sheet may be a quoted name with spaces.
|
||||
ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": "My Sheet"}}));
|
||||
ok(&mut s, "execute_command", json!({"command": "view.freezePanes", "params": {"cell": "'My Sheet'!B2"}}));
|
||||
cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"}));
|
||||
// Freeze Panes honours another sheet's prefix (or `sheet`) and leaves the active sheet alone.
|
||||
ok(&mut s, "execute_command", json!({"command": "view.freezePanes", "params": {"cell": "Other!C4"}}));
|
||||
let sheets = ok(&mut s, "inspect_workbook", json!({}))["sheets"].clone();
|
||||
assert_eq!((sheets[1]["freeze"].clone(), sheets[2]["freeze"].clone()), (json!([3, 2]), json!([1, 1])), "{sheets}");
|
||||
// A prefix naming a different sheet than the `sheet` argument is an error, not resolved in favour of either.
|
||||
cmd_fails(&mut s, "cell.get", json!({"cell": "Other!B3", "sheet": "Sheet1"}));
|
||||
}
|
||||
|
||||
@@ -196,7 +196,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"evaluate_formula",
|
||||
"Evaluate formula",
|
||||
"Evaluate a formula against the active sheet WITHOUT changing any cell, e.g. \"=SUM(B2:B10)/COUNT(B2:B10)\" or \
|
||||
\"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN().",
|
||||
\"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN(). \
|
||||
Use English function names and `,` between arguments; a formula that couldn't be entered in a cell is an error saying why.",
|
||||
obj(
|
||||
json!({"formula": string("Formula text, with or without the leading ="), "cell": string("Evaluate as if in this cell (default: active cell)")}),
|
||||
&["formula"],
|
||||
|
||||
+22
-4
@@ -49,22 +49,40 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error>
|
||||
decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64"))
|
||||
}
|
||||
|
||||
/// Base64 for optional byte blobs (VBA projects): `null`/missing stays `None`.
|
||||
/// Base64 for byte blobs shared through `Arc` (floating pictures), so copies of a workbook, such as
|
||||
/// its undo steps, share one payload.
|
||||
pub mod shared {
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &Arc<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
|
||||
super::serialize(v, s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Arc<Vec<u8>>, D::Error> {
|
||||
super::deserialize(d).map(Arc::new)
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64 for optional shared byte blobs (VBA projects): `null`/missing stays `None`.
|
||||
pub mod opt {
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
|
||||
pub fn serialize<S: Serializer>(v: &Option<Arc<Vec<u8>>>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match v {
|
||||
Some(data) => s.serialize_str(&super::encode(data)),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Arc<Vec<u8>>>, D::Error> {
|
||||
let s = Option::<String>::deserialize(d)?;
|
||||
match s {
|
||||
None => Ok(None),
|
||||
Some(s) => super::decode(&s).map(Some).ok_or_else(|| serde::de::Error::custom("invalid base64")),
|
||||
Some(s) => super::decode(&s).map(|v| Some(Arc::new(v))).ok_or_else(|| serde::de::Error::custom("invalid base64")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ pub struct Formula {
|
||||
/// Legacy array formula (Ctrl+Shift+Enter) covering this range; the formula lives in the
|
||||
/// top-left cell.
|
||||
pub array: Option<RangeRef>,
|
||||
/// Read from a file that marks this formula as a dynamic-array formula (cell metadata `cm`).
|
||||
/// The writer keeps the mark, so a formula Excel evaluates as an array is never saved as a
|
||||
/// legacy formula (which Excel evaluates with implicit intersection). Formulas entered or
|
||||
/// rewritten in GridCraft start without it, and the writer decides from their shape.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dynamic: bool,
|
||||
}
|
||||
|
||||
impl Formula {
|
||||
@@ -25,12 +31,12 @@ impl Formula {
|
||||
pub fn new(text: &str) -> Formula {
|
||||
let body = text.strip_prefix('=').unwrap_or(text);
|
||||
match gridcraft_formula::parse(body) {
|
||||
Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None },
|
||||
Err(_) => Formula { text: body.to_string(), expr: None, array: None },
|
||||
Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false },
|
||||
Err(_) => Formula { text: body.to_string(), expr: None, array: None, dynamic: false },
|
||||
}
|
||||
}
|
||||
pub fn from_expr(e: Expr) -> Formula {
|
||||
Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None }
|
||||
Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false }
|
||||
}
|
||||
/// The parsed expression, re-parsing after deserialization.
|
||||
pub fn expr(&self) -> Option<Expr> {
|
||||
|
||||
@@ -41,6 +41,7 @@ mod tests {
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false });
|
||||
|
||||
@@ -323,6 +323,34 @@ pub struct Comment {
|
||||
/// read from the file (`dT`); empty when unknown, e.g. for comments made here.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dates: Vec<String>,
|
||||
/// Where the note's box sits and how big it is, as read from the file; `None` (a note made
|
||||
/// here, or a file without the shape) writes the default box next to the cell.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note_box: Option<NoteBox>,
|
||||
}
|
||||
|
||||
/// A note box's place and size, from the note's VML shape: Excel's `<x:Anchor>` with its corners
|
||||
/// stored relative to the note's cell, so the box moves with the note when rows or columns are
|
||||
/// inserted or deleted, and the shape's width and height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NoteBox {
|
||||
/// Top-left corner: columns and rows from the note's cell (negative = left of or above it),
|
||||
/// then the pixel offset into that column and row.
|
||||
pub from: NoteCorner,
|
||||
/// Bottom-right corner, the same way.
|
||||
pub to: NoteCorner,
|
||||
/// The shape's width and height in points (the VML style's `width`/`height`), when given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size_pt: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// One corner of a [`NoteBox`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NoteCorner {
|
||||
pub col: i64,
|
||||
pub col_px: u32,
|
||||
pub row: i64,
|
||||
pub row_px: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -406,6 +434,14 @@ pub enum LegendPos {
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Quartile calculation for a box & whisker series.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuartileMethod {
|
||||
#[default]
|
||||
Exclusive,
|
||||
Inclusive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Series {
|
||||
/// Formula text for the series name (`Sheet1!$B$1`) or a literal.
|
||||
@@ -421,6 +457,9 @@ pub struct Series {
|
||||
/// Draw line segments as a smoothed (spline) curve.
|
||||
#[serde(default)]
|
||||
pub smooth: bool,
|
||||
/// Box & whisker quartiles; Excel defaults to exclusive.
|
||||
#[serde(default)]
|
||||
pub quartiles: QuartileMethod,
|
||||
/// Trendlines fitted to this series (Excel allows several per series).
|
||||
#[serde(default)]
|
||||
pub trendlines: Vec<Trendline>,
|
||||
@@ -482,6 +521,18 @@ pub struct Trendline {
|
||||
pub dash: Option<LineDash>,
|
||||
}
|
||||
|
||||
impl Trendline {
|
||||
/// Longest forward / backward forecast kept from a file or a command, in x units. Excel's own
|
||||
/// limit is unconfirmed; the renderer additionally bounds the forecast by the plot's slot budget.
|
||||
pub const MAX_FORECAST: f64 = 100_000.0;
|
||||
|
||||
/// A forecast distance as stored: finite, not negative (NaN and infinity become 0) and at most
|
||||
/// [`Self::MAX_FORECAST`].
|
||||
pub fn clamp_forecast(v: f64) -> f64 {
|
||||
if v.is_finite() && v > 0.0 { v.min(Self::MAX_FORECAST) } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Chart {
|
||||
pub id: u32,
|
||||
@@ -508,9 +559,10 @@ pub struct Chart {
|
||||
pub struct Image {
|
||||
pub id: u32,
|
||||
pub anchor: Anchor,
|
||||
/// Encoded bytes (PNG/JPEG), base64 in JSON.
|
||||
#[serde(with = "crate::b64")]
|
||||
pub data: Vec<u8>,
|
||||
/// Encoded bytes (PNG/JPEG), base64 in JSON. Shared, so a copy of the sheet (an undo step)
|
||||
/// doesn't copy the picture.
|
||||
#[serde(with = "crate::b64::shared")]
|
||||
pub data: std::sync::Arc<Vec<u8>>,
|
||||
pub mime: String,
|
||||
pub alt: String,
|
||||
}
|
||||
@@ -867,7 +919,7 @@ pub fn password_hash(pw: &str) -> u64 {
|
||||
/// Outline grouping info for a row or column.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LineInfo {
|
||||
/// Size in points; `None` = default (rows auto-fit to content).
|
||||
/// Size in pixels at 96 dpi; `None` = default (rows auto-fit to content).
|
||||
pub size: Option<f32>,
|
||||
pub hidden: bool,
|
||||
pub outline: u8,
|
||||
@@ -1048,3 +1100,19 @@ impl Default for PivotTable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trendline_forecast_tests {
|
||||
use super::Trendline;
|
||||
|
||||
#[test]
|
||||
fn clamp_forecast_bounds_hostile_values() {
|
||||
assert_eq!(Trendline::clamp_forecast(2.5), 2.5);
|
||||
assert_eq!(Trendline::clamp_forecast(0.0), 0.0);
|
||||
assert_eq!(Trendline::clamp_forecast(-4.0), 0.0);
|
||||
assert_eq!(Trendline::clamp_forecast(f64::NAN), 0.0);
|
||||
assert_eq!(Trendline::clamp_forecast(f64::INFINITY), 0.0);
|
||||
assert_eq!(Trendline::clamp_forecast(f64::NEG_INFINITY), 0.0);
|
||||
assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
VAlign, VertAlign,
|
||||
};
|
||||
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, Workbook};
|
||||
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook};
|
||||
|
||||
/// Font used for new workbooks. Only a name: renderers substitute an available open font
|
||||
/// (e.g. the metric-compatible Carlito) when it isn't installed.
|
||||
|
||||
+156
-2
@@ -64,7 +64,7 @@ pub struct Sheet {
|
||||
pub spill_ranges: BTreeMap<CellRef, RangeRef>,
|
||||
pub cols: BTreeMap<u32, LineInfo>,
|
||||
pub rows: BTreeMap<u32, LineInfo>,
|
||||
/// Default column width and row height in points.
|
||||
/// Default column width and row height in pixels at 96 dpi (a file stores row heights in points).
|
||||
pub default_col_width: f32,
|
||||
pub default_row_height: f32,
|
||||
pub merges: Vec<RangeRef>,
|
||||
@@ -113,7 +113,7 @@ pub struct Sheet {
|
||||
pub preserved: Option<Arc<crate::preserved::Preserved>>,
|
||||
}
|
||||
|
||||
/// Default width of a column (points) and height of a row (points) for the default font.
|
||||
/// Default width of a column and height of a row (pixels at 96 dpi) for the default font.
|
||||
pub const DEFAULT_COL_WIDTH: f32 = 64.0;
|
||||
pub const DEFAULT_ROW_HEIGHT: f32 = 20.0;
|
||||
|
||||
@@ -170,6 +170,12 @@ impl Sheet {
|
||||
pub fn cell(&self, c: CellRef) -> Option<&Cell> {
|
||||
self.cells.get(c)
|
||||
}
|
||||
/// The source of a non-anchor dynamic-array spill cell, from the current calc state.
|
||||
/// Anchors, ordinary cells and cells outside successfully generated spills return `None`.
|
||||
pub fn spill_anchor(&self, c: CellRef) -> Option<CellRef> {
|
||||
self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor)
|
||||
}
|
||||
|
||||
/// Displayed value of a cell: its own value, or a spilled value.
|
||||
pub fn value(&self, c: CellRef) -> Value {
|
||||
match self.cells.get(c) {
|
||||
@@ -183,6 +189,39 @@ impl Sheet {
|
||||
_ => self.spill.get(&c),
|
||||
}
|
||||
}
|
||||
/// Last row (inclusive) of the maximal contiguous run of non-empty values in `col`, starting
|
||||
/// at `from_row` and capped at `cap` (inclusive). `from_row - 1` when `from_row` itself is
|
||||
/// empty (or `from_row > cap`), same as a cell-by-cell `value_ref` scan would find, but
|
||||
/// without calling `value_ref` once per row: `CellStore::contiguous_value_run` walks each
|
||||
/// band's row map directly for the common case (a run of stored cells), only falling back to
|
||||
/// a per-row `value_ref` check where the stored run stops short of `cap`. Used by the fill-handle
|
||||
/// double-click, where `col` can be densely populated for very long stretches.
|
||||
pub fn last_contiguous_nonempty(&self, col: u32, from_row: u32, cap: u32) -> u32 {
|
||||
if from_row > cap {
|
||||
return from_row.saturating_sub(1);
|
||||
}
|
||||
let mut row = from_row;
|
||||
loop {
|
||||
if row > cap {
|
||||
return cap;
|
||||
}
|
||||
let c = CellRef::new(row, col);
|
||||
match self.cells.contiguous_value_run(c, cap) {
|
||||
Some(last) => row = last + 1,
|
||||
None => {
|
||||
// The run stopped short of `row`: ask `value_ref` for this one row, which also
|
||||
// covers a stored cell with no value and no formula (a formatted spill child)
|
||||
// falling through to `spill`.
|
||||
let qualifies = self.value_ref(c).is_some_and(|v| !v.is_empty());
|
||||
if qualifies {
|
||||
row += 1;
|
||||
} else {
|
||||
return row.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn style_id(&self, c: CellRef) -> StyleId {
|
||||
if let Some(cell) = self.cells.get(c) {
|
||||
return cell.style;
|
||||
@@ -411,6 +450,7 @@ fn line_at(pos: f64, default: f64, custom: &BTreeMap<u32, LineInfo>, max: u32) -
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cell::Formula;
|
||||
|
||||
#[test]
|
||||
fn line_index_matches_linear() {
|
||||
@@ -427,6 +467,120 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference implementation: one `value_ref` call per row, exactly the loop
|
||||
/// `last_contiguous_nonempty` replaces (from `grid.rs`'s fill-handle double-click).
|
||||
fn last_contiguous_nonempty_naive(s: &Sheet, col: u32, from_row: u32, cap: u32) -> u32 {
|
||||
let mut row = from_row;
|
||||
while row <= cap && s.value_ref(CellRef::new(row, col)).is_some_and(|v| !v.is_empty()) {
|
||||
row += 1;
|
||||
}
|
||||
row.saturating_sub(1).max(from_row.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Deterministic xorshift32 so failures reproduce without pulling in a `rand` dependency
|
||||
/// just for this test.
|
||||
struct Xorshift32(u32);
|
||||
impl Xorshift32 {
|
||||
fn next(&mut self) -> u32 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 17;
|
||||
self.0 ^= self.0 << 5;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: u32) -> u32 {
|
||||
self.next() % n.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_contiguous_nonempty_matches_naive() {
|
||||
let mut rng = Xorshift32(0x9e3779b9);
|
||||
for trial in 0..300u32 {
|
||||
let mut s = Sheet::new("S");
|
||||
let rows = rng.below(400);
|
||||
// A mix of directly-stored values, formulas (value empty but formula present),
|
||||
// spill-only cells, blank-but-stored cells, and gaps, like a real sheet.
|
||||
for r in 0..rows {
|
||||
match rng.below(5) {
|
||||
0 => s.set_value(CellRef::new(r, 0), Value::Number(r as f64)),
|
||||
1 => s.set_cell(CellRef::new(r, 0), Cell { formula: Some(Arc::new(Formula::new("1"))), ..Default::default() }),
|
||||
2 => {
|
||||
s.spill.insert(CellRef::new(r, 0), Value::Number(r as f64));
|
||||
}
|
||||
3 => {
|
||||
// Stored but blank: does not count (matches `has_value`/`value_ref`).
|
||||
s.set_value(CellRef::new(r, 0), Value::Text("".into()));
|
||||
}
|
||||
4 if rng.below(2) == 0 => {
|
||||
// A style-only stored cell over a spill value: `value_ref` falls through
|
||||
// to the spill value, so the row counts.
|
||||
s.spill.insert(CellRef::new(r, 0), Value::Number(r as f64));
|
||||
s.set_cell(CellRef::new(r, 0), Cell { style: StyleId(1), ..Default::default() });
|
||||
}
|
||||
_ => {} // gap
|
||||
}
|
||||
}
|
||||
for _ in 0..5 {
|
||||
let from_row = rng.below(420);
|
||||
let cap = from_row.saturating_add(rng.below(450));
|
||||
let got = s.last_contiguous_nonempty(0, from_row, cap);
|
||||
let want = last_contiguous_nonempty_naive(&s, 0, from_row, cap);
|
||||
assert_eq!(got, want, "trial {trial}: col 0, from_row {from_row}, cap {cap}, rows up to {rows}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_contiguous_nonempty_dense_run() {
|
||||
let mut s = Sheet::new("S");
|
||||
for r in 0..2000u32 {
|
||||
s.set_value(CellRef::new(r, 2), Value::Number(r as f64));
|
||||
}
|
||||
assert_eq!(s.last_contiguous_nonempty(2, 0, 1_999), 1999);
|
||||
assert_eq!(s.last_contiguous_nonempty(2, 0, 1_000), 1000); // capped
|
||||
assert_eq!(s.last_contiguous_nonempty(2, 500, 1_999), 1999);
|
||||
assert_eq!(s.last_contiguous_nonempty(2, 2000, 3000), 1999); // nothing at from_row
|
||||
assert_eq!(s.last_contiguous_nonempty(2, 2001, 3000), 2000); // from_row > cap: from_row - 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_contiguous_nonempty_formula_with_empty_value_ends_the_run() {
|
||||
let mut s = Sheet::new("S");
|
||||
s.set_value(CellRef::new(0, 1), Value::Number(1.0));
|
||||
s.set_value(CellRef::new(1, 1), Value::Number(2.0));
|
||||
// row 2: a formula (e.g. `=""`) whose cached value is empty. It does not count, same as
|
||||
// `value_ref(...).is_some_and(|v| !v.is_empty())` says for it, even though it has a
|
||||
// formula (unlike `CellStore::has`, used for Ctrl+arrow navigation, which counts it).
|
||||
s.set_cell(CellRef::new(2, 1), Cell { formula: Some(Arc::new(Formula::new("\"\""))), ..Default::default() });
|
||||
s.set_value(CellRef::new(3, 1), Value::Number(4.0)); // would extend the run if row 2 didn't stop it
|
||||
assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 1);
|
||||
assert_eq!(s.last_contiguous_nonempty(1, 2, 100), 1); // from_row itself is the stop
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_contiguous_nonempty_spill_extends_a_style_only_stored_row() {
|
||||
let mut s = Sheet::new("S");
|
||||
s.set_value(CellRef::new(0, 1), Value::Number(1.0));
|
||||
// row 1: a formatted spill child: stored with a style only, value from `spill`.
|
||||
s.spill.insert(CellRef::new(1, 1), Value::Number(2.0));
|
||||
s.set_cell(CellRef::new(1, 1), Cell { style: StyleId(1), ..Default::default() });
|
||||
s.set_value(CellRef::new(2, 1), Value::Number(3.0));
|
||||
assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 2);
|
||||
assert_eq!(s.last_contiguous_nonempty(1, 1, 100), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_contiguous_nonempty_spill_extends_an_unstored_row() {
|
||||
let mut s = Sheet::new("S");
|
||||
s.set_value(CellRef::new(0, 1), Value::Number(1.0));
|
||||
// row 1: nothing stored, but a spill value from an array formula anchored elsewhere.
|
||||
s.spill.insert(CellRef::new(1, 1), Value::Number(2.0));
|
||||
s.set_value(CellRef::new(2, 1), Value::Number(3.0));
|
||||
// row 3: a genuine gap (nothing stored, no spill).
|
||||
assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 2);
|
||||
assert_eq!(s.last_contiguous_nonempty(1, 3, 100), 2); // from_row itself is the gap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry() {
|
||||
let mut s = Sheet::new("S");
|
||||
|
||||
+113
-1
@@ -13,6 +13,10 @@ use crate::cell::Cell;
|
||||
|
||||
const BAND: u32 = 64;
|
||||
|
||||
fn has_value(cell: &Cell) -> bool {
|
||||
!cell.value.is_empty() || cell.formula.is_some()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
struct Band {
|
||||
/// row → (col → cell)
|
||||
@@ -57,6 +61,28 @@ impl CellStore {
|
||||
self.len += 1;
|
||||
}
|
||||
}
|
||||
/// Puts every cell in the band its row says and recounts the length — serialized band keys
|
||||
/// and the cached count are derived state, and the ones a file carries are not trusted. Cells
|
||||
/// outside the sheet grid are dropped; returns how many (#1008).
|
||||
pub fn rebuild(&mut self) -> usize {
|
||||
let mut out = CellStore::new();
|
||||
let mut dropped = 0;
|
||||
for (_, band) in std::mem::take(&mut self.bands) {
|
||||
for (row, cols) in band.rows.iter() {
|
||||
for (col, cell) in cols.iter() {
|
||||
let at = CellRef::new(*row, *col);
|
||||
if at.is_valid() {
|
||||
out.set(at, cell.clone());
|
||||
} else {
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*self = out;
|
||||
dropped
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, c: CellRef) -> Option<Cell> {
|
||||
let key = c.row / BAND;
|
||||
let band = self.bands.get_mut(&key)?;
|
||||
@@ -118,7 +144,44 @@ impl CellStore {
|
||||
}
|
||||
/// Last stored row in column `col` at or above `below_or_at` (for Ctrl+arrow navigation).
|
||||
pub fn has(&self, c: CellRef) -> bool {
|
||||
self.get(c).is_some_and(|cell| !cell.value.is_empty() || cell.formula.is_some())
|
||||
self.get(c).is_some_and(has_value)
|
||||
}
|
||||
/// Last row (inclusive, capped at `cap`) of the maximal run of contiguous rows at or after
|
||||
/// `from.row` that store a cell in `from.col` with a non-empty *value* in `from.col`. `None`
|
||||
/// when `from` itself doesn't qualify.
|
||||
///
|
||||
/// Deliberately narrower than `has`/`get` (which also count a formula with no cached value
|
||||
/// yet, for Ctrl+arrow navigation): this matches `Sheet::value_ref`, whose callers test the
|
||||
/// `Value` it returns with `!v.is_empty()` regardless of whether a formula produced it — a
|
||||
/// formula cell with an empty cached value (`=""`, or not yet recalculated) is "empty" to
|
||||
/// them. A stored cell with no value and no formula (for example a formatted spill child) does
|
||||
/// fall through to `spill` in `value_ref`, so `last_contiguous_nonempty` asks `value_ref`
|
||||
/// about the row where this run stops.
|
||||
///
|
||||
/// Walks each band's row map with `BTreeMap::range` (one in-order pass per band) instead of
|
||||
/// one `get` per row, so a long, densely-populated run costs roughly one tree-traversal step
|
||||
/// per row rather than one binary search per row.
|
||||
pub fn contiguous_value_run(&self, from: CellRef, cap: u32) -> Option<u32> {
|
||||
let mut last = None;
|
||||
let mut expect = from.row;
|
||||
while expect <= cap {
|
||||
let band_key = expect / BAND;
|
||||
let Some(band) = self.bands.get(&band_key) else { break };
|
||||
let band_hi = ((band_key + 1) * BAND - 1).min(cap);
|
||||
let mut advanced = false;
|
||||
for (r, cols) in band.rows.range(expect..=band_hi) {
|
||||
if *r != expect || cols.get(&from.col).is_none_or(|c| c.value.is_empty()) {
|
||||
return last;
|
||||
}
|
||||
last = Some(expect);
|
||||
expect += 1;
|
||||
advanced = true;
|
||||
}
|
||||
if !advanced {
|
||||
break;
|
||||
}
|
||||
}
|
||||
last
|
||||
}
|
||||
/// Positions whose cells differ between `self` and `other` (stored on one side only, or not
|
||||
/// equal). Bands both stores share are skipped, so comparing an edited copy with the store
|
||||
@@ -154,6 +217,55 @@ impl CellStore {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Approximate heap storage kept alive by this snapshot's bands that `newer` no longer
|
||||
/// shares. This is for bounding Undo history, not for reporting process memory: BTreeMap
|
||||
/// nodes and allocator overhead vary by platform. Shared formulas and values are counted
|
||||
/// only when the newer band no longer holds the same Arc.
|
||||
pub fn retained_bytes_against(&self, newer: &CellStore) -> usize {
|
||||
use std::mem::size_of;
|
||||
|
||||
let mut bytes = 0usize;
|
||||
for (key, band) in &self.bands {
|
||||
let next = newer.bands.get(key);
|
||||
if next.is_some_and(|b| Arc::ptr_eq(b, band)) {
|
||||
continue;
|
||||
}
|
||||
bytes = bytes.saturating_add(size_of::<Band>());
|
||||
for (row, cols) in &band.rows {
|
||||
bytes = bytes.saturating_add(size_of::<u32>() + 3 * size_of::<usize>());
|
||||
for (col, cell) in cols {
|
||||
// Cells and BTreeMap nodes are duplicated with a cloned band even when
|
||||
// their formula/value Arcs are still shared with the next snapshot.
|
||||
bytes = bytes.saturating_add(size_of::<u32>() + size_of::<Cell>() + 3 * size_of::<usize>());
|
||||
let next_cell = next.and_then(|b| b.rows.get(row)).and_then(|r| r.get(col));
|
||||
if let Some(formula) = &cell.formula
|
||||
&& !next_cell.and_then(|c| c.formula.as_ref()).is_some_and(|f| Arc::ptr_eq(f, formula))
|
||||
{
|
||||
bytes = bytes.saturating_add(size_of::<crate::cell::Formula>() + formula.text.len());
|
||||
if let Some(expr) = &formula.expr {
|
||||
expr.walk(&mut |_| bytes = bytes.saturating_add(size_of::<gridcraft_formula::Expr>()));
|
||||
}
|
||||
}
|
||||
match &cell.value {
|
||||
gridcraft_core::Value::Text(text)
|
||||
if !next_cell.and_then(|c| c.value.as_text()).is_some_and(|t| std::ptr::eq(t, text.as_ref())) =>
|
||||
{
|
||||
bytes = bytes.saturating_add(text.len());
|
||||
}
|
||||
gridcraft_core::Value::Array(array)
|
||||
if !next_cell.is_some_and(|c| matches!(&c.value, gridcraft_core::Value::Array(a) if Arc::ptr_eq(a, array))) =>
|
||||
{
|
||||
bytes = bytes.saturating_add(
|
||||
size_of::<gridcraft_core::Array>() + array.data.len().saturating_mul(size_of::<gridcraft_core::Value>()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
/// Removes every cell in `r` and returns them.
|
||||
pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> {
|
||||
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
|
||||
|
||||
@@ -119,16 +119,42 @@ pub struct Theme {
|
||||
pub colors: [u32; 12],
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// The heading font's East Asian typeface (`<a:ea>`); empty when the file did not set one.
|
||||
#[serde(default)]
|
||||
pub major_font_ea: String,
|
||||
/// The body font's East Asian typeface (`<a:ea>`); empty when the file did not set one.
|
||||
#[serde(default)]
|
||||
pub minor_font_ea: String,
|
||||
/// The heading font's complex-script typeface (`<a:cs>`); empty when the file did not set one.
|
||||
#[serde(default)]
|
||||
pub major_font_cs: String,
|
||||
/// The body font's complex-script typeface (`<a:cs>`); empty when the file did not set one.
|
||||
#[serde(default)]
|
||||
pub minor_font_cs: String,
|
||||
/// The heading font's per-script typefaces (`<a:font script="Arab" typeface="…"/>`), as
|
||||
/// (script, typeface) in file order.
|
||||
#[serde(default)]
|
||||
pub major_font_scripts: Vec<(String, String)>,
|
||||
/// The body font's per-script typefaces, as (script, typeface) in file order.
|
||||
#[serde(default)]
|
||||
pub minor_font_scripts: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
/// GridCraft's own default theme ("Craft"): original palette.
|
||||
/// GridCraft's own default theme ("Craft"): original palette. The East Asian and
|
||||
/// complex-script typefaces stay empty: the file did not set one.
|
||||
fn default() -> Self {
|
||||
Theme {
|
||||
name: "Craft".into(),
|
||||
colors: [0xFFFFFF, 0x000000, 0xE8E8E8, 0x0E2841, 0x156082, 0xE97132, 0x196B24, 0x0F9ED5, 0xA02B93, 0x4EA72E, 0x467886, 0x96607D],
|
||||
major_font: crate::DEFAULT_FONT.into(),
|
||||
minor_font: crate::DEFAULT_FONT.into(),
|
||||
major_font_ea: String::new(),
|
||||
minor_font_ea: String::new(),
|
||||
major_font_cs: String::new(),
|
||||
minor_font_cs: String::new(),
|
||||
major_font_scripts: Vec::new(),
|
||||
minor_font_scripts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +187,12 @@ pub struct Font {
|
||||
pub strike: bool,
|
||||
pub color: Color,
|
||||
pub vert: VertAlign,
|
||||
/// Windows character set the font was chosen for (178 = Arabic); kept so Excel substitutes a font for the same script.
|
||||
#[serde(default)]
|
||||
pub charset: Option<u8>,
|
||||
/// Font family class (1 roman, 2 swiss, 3 modern…); `None` is swiss, the default.
|
||||
#[serde(default)]
|
||||
pub family: Option<u8>,
|
||||
}
|
||||
|
||||
impl Eq for Font {}
|
||||
@@ -168,7 +200,7 @@ impl Hash for Font {
|
||||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
self.name.hash(h);
|
||||
self.size.to_bits().hash(h);
|
||||
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert).hash(h);
|
||||
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +215,8 @@ impl Default for Font {
|
||||
strike: false,
|
||||
color: Color::Auto,
|
||||
vert: VertAlign::Baseline,
|
||||
charset: None,
|
||||
family: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +373,27 @@ pub struct Style {
|
||||
/// Number format code; "General" by default.
|
||||
pub num_fmt: NumFmt,
|
||||
pub protection: Protection,
|
||||
/// Differential formats only.
|
||||
#[serde(default)]
|
||||
pub dxf_stated: DxfStated,
|
||||
}
|
||||
|
||||
/// The parts a differential format states at a value its `Style` can't tell from unset: a dxf's
|
||||
/// default parts are unset, so a bold turned off, a face, a size or a protection that matches the
|
||||
/// defaults would otherwise read as nothing. The values are the style's own; this records that
|
||||
/// the format states them, and only where they match the defaults, so a format built in the app
|
||||
/// and the same format read back from a file stay equal.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct DxfStated {
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub strike: bool,
|
||||
pub underline: bool,
|
||||
pub size: bool,
|
||||
pub name: bool,
|
||||
/// `<scheme val>`: the theme font the face follows (`major`, `minor` or `none`).
|
||||
pub scheme: Option<String>,
|
||||
pub protection: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -435,6 +490,14 @@ mod tests {
|
||||
assert!(!t.get(StyleId(999)).font.bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_saved_before_per_script_fonts_still_opens() {
|
||||
let mut json = serde_json::to_value(Theme::default()).unwrap();
|
||||
let fields = json.as_object_mut().unwrap();
|
||||
assert!(fields.remove("major_font_scripts").is_some() && fields.remove("minor_font_scripts").is_some());
|
||||
assert_eq!(serde_json::from_value::<Theme>(json).unwrap(), Theme::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors() {
|
||||
let th = Theme::default();
|
||||
@@ -444,4 +507,27 @@ mod tests {
|
||||
assert_eq!(Color::Theme(1, 500).resolve(&th), Some([128, 128, 128]));
|
||||
assert_eq!(Color::Auto.resolve(&th), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_json_keeps_ea_and_cs_and_reads_old_json() {
|
||||
// JSON written before the ea/cs fields existed still deserializes (`serde(default)`).
|
||||
let mut old = serde_json::to_value(Theme::default()).unwrap();
|
||||
let obj = old.as_object_mut().unwrap();
|
||||
for key in ["major_font_ea", "minor_font_ea", "major_font_cs", "minor_font_cs"] {
|
||||
assert!(obj.remove(key).is_some(), "the default theme serializes {key}");
|
||||
}
|
||||
assert_eq!(serde_json::from_value::<Theme>(old).unwrap(), Theme::default());
|
||||
|
||||
// Set fields serialize and come back.
|
||||
let t = Theme {
|
||||
major_font_ea: "游ゴシック".into(),
|
||||
minor_font_ea: "MS Pゴシック".into(),
|
||||
major_font_cs: "Arial".into(),
|
||||
minor_font_cs: "Times New Roman".into(),
|
||||
..Theme::default()
|
||||
};
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
assert!(json.contains("major_font_ea") && json.contains("游ゴシック"), "{json}");
|
||||
assert_eq!(serde_json::from_str::<Theme>(&json).unwrap(), t);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,17 @@ pub enum CalcMode {
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// Options › Advanced › "For objects, show" (`<workbookPr showObjects>`, ECMA-376 `ST_Objects`):
|
||||
/// whether charts, pictures, shapes and slicers are drawn. Ctrl+6 switches between `All` and
|
||||
/// `None`; `Placeholders` only comes from files and is kept as it is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ShowObjects {
|
||||
#[default]
|
||||
All,
|
||||
Placeholders,
|
||||
None,
|
||||
}
|
||||
|
||||
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalcSettings {
|
||||
@@ -85,6 +96,47 @@ pub struct DocProps {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// One phonetic (furigana) run of a string item (`<rPh>`): the reading `text` and the range of the
|
||||
/// base text it annotates.
|
||||
///
|
||||
/// `start` and `end` are zero-based offsets into the base text (`sb` and `eb` in the file) with
|
||||
/// `start < end`; reading keeps them within the text's length in UTF-16 code units. Behaviour
|
||||
/// reference: ISO/IEC 29500-1 §18.4.6 `rPh` and the Microsoft Learn `PhoneticRun` class.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PhoneticRun {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The `<phoneticPr>` of a string item: how its phonetic runs are displayed. `kind` (`type`) and
|
||||
/// `alignment` only ever hold values ISO/IEC 29500-1 §18.18.57 `ST_PhoneticType` / §18.18.56
|
||||
/// `ST_PhoneticAlignment` define, so damaged files cannot be written back invalid.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PhoneticProps {
|
||||
/// `fontId`: a required attribute, an index into the style sheet's fonts. A file without one
|
||||
/// gets 0 — the Normal style's font, which an out-of-range index falls back to anyway.
|
||||
#[serde(default)]
|
||||
pub font_id: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alignment: Option<String>,
|
||||
}
|
||||
|
||||
/// The phonetic (furigana) data of one shared string: its `<rPh>` runs, in document order, and its
|
||||
/// `<phoneticPr>` when the file had one.
|
||||
///
|
||||
/// `text` is the string's base text — the only handle a cell keeps on its shared string — and is
|
||||
/// the key the writer uses to re-emit the runs next to the right `<si>`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PhoneticString {
|
||||
pub text: String,
|
||||
pub runs: Vec<PhoneticRun>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub props: Option<PhoneticProps>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Workbook {
|
||||
pub sheets: Vec<Arc<Sheet>>,
|
||||
@@ -93,6 +145,8 @@ pub struct Workbook {
|
||||
pub theme: Theme,
|
||||
pub date_system: DateSystem,
|
||||
pub calc: CalcSettings,
|
||||
#[serde(default, skip_serializing_if = "is_all")]
|
||||
pub show_objects: ShowObjects,
|
||||
pub active_sheet: usize,
|
||||
pub props: DocProps,
|
||||
/// Workbook structure protection.
|
||||
@@ -104,11 +158,23 @@ pub struct Workbook {
|
||||
pub custom_lists: Vec<Vec<String>>,
|
||||
/// Named cell styles (Cell Styles gallery): name → style.
|
||||
pub cell_styles: Vec<(String, crate::style::Style)>,
|
||||
/// Phonetic (furigana) data of shared strings (`xl/sharedStrings.xml`), one entry per string
|
||||
/// text, so that an open + save keeps a Japanese workbook's readings. Written back next to the
|
||||
/// `<si>` holding the same text.
|
||||
///
|
||||
/// Keyed by text because that is all a cell keeps of its shared string: the saved table holds
|
||||
/// each text once (the format's `uniqueCount`), so two `<si>` items that share a base text can
|
||||
/// only keep one reading — the reader keeps the first and warns. Empty for workbooks without
|
||||
/// phonetic data; not present in older JSON. Modelled data (unlike [`Workbook::preserved`],
|
||||
/// which holds unmodelled parts), so it is saved to JSON as well as to XLSX.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub phonetics: Vec<PhoneticString>,
|
||||
/// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run
|
||||
/// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the
|
||||
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros.
|
||||
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros. Shared, so a
|
||||
/// copy of the workbook (every edit's undo step) doesn't copy it.
|
||||
#[serde(default, with = "crate::b64::opt")]
|
||||
pub vba_project: Option<Vec<u8>>,
|
||||
pub vba_project: Option<Arc<Vec<u8>>>,
|
||||
/// Field separator of the CSV this workbook was read from, so saving it as CSV writes the same
|
||||
/// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -129,6 +195,17 @@ pub struct Workbook {
|
||||
/// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub source_dir: Option<std::path::PathBuf>,
|
||||
/// The file this workbook reports as its own, which is what `CELL("filename")` returns.
|
||||
/// `None` = no file yet, which Excel reports as an empty string until the workbook has been
|
||||
/// saved once. Deliberately separate from `source_dir`: that one names the folder relative
|
||||
/// links are relative to and must keep naming it after a Save As into another folder, since
|
||||
/// the links in memory are still written for the old one. Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub source_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
fn is_all(s: &ShowObjects) -> bool {
|
||||
*s == ShowObjects::All
|
||||
}
|
||||
|
||||
impl Default for Workbook {
|
||||
@@ -147,18 +224,21 @@ impl Workbook {
|
||||
theme: Theme::default(),
|
||||
date_system: DateSystem::D1900,
|
||||
calc: CalcSettings::default(),
|
||||
show_objects: ShowObjects::All,
|
||||
active_sheet: 0,
|
||||
props: DocProps::default(),
|
||||
protected_structure: false,
|
||||
structure_password: None,
|
||||
custom_lists: vec![],
|
||||
cell_styles: vec![],
|
||||
phonetics: vec![],
|
||||
vba_project: None,
|
||||
csv_delimiter: None,
|
||||
macros: vec![],
|
||||
macros_read: vec![],
|
||||
preserved: None,
|
||||
source_dir: None,
|
||||
source_path: None,
|
||||
}
|
||||
}
|
||||
pub fn sheet(&self, i: usize) -> Option<&Sheet> {
|
||||
@@ -230,7 +310,7 @@ impl Workbook {
|
||||
let f = cell.formula.as_ref()?;
|
||||
let e = f.expr()?;
|
||||
let ne = spell_calls(e.clone(), &lambdas);
|
||||
(ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) }))
|
||||
(ne != e).then(|| (c, Formula { array: f.array, dynamic: f.dynamic, ..Formula::from_expr(ne) }))
|
||||
})
|
||||
.collect();
|
||||
if fixed.is_empty() {
|
||||
@@ -244,7 +324,7 @@ impl Workbook {
|
||||
}
|
||||
}
|
||||
for n in self.names.iter_mut() {
|
||||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||||
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
|
||||
let ne = spell_calls(e.clone(), &lambdas);
|
||||
if ne != e {
|
||||
n.formula = gridcraft_formula::print(&ne);
|
||||
@@ -307,6 +387,23 @@ fn spell_calls(e: Expr, lambdas: &[String]) -> Expr {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn phonetics_are_additive_in_json() {
|
||||
let mut wb = Workbook::new();
|
||||
assert!(wb.phonetics.is_empty(), "new workbooks have no phonetic data");
|
||||
wb.phonetics.push(PhoneticString {
|
||||
text: "東".into(),
|
||||
runs: vec![PhoneticRun { start: 0, end: 1, text: "ヒガシ".into() }],
|
||||
props: Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }),
|
||||
});
|
||||
let json = serde_json::to_string(&wb).expect("serialize");
|
||||
assert_eq!(serde_json::from_str::<Workbook>(&json).expect("deserialize").phonetics, wb.phonetics);
|
||||
// JSON written before the field existed still loads.
|
||||
let mut value: serde_json::Value = serde_json::from_str(&json).expect("value");
|
||||
value.as_object_mut().expect("an object").remove("phonetics");
|
||||
assert!(serde_json::from_value::<Workbook>(value).expect("older JSON").phonetics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_names() {
|
||||
let wb = Workbook::new();
|
||||
|
||||
@@ -38,6 +38,24 @@ pub enum FormatColor {
|
||||
Indexed(u8),
|
||||
}
|
||||
|
||||
impl FormatColor {
|
||||
/// RGB value used to render this number-format color.
|
||||
pub fn rgb(self) -> Option<[u8; 3]> {
|
||||
use FormatColor as F;
|
||||
Some(match self {
|
||||
F::Black => [0, 0, 0],
|
||||
F::Blue => [0, 0, 255],
|
||||
F::Cyan => [0, 255, 255],
|
||||
F::Green => [0, 255, 0],
|
||||
F::Magenta => [255, 0, 255],
|
||||
F::Red => [255, 0, 0],
|
||||
F::White => [255, 255, 255],
|
||||
F::Yellow => [255, 255, 0],
|
||||
F::Indexed(index) => return gridcraft_core::color::indexed_color(index),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Best guess at the ribbon's number-format category.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum FormatKind {
|
||||
|
||||
@@ -466,20 +466,26 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
||||
let has_whole = !whole_idx.is_empty();
|
||||
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
|
||||
let (mut num, den) = match fixed_den {
|
||||
Some(d) => ((f * d as f64).round() as u64, d),
|
||||
// Without a whole part the numerator carries the whole value and can pass u64::MAX, so it
|
||||
// stays an f64 (a float-to-u64 cast saturates).
|
||||
let (mut num, den): (f64, u64) = match fixed_den {
|
||||
Some(d) => ((f * d as f64).round(), d),
|
||||
None if f >= 1e15 => (f.round(), 1),
|
||||
None => {
|
||||
let digits = den_idx.len().clamp(1, 7) as u32;
|
||||
approximate(f, 10u64.pow(digits) - 1)
|
||||
let (n, d) = approximate(f, 10u64.pow(digits) - 1);
|
||||
(n as f64, d)
|
||||
}
|
||||
};
|
||||
if has_whole && num >= den && den > 0 {
|
||||
whole += (num / den) as f64;
|
||||
num %= den;
|
||||
if has_whole && num >= den as f64 && den > 0 {
|
||||
// The fractional part is below 1, so the numerator is at most the denominator here.
|
||||
let n = num as u64;
|
||||
whole += (n / den) as f64;
|
||||
num = (n % den) as f64;
|
||||
}
|
||||
let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() };
|
||||
let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0')));
|
||||
if has_whole && num == 0 && !num_zero_ph {
|
||||
if has_whole && num == 0.0 && !num_zero_ph {
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces);
|
||||
let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx);
|
||||
blank_range(toks, sep_start..den_end, spaces, pieces, lits);
|
||||
@@ -488,7 +494,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
if has_whole {
|
||||
// A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one.
|
||||
let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#')));
|
||||
let force = whole_s.is_empty() && num == 0 && !units_hash;
|
||||
let force = whole_s.is_empty() && num == 0.0 && !units_hash;
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces);
|
||||
let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit())));
|
||||
if !whole_shown {
|
||||
@@ -496,7 +502,8 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
blank_range(toks, sep_start..num_start, spaces, pieces, lits);
|
||||
}
|
||||
}
|
||||
fill_int(toks, &num_idx, &num.to_string(), false, true, pieces);
|
||||
let num_s = if num < 1e15 { (num as u64).to_string() } else { fixed(num, 0).0 };
|
||||
fill_int(toks, &num_idx, &num_s, false, true, pieces);
|
||||
if let Some(p) = pieces.get_mut(slash) {
|
||||
*p = "/".into();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_colors_resolve_named_and_indexed_rgb_safely() {
|
||||
assert_eq!(FormatColor::Green.rgb(), Some([0, 255, 0]));
|
||||
assert_eq!(FormatColor::Black.rgb(), Some([0, 0, 0]));
|
||||
assert_eq!(FormatColor::Indexed(1).rgb(), Some([0, 0, 0]));
|
||||
assert_eq!(FormatColor::Indexed(10).rgb(), Some([0, 128, 0]));
|
||||
assert_eq!(FormatColor::Indexed(46).rgb(), Some([255, 102, 0]));
|
||||
assert_eq!(FormatColor::Indexed(56).rgb(), Some([51, 51, 51]));
|
||||
for index in [0, 57, 255] {
|
||||
assert_eq!(FormatColor::Indexed(index).rgb(), None);
|
||||
}
|
||||
}
|
||||
|
||||
const S: DateSystem = DateSystem::D1900;
|
||||
|
||||
fn f(code: &str, n: f64) -> String {
|
||||
@@ -145,6 +158,18 @@ fn fractions() {
|
||||
assert_eq!(f("# ???/???", 0.5), " 1/2 ");
|
||||
}
|
||||
|
||||
/// A fraction without a whole part puts the whole value in the numerator; large values must
|
||||
/// not saturate at u64::MAX.
|
||||
#[test]
|
||||
fn fraction_numerator_beyond_u64() {
|
||||
assert_eq!(f("?/1000000000", 2e10), "20000000000000000000/1000000000");
|
||||
assert_eq!(f("?/?", 1e20), "100000000000000000000/1");
|
||||
assert_eq!(f("?/100", 1e18), "100000000000000000000/100");
|
||||
assert_eq!(f("??/??", 1e25), "10000000000000000000000000/1 ");
|
||||
assert!(!f("?/10", 1e300).contains("18446744073709551615"));
|
||||
assert_eq!(f("?/1000000000", 2.5), "2500000000/1000000000");
|
||||
}
|
||||
|
||||
/// Fraction sections with literal text, as Excel renders them (observed `TEXT()` results).
|
||||
#[test]
|
||||
fn fractions_with_literals() {
|
||||
|
||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user