mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 14:17:12 -06:00
Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67e39faeea | ||
|
|
59fd17ae56 | ||
|
|
8069ad33ef | ||
|
|
82a78a9d21 | ||
|
|
ac27e3da14 | ||
|
|
79fd7f456e | ||
|
|
671e194bb9 | ||
|
|
981fad14e0 | ||
|
|
7fcf4232b7 | ||
|
|
79722c7666 | ||
|
|
2329904024 | ||
|
|
5f7137ca01 | ||
|
|
55850841d3 | ||
|
|
1f73f62c4f | ||
|
|
fb1e59ff61 | ||
|
|
8f59487cbe | ||
|
|
71d4e2311d | ||
|
|
213fe1150e | ||
|
|
0db7d97f2e | ||
|
|
4d1233d0a2 | ||
|
|
07c8201786 | ||
|
|
31d5cc5b41 | ||
|
|
c0fcc7de16 | ||
|
|
5390e230d8 | ||
|
|
c9eb797d8b | ||
|
|
5441691ca3 | ||
|
|
d3f54debd9 | ||
|
|
64ea0cb69c | ||
|
|
f887ecf1cf | ||
|
|
ae598a74fe | ||
|
|
bc0fffbe97 | ||
|
|
1ebef5a694 | ||
|
|
f812c53814 | ||
|
|
b775b3ce32 | ||
|
|
76aed738e3 | ||
|
|
a44d3d5ee5 | ||
|
|
2bf5dbac84 | ||
|
|
c269ff483d | ||
|
|
15f6297001 | ||
|
|
006b7962bd | ||
|
|
7ea3a4c2c3 | ||
|
|
cae0c9abdb | ||
|
|
ed81d7e34e | ||
|
|
8f7dff0d04 | ||
|
|
7b7e734524 | ||
|
|
6d5ab57f84 | ||
|
|
a04629576d | ||
|
|
3174020a70 | ||
|
|
781149a593 | ||
|
|
322298b202 | ||
|
|
11d6ff1b79 | ||
|
|
eefa7c4a54 | ||
|
|
45939a8a41 | ||
|
|
70da8bb3ae | ||
|
|
a0da53f576 | ||
|
|
ca44a5d68f | ||
|
|
3b61d660e9 | ||
|
|
cca8a58bfa | ||
|
|
d3cd393dba | ||
|
|
8985685833 | ||
|
|
962a5df841 | ||
|
|
fea4c03ca2 | ||
|
|
1d9096e5ec | ||
|
|
8e740d97e1 | ||
|
|
a9bacdca4c | ||
|
|
53d6de3c16 | ||
|
|
6aa2cb8223 | ||
|
|
83e6408b71 | ||
|
|
1b1224c67a | ||
|
|
213b76af10 | ||
|
|
4e5c86d842 | ||
|
|
ad69d4cbdd | ||
|
|
32dbf6a224 | ||
|
|
11b54ebe40 | ||
|
|
9e710199b1 | ||
|
|
5ee65a8005 | ||
|
|
48c6b57060 | ||
|
|
ec2efe3246 | ||
|
|
5ed6a1b9c7 | ||
|
|
5d89b9efdf | ||
|
|
f1ff38504a | ||
|
|
0435ff50f9 | ||
|
|
550085f8b8 | ||
|
|
3a27743a38 | ||
|
|
dc691d52db | ||
|
|
bd8cf4e14a | ||
|
|
dc75555ee9 | ||
|
|
4d725e6bc1 | ||
|
|
9fccec28af | ||
|
|
52d317c8ff | ||
|
|
00359ee93a | ||
|
|
c895acf1f7 | ||
|
|
1f4c67ef0a | ||
|
|
8cf8425745 | ||
|
|
59a8b14d4d | ||
|
|
8f93ba2e9c | ||
|
|
3de6cb1a3d | ||
|
|
75d4de230b | ||
|
|
fbff1e301c | ||
|
|
5c31d7a17e | ||
|
|
22b6dfe8d7 | ||
|
|
b612141726 | ||
|
|
f6015a7e6e | ||
|
|
25816f0009 | ||
|
|
66f8b05fc2 | ||
|
|
c72f5a49b0 | ||
|
|
5ed5c09bc6 | ||
|
|
a75b19e678 | ||
|
|
551ec6590d | ||
|
|
9cc126409b |
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
||||||
|
|
||||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
|
||||||
|
|
||||||
## Start every session here
|
## Start every session here
|
||||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
||||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||||
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
|||||||
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
|
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
|
||||||
|
|
||||||
## Never crash
|
## Never crash
|
||||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
|
||||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
||||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||||
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
|||||||
|
|
||||||
## Non-negotiables
|
## Non-negotiables
|
||||||
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
||||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
|
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
|
||||||
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
|
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
|
||||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
|
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
|
||||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||||
@@ -51,7 +49,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
|||||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||||
|
|
||||||
## Contributor credits (About window)
|
## Contributor credits (About window)
|
||||||
|
|
||||||
@@ -62,14 +60,11 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
|||||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||||
(copy the template at the top of the file, or run
|
(copy the template at the top of the file)
|
||||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
|
||||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||||
`@username` anyway.
|
`@username` anyway.
|
||||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||||
GitHub profiles). Never hand-edit `contributors.json`.
|
GitHub profiles). Never hand-edit `contributors.json`.
|
||||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
|
||||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
|
||||||
|
|
||||||
## CI workflows
|
## CI workflows
|
||||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||||
|
|||||||
+9
-9
@@ -12,15 +12,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
|||||||
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||||
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||||
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
|
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
|
||||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
|
| `docs/brand/LICENSE-brand.txt` | (licence text) | project | — | |
|
||||||
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
|
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
|
||||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
|
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
|
||||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
||||||
|
|
||||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
|
||||||
|
|
||||||
## Start every session here
|
## Start every session here
|
||||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
||||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||||
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
|||||||
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
|
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
|
||||||
|
|
||||||
## Never crash
|
## Never crash
|
||||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
|
||||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
||||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||||
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
|||||||
|
|
||||||
## Non-negotiables
|
## Non-negotiables
|
||||||
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
||||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
|
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
|
||||||
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
|
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
|
||||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
|
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
|
||||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||||
@@ -50,7 +48,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
|||||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||||
|
|
||||||
## CI workflows
|
## CI workflows
|
||||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||||
|
|||||||
Generated
+31
-30
@@ -220,7 +220,7 @@ dependencies = [
|
|||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
@@ -889,12 +889,12 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-fonts-build"
|
name = "craft-fonts-build"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-i18n"
|
name = "craft-i18n"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fixed_decimal",
|
"fixed_decimal",
|
||||||
"icu_decimal",
|
"icu_decimal",
|
||||||
@@ -917,9 +917,10 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-print"
|
name = "craft-print"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-winprint-gdi",
|
"craft-winprint-gdi",
|
||||||
|
"log",
|
||||||
"miniz_oxide 0.9.1",
|
"miniz_oxide 0.9.1",
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -930,7 +931,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-ui"
|
name = "craft-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-fonts-build",
|
"craft-fonts-build",
|
||||||
"craft-i18n",
|
"craft-i18n",
|
||||||
@@ -950,7 +951,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-update"
|
name = "craft-update"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -976,7 +977,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-window"
|
name = "craft-window"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"egui",
|
"egui",
|
||||||
"objc2 0.6.5",
|
"objc2 0.6.5",
|
||||||
@@ -1456,7 +1457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1936,7 +1937,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft"
|
name = "gridcraft"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1963,7 +1964,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-calc"
|
name = "gridcraft-calc"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-core",
|
"gridcraft-core",
|
||||||
"gridcraft-formula",
|
"gridcraft-formula",
|
||||||
@@ -1975,7 +1976,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-chart"
|
name = "gridcraft-chart"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-calc",
|
"gridcraft-calc",
|
||||||
"gridcraft-core",
|
"gridcraft-core",
|
||||||
@@ -1986,7 +1987,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-cli"
|
name = "gridcraft-cli"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-engine",
|
"gridcraft-engine",
|
||||||
"gridcraft-mcp",
|
"gridcraft-mcp",
|
||||||
@@ -1998,7 +1999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-core"
|
name = "gridcraft-core"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -2009,7 +2010,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-engine"
|
name = "gridcraft-engine"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-calc",
|
"gridcraft-calc",
|
||||||
"gridcraft-chart",
|
"gridcraft-chart",
|
||||||
@@ -2031,7 +2032,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-formula"
|
name = "gridcraft-formula"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-core",
|
"gridcraft-core",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2040,7 +2041,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-functions"
|
name = "gridcraft-functions"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-core",
|
"gridcraft-core",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -2048,7 +2049,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-mcp"
|
name = "gridcraft-mcp"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-engine",
|
"gridcraft-engine",
|
||||||
"log",
|
"log",
|
||||||
@@ -2057,7 +2058,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-model"
|
name = "gridcraft-model"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-core",
|
"gridcraft-core",
|
||||||
"gridcraft-formula",
|
"gridcraft-formula",
|
||||||
@@ -2069,18 +2070,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-numfmt"
|
name = "gridcraft-numfmt"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-core",
|
"gridcraft-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-pdf"
|
name = "gridcraft-pdf"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-ui-egui"
|
name = "gridcraft-ui-egui"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-ui",
|
"craft-ui",
|
||||||
"craft-update",
|
"craft-update",
|
||||||
@@ -2105,7 +2106,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-web"
|
name = "gridcraft-web"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
@@ -2122,7 +2123,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridcraft-xlsx"
|
name = "gridcraft-xlsx"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"gridcraft-core",
|
"gridcraft-core",
|
||||||
@@ -3461,7 +3462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3875,7 +3876,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4185,7 +4186,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4244,7 +4245,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4978,7 +4979,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5937,7 +5938,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6403,7 +6404,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xtask"
|
name = "xtask"
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gridcraft-engine",
|
"gridcraft-engine",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.13.0"
|
version = "0.14.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
@@ -27,7 +27,7 @@ gridcraft-pdf = { path = "crates/pdf" }
|
|||||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||||
# the app) comes from one storytold/craft-libs revision.
|
# the app) comes from one storytold/craft-libs revision.
|
||||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", version = "=0.1.0", features = ["egui"] }
|
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] }
|
||||||
|
|
||||||
# foundation
|
# foundation
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -63,7 +63,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
|||||||
epaint_default_fonts = "0.37"
|
epaint_default_fonts = "0.37"
|
||||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||||
# driver. Same craft-libs revision as craft-update above.
|
# driver. Same craft-libs revision as craft-update above.
|
||||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
|
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|||||||
+2
-2
@@ -233,7 +233,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||||||
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
|
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
|
||||||
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
|
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
|
||||||
| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) |
|
| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) |
|
||||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||||
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
|
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
|
||||||
@@ -296,5 +296,5 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
|
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
|
||||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
|
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
|
||||||
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
|
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
|
||||||
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` |
|
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; progress docs restructured; detail moved to `docs/` |
|
||||||
| 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity |
|
| 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity |
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
|
|||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||||
# Cargo.toml).
|
# Cargo.toml).
|
||||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||||
craft-update = { workspace = true }
|
craft-update = { workspace = true }
|
||||||
rfd = { workspace = true }
|
rfd = { workspace = true }
|
||||||
arboard = { workspace = true }
|
arboard = { workspace = true }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# GridCraft app icon (PLACEHOLDER)
|
# GridCraft app icon (PLACEHOLDER)
|
||||||
|
|
||||||
**This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the
|
**This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the
|
||||||
product owner makes (craftrules `standards/icon-design.md`); GridCraft's hasn't been made yet.
|
product owner makes; GridCraft's hasn't been made yet.
|
||||||
Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number
|
Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number
|
||||||
column, a selected cell with its fill handle and a double rule under a total, on a full-bleed
|
column, a selected cell with its fill handle and a double rule under a total, on a full-bleed
|
||||||
GridCraft-green tile. It is original geometry written by hand for this repository, deliberately
|
GridCraft-green tile. It is original geometry written by hand for this repository, deliberately
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
||||||
Original work; temporary until the owner's engraved creature portrait replaces it
|
Original work; temporary until the owner's engraved creature portrait replaces it
|
||||||
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
|
Palette: ink #0b0b0c, paper #efe9dc,
|
||||||
app colour #1f9d55 (proposed). -->
|
app colour #1f9d55 (proposed). -->
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,7 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
||||||
Original work; temporary until the owner's engraved creature portrait replaces it
|
Original work; temporary until the owner's engraved creature portrait replaces it
|
||||||
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
|
Palette: ink #0b0b0c, paper #efe9dc,
|
||||||
app colour #1f9d55 (proposed). -->
|
app colour #1f9d55 (proposed). -->
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,13 +1,10 @@
|
|||||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
# Contributor names for the About window.
|
||||||
#
|
#
|
||||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||||
#
|
#
|
||||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
# Copy this block, replacing your-github-username (every field is optional):
|
||||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
|
||||||
#
|
|
||||||
# Or copy this block, replacing your-github-username (every field is optional):
|
|
||||||
#
|
#
|
||||||
# [people.your-github-username]
|
# [people.your-github-username]
|
||||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
|
|||||||
use super::objects::draw_cell_picture;
|
use super::objects::draw_cell_picture;
|
||||||
use super::text::{TextPlacement, draw_text};
|
use super::text::{TextPlacement, draw_text};
|
||||||
use super::{
|
use super::{
|
||||||
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
|
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
|
||||||
split_sheet,
|
Workbook, split_sheet,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------- drawing
|
// ---------------------------------------------------------------- drawing
|
||||||
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
|||||||
|| merges.iter().any(|m| m.start == *c)
|
|| merges.iter().any(|m| m.start == *c)
|
||||||
|| !sh.value(*c).is_empty()
|
|| !sh.value(*c).is_empty()
|
||||||
|| l.style.borders != Borders::default()
|
|| l.style.borders != Borders::default()
|
||||||
|
|| l.style.num_fmt.as_str() == "checkbox"
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
|
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
|
||||||
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
|||||||
}
|
}
|
||||||
if let Some(picture) = sh.cell_pictures.get(c) {
|
if let Some(picture) = sh.cell_pictures.get(c) {
|
||||||
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
|
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
|
||||||
|
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
|
||||||
|
draw_checkbox(pg, (x, y, w, h), b.k, on);
|
||||||
} else {
|
} else {
|
||||||
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
|
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
|
||||||
}
|
}
|
||||||
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
|||||||
pg.restore();
|
pg.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
|
||||||
|
/// print as text.
|
||||||
|
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
|
||||||
|
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match v {
|
||||||
|
Value::Bool(on) => Some(*on),
|
||||||
|
Value::Empty => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
|
||||||
|
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
|
||||||
|
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
|
||||||
|
if s <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
|
||||||
|
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
|
||||||
|
if on {
|
||||||
|
let green = [0x10, 0x7C, 0x41];
|
||||||
|
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
|
||||||
|
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
|
||||||
|
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
|
||||||
|
} else {
|
||||||
|
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct PendingPdfLink {
|
pub(super) struct PendingPdfLink {
|
||||||
pub(super) page: usize,
|
pub(super) page: usize,
|
||||||
pub(super) sheet: usize,
|
pub(super) sheet: usize,
|
||||||
|
|||||||
@@ -565,3 +565,19 @@ fn rotated_wrapped_text_prints_rotated() {
|
|||||||
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
|
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
|
||||||
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
|
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkbox_cells_print_as_boxes_not_true_false() {
|
||||||
|
// #2972: a checkbox cell printed its value as TRUE or FALSE.
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
|
||||||
|
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
|
||||||
|
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||||
|
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
|
||||||
|
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
|
||||||
|
let text = String::from_utf8_lossy(&pdf);
|
||||||
|
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
|
||||||
|
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
|
||||||
|
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
|
|||||||
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
|
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
|
||||||
}
|
}
|
||||||
(Some(c), None) if s.specifiers.is_empty() => {
|
(Some(c), None) if s.specifiers.is_empty() => {
|
||||||
// Excel writes a name with spaces at its edges in the double-bracket form.
|
// Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form.
|
||||||
if c.trim() == c {
|
if c.trim() == c && !c.starts_with('@') {
|
||||||
let _ = write!(out, "[{}]", esc(c));
|
let _ = write!(out, "[{}]", esc(c));
|
||||||
} else {
|
} else {
|
||||||
let _ = write!(out, "[[{}]]", esc(c));
|
let _ = write!(out, "[[{}]]", esc(c));
|
||||||
|
|||||||
@@ -558,3 +558,12 @@ fn reference_shapes_print_unchanged() -> Result<(), Box<dyn std::error::Error>>
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn column_starting_with_at_round_trips() {
|
||||||
|
for name in ["@a", "a@b"] {
|
||||||
|
let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None };
|
||||||
|
let text = format!("={}", printer::struct_ref(&s));
|
||||||
|
assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -449,13 +449,15 @@ pub(crate) fn yearfrac_core(sys: DateSystem, s: i64, e: i64, basis: i64) -> R<f6
|
|||||||
Ok(match basis {
|
Ok(match basis {
|
||||||
0 => {
|
0 => {
|
||||||
let (mut a, mut b) = (d1.day as i64, d2.day as i64);
|
let (mut a, mut b) = (d1.day as i64, d2.day as i64);
|
||||||
|
// Excel tests the end-31 rule against the original start day, before a February end moves to 30.
|
||||||
|
let start_30 = a >= 30;
|
||||||
if last_of_feb(&d1) && last_of_feb(&d2) {
|
if last_of_feb(&d1) && last_of_feb(&d2) {
|
||||||
b = 30;
|
b = 30;
|
||||||
}
|
}
|
||||||
if last_of_feb(&d1) || a == 31 {
|
if last_of_feb(&d1) || a == 31 {
|
||||||
a = 30;
|
a = 30;
|
||||||
}
|
}
|
||||||
if a == 30 && b == 31 {
|
if start_30 && b == 31 {
|
||||||
b = 30;
|
b = 30;
|
||||||
}
|
}
|
||||||
((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0
|
((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0
|
||||||
@@ -817,8 +819,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn yearfrac_basis0_feb_end_to_31() {
|
fn yearfrac_basis0_feb_end_to_31() {
|
||||||
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
|
// Excel 16: a February-end start keeps its original day for the end-31 test, so 31 days.
|
||||||
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
|
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11);
|
||||||
|
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11);
|
||||||
|
close(ev("YEARFRAC", vec![n(d(2021.0, 3.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.086_111_111_111_111_11);
|
||||||
|
close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.077_777_777_777_777_78);
|
||||||
|
close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 30.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 1.0 / 6.0);
|
||||||
|
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 1.0);
|
||||||
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.0);
|
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R<String> {
|
|||||||
Ok(number_to_text(n))
|
Ok(number_to_text(n))
|
||||||
}
|
}
|
||||||
Value::Empty => Ok(String::new()),
|
Value::Empty => Ok(String::new()),
|
||||||
v => Ok(v.to_text()?.trim().to_string()),
|
v => Ok(v.to_text()?.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
|
|||||||
if v < min || v > max {
|
if v < min || v > max {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
|
let places = places
|
||||||
|
.map(|p| {
|
||||||
|
let p = p.trunc();
|
||||||
|
if !(1.0..=10.0).contains(&p) {
|
||||||
|
return Err(CellError::Num);
|
||||||
|
}
|
||||||
|
Ok(p as usize)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
let u = v + (1i64 << bits);
|
let u = v + (1i64 << bits);
|
||||||
return text_val(to_radix(u, radix));
|
return text_val(to_radix(u, radix));
|
||||||
@@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
|
|||||||
match places {
|
match places {
|
||||||
None => text_val(s),
|
None => text_val(s),
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
let p = p.trunc();
|
|
||||||
if !(1.0..=10.0).contains(&p) {
|
|
||||||
return Err(CellError::Num);
|
|
||||||
}
|
|
||||||
let p = p as usize;
|
|
||||||
if s.len() > p {
|
if s.len() > p {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
@@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn places_arg(a: &[Arg]) -> R<Option<f64>> {
|
fn places_arg(a: &[Arg]) -> R<Option<f64>> {
|
||||||
if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) }
|
if !has(a, 1) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) {
|
||||||
|
return Err(CellError::Value);
|
||||||
|
}
|
||||||
|
Ok(Some(num(a, 1)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> {
|
fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> {
|
||||||
@@ -175,6 +185,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||||||
fn shift(a: &[Arg], left: bool) -> R<Value> {
|
fn shift(a: &[Arg], left: bool) -> R<Value> {
|
||||||
let n = bit_operand(a, 0)?;
|
let n = bit_operand(a, 0)?;
|
||||||
let s = num(a, 1)?.trunc();
|
let s = num(a, 1)?.trunc();
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(Value::Number(0.0));
|
||||||
|
}
|
||||||
if s.abs() > 53.0 {
|
if s.abs() > 53.0 {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
@@ -402,6 +415,12 @@ mod tests {
|
|||||||
is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777");
|
is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777");
|
||||||
is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001");
|
is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001");
|
||||||
is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100");
|
is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100");
|
||||||
|
is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num);
|
||||||
|
is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num);
|
||||||
|
is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num);
|
||||||
|
is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value);
|
||||||
|
is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111");
|
||||||
|
is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF");
|
||||||
is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num);
|
is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num);
|
||||||
is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064");
|
is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064");
|
||||||
is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA");
|
is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA");
|
||||||
@@ -426,6 +445,21 @@ mod tests {
|
|||||||
is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040");
|
is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040");
|
||||||
is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B");
|
is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B");
|
||||||
is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num);
|
is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num);
|
||||||
|
for (f, input) in [
|
||||||
|
("BIN2DEC", " 10"),
|
||||||
|
("BIN2DEC", "10 "),
|
||||||
|
("BIN2DEC", "\t10"),
|
||||||
|
("BIN2HEX", " 10"),
|
||||||
|
("BIN2OCT", "10 "),
|
||||||
|
("HEX2DEC", " 10"),
|
||||||
|
("HEX2BIN", "10 "),
|
||||||
|
("HEX2OCT", "\t10"),
|
||||||
|
("OCT2DEC", " 10"),
|
||||||
|
("OCT2BIN", "10 "),
|
||||||
|
("OCT2HEX", "\t10"),
|
||||||
|
] {
|
||||||
|
is_err(ev(f, vec![t(input)]), CellError::Num);
|
||||||
|
}
|
||||||
is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num);
|
is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num);
|
||||||
is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num);
|
is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num);
|
||||||
is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value);
|
is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value);
|
||||||
@@ -446,6 +480,21 @@ mod tests {
|
|||||||
is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num);
|
is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num);
|
||||||
is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num);
|
is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num);
|
||||||
is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num);
|
is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num);
|
||||||
|
for f in ["BITLSHIFT", "BITRSHIFT"] {
|
||||||
|
for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] {
|
||||||
|
close(ev(f, vec![n(0.0), n(shift)]), 0.0);
|
||||||
|
}
|
||||||
|
for shift in [-100.0, -54.0, 54.0, 100.0] {
|
||||||
|
is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num);
|
||||||
|
}
|
||||||
|
if f == "BITLSHIFT" {
|
||||||
|
is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num);
|
||||||
|
} else {
|
||||||
|
close(ev(f, vec![n(1.0), n(53.0)]), 0.0);
|
||||||
|
}
|
||||||
|
is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value);
|
||||||
|
}
|
||||||
|
is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ fn modulo(x: f64, d: f64) -> R<f64> {
|
|||||||
if d == 0.0 {
|
if d == 0.0 {
|
||||||
return Err(CellError::Div0);
|
return Err(CellError::Div0);
|
||||||
}
|
}
|
||||||
|
// Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16).
|
||||||
|
if (x / d).abs() >= 1.1259e12 {
|
||||||
|
return Err(CellError::Num);
|
||||||
|
}
|
||||||
// `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors.
|
// `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors.
|
||||||
let r = x % d;
|
let r = x % d;
|
||||||
// Keep the sign of the divisor and avoid returning |d| from rounding.
|
// Keep the sign of the divisor and avoid returning |d| from rounding.
|
||||||
|
|||||||
@@ -140,16 +140,22 @@ fn rounding() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mod_tiny_divisor_stays_in_range() {
|
fn mod_quotient_limit() {
|
||||||
for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] {
|
// Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12.
|
||||||
match ev2("MOD", x, d) {
|
for (x, d) in [
|
||||||
Value::Number(r) => {
|
(3.0, 1e-300),
|
||||||
assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}");
|
(-1.0, 1e-300),
|
||||||
assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}");
|
(3.0, -1e-300),
|
||||||
}
|
(-1.0, -1e-300),
|
||||||
other => panic!("MOD({x}, {d}) = {other:?}"),
|
(1e15, 7.0),
|
||||||
}
|
(1e10, 0.001),
|
||||||
|
(1_125_900_000_000.0, 1.0),
|
||||||
|
(-1_125_900_000_000.0, 1.0),
|
||||||
|
] {
|
||||||
|
is_err(ev2("MOD", x, d), Num);
|
||||||
}
|
}
|
||||||
|
close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0);
|
||||||
|
close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -336,6 +336,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R<Value> {
|
|||||||
if sig < 1.0 || s.is_empty() {
|
if sig < 1.0 || s.is_empty() {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
|
// Excel: 10^significance overflows from 309 up.
|
||||||
|
if sig >= 309.0 {
|
||||||
|
return Err(CellError::Div0);
|
||||||
|
}
|
||||||
let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0));
|
let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0));
|
||||||
if x < first || x > last {
|
if x < first || x > last {
|
||||||
return Err(CellError::NA);
|
return Err(CellError::NA);
|
||||||
@@ -359,6 +363,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R<Value> {
|
|||||||
let (rl, rh) = (rank_of(lo), rank_of(hi));
|
let (rl, rh) = (rank_of(lo), rank_of(hi));
|
||||||
rl + (x - lo) / (hi - lo) * (rh - rl)
|
rl + (x - lo) / (hi - lo) * (rh - rl)
|
||||||
};
|
};
|
||||||
|
// From significance 15 up Excel returns 15 significant digits, rounded.
|
||||||
|
if sig >= 15.0 {
|
||||||
|
return num_val(format!("{r:.14e}").parse().unwrap_or(r));
|
||||||
|
}
|
||||||
num_val(trunc_digits(r, sig))
|
num_val(trunc_digits(r, sig))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +428,10 @@ pub(super) fn geomean(v: &[f64]) -> R<f64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn harmean(v: &[f64]) -> R<f64> {
|
pub(super) fn harmean(v: &[f64]) -> R<f64> {
|
||||||
if v.is_empty() || v.iter().any(|&x| x <= 0.0) {
|
if v.is_empty() {
|
||||||
|
return Err(CellError::NA);
|
||||||
|
}
|
||||||
|
if v.iter().any(|&x| x <= 0.0) {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>())
|
Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>())
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ fn t_cdf(t: f64, v: f64) -> f64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn t_pdf(t: f64, v: f64) -> f64 {
|
fn t_pdf(t: f64, v: f64) -> f64 {
|
||||||
(ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp()
|
let z = v / 2.0;
|
||||||
|
let log_gamma_ratio = if z >= 16.0 {
|
||||||
|
let inv_z = 1.0 / z;
|
||||||
|
0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0
|
||||||
|
} else {
|
||||||
|
ln_gamma(z + 0.5) - ln_gamma(z)
|
||||||
|
};
|
||||||
|
(log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// t ≥ 0 with P(T > t) = q, q in (0, 0.5].
|
/// t ≥ 0 with P(T > t) = q, q in (0, 0.5].
|
||||||
@@ -51,6 +58,12 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 {
|
|||||||
if x <= 0.0 {
|
if x <= 0.0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
if x == 1.0 && d1 == d2 {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
|
||||||
|
return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
|
||||||
|
}
|
||||||
reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0)
|
reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +71,10 @@ fn f_upper(x: f64, d1: f64, d2: f64) -> f64 {
|
|||||||
if x <= 0.0 {
|
if x <= 0.0 {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
|
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
|
||||||
|
// With a much larger numerator degree, the scaled beta tail converges to a gamma tail.
|
||||||
|
return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
|
||||||
|
}
|
||||||
reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0)
|
reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +91,8 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
|
|||||||
((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp()
|
((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
|
||||||
|
|
||||||
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
|
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
|
||||||
if k < 0.0 || k > n {
|
if k < 0.0 || k > n {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -176,8 +195,14 @@ fn df_arg(a: &[Arg], i: usize) -> R<f64> {
|
|||||||
|
|
||||||
pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> {
|
pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> {
|
||||||
let x = num(a, 0)?;
|
let x = num(a, 0)?;
|
||||||
let v = df_arg(a, 1)?;
|
let v = num(a, 1)?.trunc();
|
||||||
let cum = boolean(a, 2)?;
|
let cum = boolean(a, 2)?;
|
||||||
|
if v == 0.0 {
|
||||||
|
return Err(if cum { CellError::Num } else { CellError::Div0 });
|
||||||
|
}
|
||||||
|
if v < 0.0 {
|
||||||
|
return Err(CellError::Num);
|
||||||
|
}
|
||||||
num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) })
|
num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +234,9 @@ pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R<Value> {
|
|||||||
pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> {
|
pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> {
|
||||||
let p = num(a, 0)?;
|
let p = num(a, 0)?;
|
||||||
let v = df_arg(a, 1)?;
|
let v = df_arg(a, 1)?;
|
||||||
|
if p == 0.0 {
|
||||||
|
return Err(CellError::Div0);
|
||||||
|
}
|
||||||
if !(p > 0.0 && p < 1.0) {
|
if !(p > 0.0 && p < 1.0) {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
@@ -347,7 +375,7 @@ fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> {
|
|||||||
let x = num(a, 0)?;
|
let x = num(a, 0)?;
|
||||||
let d1 = num(a, 1)?.trunc();
|
let d1 = num(a, 1)?.trunc();
|
||||||
let d2 = num(a, 2)?.trunc();
|
let d2 = num(a, 2)?.trunc();
|
||||||
if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 {
|
if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
Ok((x, d1, d2))
|
Ok((x, d1, d2))
|
||||||
@@ -378,7 +406,8 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R<Value> {
|
|||||||
if p == 0.0 {
|
if p == 0.0 {
|
||||||
return num_val(0.0);
|
return num_val(0.0);
|
||||||
}
|
}
|
||||||
num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60))
|
let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) };
|
||||||
|
num_val(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> {
|
pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> {
|
||||||
@@ -410,7 +439,7 @@ pub(super) fn binom_dist(a: &[Arg], _: C) -> R<Value> {
|
|||||||
let n = num(a, 1)?.trunc();
|
let n = num(a, 1)?.trunc();
|
||||||
let p = num(a, 2)?;
|
let p = num(a, 2)?;
|
||||||
let cum = boolean(a, 3)?;
|
let cum = boolean(a, 3)?;
|
||||||
if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) {
|
if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) })
|
num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) })
|
||||||
@@ -421,7 +450,7 @@ pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
|
|||||||
let p = num(a, 1)?;
|
let p = num(a, 1)?;
|
||||||
let s1 = num(a, 2)?.trunc();
|
let s1 = num(a, 2)?.trunc();
|
||||||
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
|
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
|
||||||
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
|
if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
if s2 - s1 > 10_000.0 {
|
if s2 - s1 > 10_000.0 {
|
||||||
@@ -499,13 +528,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R<Value> {
|
|||||||
return num_val(reg_gamma_p(al, x / be));
|
return num_val(reg_gamma_p(al, x / be));
|
||||||
}
|
}
|
||||||
if x == 0.0 {
|
if x == 0.0 {
|
||||||
return if al < 1.0 {
|
return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) };
|
||||||
Err(CellError::Num)
|
|
||||||
} else if al == 1.0 {
|
|
||||||
num_val(1.0 / be)
|
|
||||||
} else {
|
|
||||||
num_val(0.0)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp())
|
num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp())
|
||||||
}
|
}
|
||||||
@@ -548,7 +571,7 @@ pub(super) fn beta_dist(a: &[Arg], _: C) -> R<Value> {
|
|||||||
if cum {
|
if cum {
|
||||||
return num_val(reg_inc_beta(z, al, be));
|
return num_val(reg_inc_beta(z, al, be));
|
||||||
}
|
}
|
||||||
if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) {
|
if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
let lp = if z == 0.0 || z == 1.0 {
|
let lp = if z == 0.0 || z == 1.0 {
|
||||||
@@ -608,6 +631,9 @@ pub(super) fn weibull_dist(a: &[Arg], _: C) -> R<Value> {
|
|||||||
if x < 0.0 || al <= 0.0 || be <= 0.0 {
|
if x < 0.0 || al <= 0.0 || be <= 0.0 {
|
||||||
return Err(CellError::Num);
|
return Err(CellError::Num);
|
||||||
}
|
}
|
||||||
|
if x == 0.0 {
|
||||||
|
return num_val(0.0);
|
||||||
|
}
|
||||||
let t = (x / be).powf(al);
|
let t = (x / be).powf(al);
|
||||||
num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() })
|
num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,19 @@ fn spread() {
|
|||||||
let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0];
|
let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0];
|
||||||
close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9);
|
close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9);
|
||||||
close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9);
|
close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9);
|
||||||
|
let text_values = arr(vec![vec![tv("1"), tv("2")]]);
|
||||||
|
let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]);
|
||||||
|
let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]);
|
||||||
|
is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA);
|
||||||
|
is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA);
|
||||||
|
is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA);
|
||||||
|
is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA);
|
||||||
|
close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0);
|
||||||
|
close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0);
|
||||||
|
close(ev("HARMEAN", vec![b(true)]), 1.0);
|
||||||
|
close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0);
|
||||||
|
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num);
|
||||||
|
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num);
|
||||||
is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num);
|
is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num);
|
||||||
close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9);
|
close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9);
|
||||||
is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num);
|
is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num);
|
||||||
@@ -214,10 +227,19 @@ fn percentiles() {
|
|||||||
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381);
|
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381);
|
||||||
close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3);
|
close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3);
|
||||||
let t = c(&[1.0, 2.0, 3.0]);
|
let t = c(&[1.0, 2.0, 3.0]);
|
||||||
close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
|
// Excel 16: significance 309 and up is #DIV/0!, 308 still works.
|
||||||
close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
|
for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] {
|
||||||
close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0);
|
is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0);
|
||||||
close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5);
|
is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0);
|
||||||
|
close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5);
|
||||||
|
}
|
||||||
|
// From significance 15 up the result has 15 significant digits.
|
||||||
|
let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
|
||||||
|
for sig in [15.0, 16.0, 308.0] {
|
||||||
|
close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16);
|
||||||
|
}
|
||||||
|
close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66, 1e-15);
|
||||||
|
close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -360,6 +382,7 @@ fn normal() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn t_chi_f() {
|
fn t_chi_f() {
|
||||||
|
close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10);
|
||||||
close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7);
|
close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7);
|
||||||
close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5);
|
close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5);
|
||||||
close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7);
|
close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7);
|
||||||
@@ -397,6 +420,7 @@ fn t_chi_f() {
|
|||||||
close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
|
close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
|
||||||
close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
|
close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
|
||||||
close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7);
|
close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7);
|
||||||
|
close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7);
|
||||||
close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
|
close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
|
||||||
close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
|
close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
|
||||||
close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7);
|
close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7);
|
||||||
@@ -404,6 +428,48 @@ fn t_chi_f() {
|
|||||||
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
|
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f_distributions_accept_ten_billion_degrees() {
|
||||||
|
close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0);
|
||||||
|
close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0);
|
||||||
|
close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28);
|
||||||
|
close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0);
|
||||||
|
close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8);
|
||||||
|
close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6);
|
||||||
|
close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0);
|
||||||
|
is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num);
|
||||||
|
is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_dist_zero_degrees_error_depends_on_mode() {
|
||||||
|
for df in [0.0, 0.9, -0.1] {
|
||||||
|
is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0);
|
||||||
|
is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num);
|
||||||
|
}
|
||||||
|
is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num);
|
||||||
|
close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
|
||||||
|
close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_density_is_stable_for_large_degrees() {
|
||||||
|
close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12);
|
||||||
|
close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12);
|
||||||
|
close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_inv_zero_probability_returns_div0() {
|
||||||
|
is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num);
|
||||||
|
is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0);
|
||||||
|
close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7);
|
||||||
|
is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num);
|
||||||
|
is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num);
|
||||||
|
is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num);
|
||||||
|
is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn binom_inv_huge_trials_returns() {
|
fn binom_inv_huge_trials_returns() {
|
||||||
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
|
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
|
||||||
@@ -427,7 +493,15 @@ fn discrete() {
|
|||||||
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
|
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
|
||||||
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12);
|
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12);
|
||||||
close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
|
close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
|
||||||
|
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0);
|
||||||
|
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0);
|
||||||
|
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num);
|
||||||
|
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num);
|
||||||
is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num);
|
is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num);
|
||||||
|
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0);
|
||||||
|
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0);
|
||||||
|
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num);
|
||||||
|
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num);
|
||||||
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6);
|
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6);
|
||||||
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6);
|
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6);
|
||||||
close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0);
|
close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0);
|
||||||
@@ -466,6 +540,19 @@ fn continuous() {
|
|||||||
is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num);
|
is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num);
|
||||||
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7);
|
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7);
|
||||||
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7);
|
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7);
|
||||||
|
is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num);
|
||||||
|
is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num);
|
||||||
|
is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num);
|
||||||
|
is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num);
|
||||||
|
close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0);
|
||||||
|
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0);
|
||||||
|
close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0);
|
||||||
|
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0);
|
||||||
|
is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num);
|
||||||
|
is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num);
|
||||||
|
close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25);
|
||||||
|
close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0);
|
||||||
|
close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0);
|
||||||
close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7);
|
close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7);
|
||||||
close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
|
close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
|
||||||
close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
|
close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
|
||||||
@@ -479,6 +566,30 @@ fn continuous() {
|
|||||||
close_tol(r[0][1].clone(), 0.841344746, 1e-8);
|
close_tol(r[0][1].clone(), 0.841344746, 1e-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamma_density_at_zero_shape_one_is_num() {
|
||||||
|
for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] {
|
||||||
|
is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num);
|
||||||
|
}
|
||||||
|
is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num);
|
||||||
|
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0);
|
||||||
|
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0);
|
||||||
|
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15);
|
||||||
|
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
|
||||||
|
is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weibull_density_at_zero_is_zero() {
|
||||||
|
for shape in [0.5, 1.0, 2.0] {
|
||||||
|
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0);
|
||||||
|
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0);
|
||||||
|
}
|
||||||
|
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15);
|
||||||
|
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
|
||||||
|
is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aggregate_helper() {
|
fn aggregate_helper() {
|
||||||
let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)];
|
let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)];
|
||||||
|
|||||||
+113
-12
@@ -111,12 +111,31 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||||||
num_val(text(a, 0)?.chars().count() as f64)
|
num_val(text(a, 0)?.chars().count() as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lower_char(ch: char) -> char {
|
||||||
|
match ch.to_lowercase().next() {
|
||||||
|
Some(lower) => lower,
|
||||||
|
None => ch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upper_char(ch: char) -> char {
|
||||||
|
let mut uppercase = ch.to_uppercase();
|
||||||
|
match uppercase.next() {
|
||||||
|
Some(upper) if uppercase.next().is_none() => upper,
|
||||||
|
_ => ch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proper_upper_char(ch: char) -> char {
|
||||||
|
if ch == '\u{130}' { 'I' } else { upper_char(ch) }
|
||||||
|
}
|
||||||
|
|
||||||
fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||||
text_val(text(a, 0)?.to_lowercase())
|
text_val(text(a, 0)?.chars().map(lower_char).collect::<String>())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||||
text_val(text(a, 0)?.to_uppercase())
|
text_val(text(a, 0)?.chars().map(upper_char).collect::<String>())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||||
@@ -126,9 +145,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||||||
for ch in s.chars() {
|
for ch in s.chars() {
|
||||||
if ch.is_alphabetic() {
|
if ch.is_alphabetic() {
|
||||||
if prev_letter {
|
if prev_letter {
|
||||||
out.extend(ch.to_lowercase());
|
out.push(lower_char(ch));
|
||||||
} else {
|
} else {
|
||||||
out.extend(ch.to_uppercase());
|
out.push(proper_upper_char(ch));
|
||||||
}
|
}
|
||||||
prev_letter = true;
|
prev_letter = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -281,7 +300,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||||||
if !(1.0..=1_114_111.0).contains(&n) {
|
if !(1.0..=1_114_111.0).contains(&n) {
|
||||||
return Err(CellError::Value);
|
return Err(CellError::Value);
|
||||||
}
|
}
|
||||||
match char::from_u32(n as u32) {
|
let cp = n as u32;
|
||||||
|
if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE {
|
||||||
|
return Err(CellError::NA);
|
||||||
|
}
|
||||||
|
match char::from_u32(cp) {
|
||||||
Some(c) => text_val(c.to_string()),
|
Some(c) => text_val(c.to_string()),
|
||||||
None => Err(CellError::NA),
|
None => Err(CellError::NA),
|
||||||
}
|
}
|
||||||
@@ -350,10 +373,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
|||||||
/// Formats `x` (already rounded) with `decimals` places and optional thousands separators.
|
/// Formats `x` (already rounded) with `decimals` places and optional thousands separators.
|
||||||
fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
|
fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
|
||||||
let d = decimals.max(0) as usize;
|
let d = decimals.max(0) as usize;
|
||||||
let s = format!("{:.*}", d, x.abs());
|
let s = x.abs().to_string();
|
||||||
let (int_part, frac) = match s.split_once('.') {
|
let (int_part, frac) = match s.split_once('.') {
|
||||||
Some((i, f)) => (i.to_string(), Some(f.to_string())),
|
Some((i, f)) => (i, f),
|
||||||
None => (s.clone(), None),
|
None => (s.as_str(), ""),
|
||||||
};
|
};
|
||||||
let int_part = if commas {
|
let int_part = if commas {
|
||||||
let b = int_part.as_bytes();
|
let b = int_part.as_bytes();
|
||||||
@@ -366,12 +389,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
|
|||||||
}
|
}
|
||||||
o
|
o
|
||||||
} else {
|
} else {
|
||||||
int_part
|
int_part.to_string()
|
||||||
};
|
};
|
||||||
let mut out = int_part;
|
let mut out = int_part;
|
||||||
if let Some(f) = frac {
|
if d > 0 {
|
||||||
|
let frac_len = frac.len().min(d);
|
||||||
out.push('.');
|
out.push('.');
|
||||||
out.push_str(&f);
|
out.push_str(&frac[..frac_len]);
|
||||||
|
out.extend(std::iter::repeat_n('0', d - frac_len));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -394,13 +419,21 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||||||
let (r, d) = rounded(a, 2.0)?;
|
let (r, d) = rounded(a, 2.0)?;
|
||||||
let no_commas = opt_bool(a, 2, false)?;
|
let no_commas = opt_bool(a, 2, false)?;
|
||||||
let body = format_fixed(r, d, !no_commas);
|
let body = format_fixed(r, d, !no_commas);
|
||||||
|
if body.len() + if r < 0.0 { 1 } else { 0 } > 255 {
|
||||||
|
return Err(CellError::Value);
|
||||||
|
}
|
||||||
text_val(if r < 0.0 { format!("-{body}") } else { body })
|
text_val(if r < 0.0 { format!("-{body}") } else { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||||
let (r, d) = rounded(a, 2.0)?;
|
let (r, d) = rounded(a, 2.0)?;
|
||||||
|
let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0);
|
||||||
let body = format_fixed(r, d, true);
|
let body = format_fixed(r, d, true);
|
||||||
text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") })
|
let out = if negative { format!("(${body})") } else { format!("${body}") };
|
||||||
|
if out.len() > 254 {
|
||||||
|
return Err(CellError::Value);
|
||||||
|
}
|
||||||
|
text_val(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@@ -1123,6 +1156,20 @@ mod tests {
|
|||||||
fn case_and_cleanup() {
|
fn case_and_cleanup() {
|
||||||
is_text(ev("UPPER", vec![t("abc")]), "ABC");
|
is_text(ev("UPPER", vec![t("abc")]), "ABC");
|
||||||
is_text(ev("LOWER", vec![t("ABC")]), "abc");
|
is_text(ev("LOWER", vec![t("ABC")]), "abc");
|
||||||
|
is_text(ev("UPPER", vec![t("Straße")]), "STRAßE");
|
||||||
|
is_text(ev("LOWER", vec![t("İ")]), "i");
|
||||||
|
is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul");
|
||||||
|
is_text(ev("PROPER", vec![t("ß")]), "ß");
|
||||||
|
is_text(ev("PROPER", vec![t("ÉCOLE")]), "École");
|
||||||
|
let proper = ev("PROPER", vec![t("İSTANBUL Straße")]);
|
||||||
|
is_text(proper.clone(), "Istanbul Straße");
|
||||||
|
assert_eq!(
|
||||||
|
match proper {
|
||||||
|
Value::Text(s) => s.chars().count(),
|
||||||
|
_ => 0,
|
||||||
|
},
|
||||||
|
"Istanbul Straße".chars().count()
|
||||||
|
);
|
||||||
is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title");
|
is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title");
|
||||||
is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S");
|
is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S");
|
||||||
is_text(ev("TRIM", vec![t(" a b c ")]), "a b c");
|
is_text(ev("TRIM", vec![t(" a b c ")]), "a b c");
|
||||||
@@ -1181,8 +1228,15 @@ mod tests {
|
|||||||
close(ev("CODE", vec![t("€")]), 128.0);
|
close(ev("CODE", vec![t("€")]), 128.0);
|
||||||
is_err(ev("CODE", vec![t("")]), CellError::Value);
|
is_err(ev("CODE", vec![t("")]), CellError::Value);
|
||||||
is_text(ev("UNICHAR", vec![n(9731.0)]), "☃");
|
is_text(ev("UNICHAR", vec![n(9731.0)]), "☃");
|
||||||
|
for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] {
|
||||||
|
is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA);
|
||||||
|
}
|
||||||
|
for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] {
|
||||||
|
is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected);
|
||||||
|
}
|
||||||
close(ev("UNICODE", vec![t("☃")]), 9731.0);
|
close(ev("UNICODE", vec![t("☃")]), 9731.0);
|
||||||
is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value);
|
is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value);
|
||||||
|
is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value);
|
||||||
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
|
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,10 +1291,57 @@ mod tests {
|
|||||||
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
|
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
|
||||||
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
|
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
|
||||||
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
|
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
|
||||||
|
let fixed_16 = format!("1.1{}", "0".repeat(15));
|
||||||
|
let dollar_16 = format!("$1.1{}", "0".repeat(15));
|
||||||
|
let fixed_20 = format!("1.005{}", "0".repeat(17));
|
||||||
|
let dollar_20 = format!("$1.005{}", "0".repeat(17));
|
||||||
|
let fixed_127 = format!("1.1{}", "0".repeat(126));
|
||||||
|
let dollar_127 = format!("$1.1{}", "0".repeat(126));
|
||||||
|
is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16);
|
||||||
|
is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16);
|
||||||
|
is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20);
|
||||||
|
is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20);
|
||||||
|
is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127);
|
||||||
|
is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127);
|
||||||
is_text(ev("T", vec![t("x")]), "x");
|
is_text(ev("T", vec![t("x")]), "x");
|
||||||
is_text(ev("T", vec![n(1.0)]), "");
|
is_text(ev("T", vec![n(1.0)]), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_and_dollar_output_limits() {
|
||||||
|
assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255));
|
||||||
|
is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value);
|
||||||
|
assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-')));
|
||||||
|
is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value);
|
||||||
|
assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255));
|
||||||
|
is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value);
|
||||||
|
|
||||||
|
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254));
|
||||||
|
is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value);
|
||||||
|
assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254));
|
||||||
|
is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value);
|
||||||
|
assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254));
|
||||||
|
is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value);
|
||||||
|
is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value);
|
||||||
|
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254));
|
||||||
|
is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() {
|
||||||
|
is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)");
|
||||||
|
is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)");
|
||||||
|
is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0");
|
||||||
|
is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0");
|
||||||
|
is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0");
|
||||||
|
is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0");
|
||||||
|
is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0");
|
||||||
|
is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)");
|
||||||
|
is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)");
|
||||||
|
is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)");
|
||||||
|
is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn before_after() {
|
fn before_after() {
|
||||||
is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red");
|
is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red");
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
|
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
|
||||||
# craft-libs dependency uses (see the root Cargo.toml).
|
# craft-libs dependency uses (see the root Cargo.toml).
|
||||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||||
gridcraft-engine = { workspace = true }
|
gridcraft-engine = { workspace = true }
|
||||||
gridcraft-chart = { workspace = true }
|
gridcraft-chart = { workspace = true }
|
||||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||||
//! standards/contributors.md). Nothing is read at run time.
|
|
||||||
//!
|
//!
|
||||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose
|
||||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
//! people.toml entry is verified.
|
||||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||||
//! JSON says the entry was verified (`people_entry`).
|
//! JSON says the entry was verified (`people_entry`).
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
//! build.rs from contributors/contributors.json; nothing is read at run time.
|
||||||
//! read at run time.
|
|
||||||
|
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use super::controls::{
|
|||||||
use super::galleries::{cell_style_gallery, table_gallery};
|
use super::galleries::{cell_style_gallery, table_gallery};
|
||||||
use super::{
|
use super::{
|
||||||
HAlign, Icon, Key, Modifiers, Rect, SheetApp, Style, Tokens, Ui, VAlign, big_button, color_palette, icon_button, json, keytip_table,
|
HAlign, Icon, Key, Modifiers, Rect, SheetApp, Style, Tokens, Ui, VAlign, big_button, color_palette, icon_button, json, keytip_table,
|
||||||
small_button, split_button, theme, toggle_button, vec2,
|
small_button, small_split_button, split_button, theme, toggle_button, vec2,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol.
|
/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol.
|
||||||
@@ -292,12 +292,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
if __k.clicked() {
|
if __k.clicked() {
|
||||||
act(app, "home.increaseIndent", json!({}));
|
act(app, "home.increaseIndent", json!({}));
|
||||||
}
|
}
|
||||||
let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center");
|
let (m, ma) = small_split_button(ui, Icon::Merge, "Merge & Center", "Merge & Center");
|
||||||
control_keytip(app, ui, &ma, "home.mergeCenter");
|
control_keytip(app, ui, &ma, "home.mergeCenter");
|
||||||
if m {
|
if m.clicked() {
|
||||||
act(app, "home.mergeCenter", json!({}));
|
act(app, "home.mergeCenter", json!({}));
|
||||||
}
|
}
|
||||||
ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5)));
|
|
||||||
egui::Popup::menu(&ma).show(|ui| {
|
egui::Popup::menu(&ma).show(|ui| {
|
||||||
menu_items(
|
menu_items(
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! offers the new release: Update and Restart (direct self-update), Download or Later. The flow
|
//! offers the new release: Update and Restart (direct self-update), Download or Later. The flow
|
||||||
//! itself is `craft_update::updater::Updater`.
|
//! itself is `craft_update::updater::Updater`.
|
||||||
|
|
||||||
|
use craft_update::CheckResultResponse;
|
||||||
use craft_update::updater::{Action, Status, Updater};
|
use craft_update::updater::{Action, Status, Updater};
|
||||||
|
|
||||||
use crate::SheetApp;
|
use crate::SheetApp;
|
||||||
@@ -14,11 +15,13 @@ pub(crate) struct Runtime {
|
|||||||
startup: bool,
|
startup: bool,
|
||||||
/// Help ▸ Check for Updates… was chosen; the next `tick` starts it.
|
/// Help ▸ Check for Updates… was chosen; the next `tick` starts it.
|
||||||
manual: bool,
|
manual: bool,
|
||||||
|
/// The result dialog of a requested check is showing.
|
||||||
|
result_open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Runtime {
|
impl Default for Runtime {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false }
|
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false, result_open: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,19 +38,14 @@ pub(crate) fn tick(app: &mut SheetApp, ctx: &egui::Context) {
|
|||||||
let rt = &mut app.updates_rt;
|
let rt = &mut app.updates_rt;
|
||||||
if std::mem::take(&mut rt.manual) {
|
if std::mem::take(&mut rt.manual) {
|
||||||
rt.updater.check_now(ctx);
|
rt.updater.check_now(ctx);
|
||||||
|
rt.result_open = true;
|
||||||
} else if rt.startup {
|
} else if rt.startup {
|
||||||
rt.updater.check_at_start(ctx);
|
rt.updater.check_at_start(ctx);
|
||||||
}
|
}
|
||||||
rt.updater.tick(ctx);
|
rt.updater.tick(ctx);
|
||||||
let lang = app.ui.language;
|
// A requested check that found a release leaves the status idle and the prompt opens.
|
||||||
let outcome = match rt.updater.status() {
|
if matches!(rt.updater.status(), Status::Idle) {
|
||||||
Status::UpToDate => Some(lang.tr("GridCraft {version} is up to date.").replace("{version}", VERSION)),
|
rt.result_open = false;
|
||||||
Status::Failed(e) => Some(lang.tr("Could not check for updates: {error}").replace("{error}", e)),
|
|
||||||
Status::Idle | Status::Checking => None,
|
|
||||||
};
|
|
||||||
if let Some(message) = outcome {
|
|
||||||
rt.updater.dismiss_status();
|
|
||||||
app.message = Some(("GridCraft".into(), message));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,4 +63,15 @@ pub(crate) fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||||
Action::None => {}
|
Action::None => {}
|
||||||
}
|
}
|
||||||
|
if app.updates_rt.result_open {
|
||||||
|
let rt = &mut app.updates_rt;
|
||||||
|
match craft_update::CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
||||||
|
CheckResultResponse::Close => {
|
||||||
|
rt.updater.dismiss_status();
|
||||||
|
rt.result_open = false;
|
||||||
|
}
|
||||||
|
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
|
||||||
|
CheckResultResponse::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ mod keytips_generated;
|
|||||||
mod macro_editor;
|
mod macro_editor;
|
||||||
mod macros_ui;
|
mod macros_ui;
|
||||||
mod menu_hover;
|
mod menu_hover;
|
||||||
|
mod merge_center_label;
|
||||||
mod merged_cells;
|
mod merged_cells;
|
||||||
mod more_colors;
|
mod more_colors;
|
||||||
mod native_print;
|
mod native_print;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Home › Merge & Center is a split button: clicking its text label runs `home.mergeCenter`, like
|
||||||
|
//! its icon does.
|
||||||
|
|
||||||
|
use egui::{Event, Shape, vec2};
|
||||||
|
use gridcraft_engine::Session;
|
||||||
|
use gridcraft_ui_egui::SheetApp;
|
||||||
|
use gridcraft_ui_egui::i18n::Language;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_the_label_merges_and_centers() {
|
||||||
|
for language in [Language::En, Language::De] {
|
||||||
|
click_label(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click_label(language: Language) {
|
||||||
|
let label = language.tr("Merge & Center");
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.new_workbook();
|
||||||
|
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||||
|
let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|
||||||
|
|ui, app: &mut SheetApp| {
|
||||||
|
app.logic(ui.ctx());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut app = crate::common::app(s);
|
||||||
|
app.ui.language = language;
|
||||||
|
app
|
||||||
|
},
|
||||||
|
);
|
||||||
|
h.run_steps(4);
|
||||||
|
let at = h
|
||||||
|
.output()
|
||||||
|
.shapes
|
||||||
|
.iter()
|
||||||
|
.find_map(|s| match &s.shape {
|
||||||
|
Shape::Text(t) if t.galley.job.text == label => Some(t.pos + t.galley.size() / 2.0),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| panic!("the Home tab shows {label}"));
|
||||||
|
h.input_mut().events.push(Event::PointerMoved(at));
|
||||||
|
h.run_steps(2);
|
||||||
|
for pressed in [true, false] {
|
||||||
|
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||||
|
h.run_steps(2);
|
||||||
|
}
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeCenter"), "{label}");
|
||||||
|
}
|
||||||
@@ -151,6 +151,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
|
|||||||
"CSCH",
|
"CSCH",
|
||||||
"DAYS",
|
"DAYS",
|
||||||
"DECIMAL",
|
"DECIMAL",
|
||||||
|
"DETECTLANGUAGE",
|
||||||
"DROP",
|
"DROP",
|
||||||
"ENCODEURL",
|
"ENCODEURL",
|
||||||
"ERF.PRECISE",
|
"ERF.PRECISE",
|
||||||
@@ -243,6 +244,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
|
|||||||
"SORTBY",
|
"SORTBY",
|
||||||
"STDEV.P",
|
"STDEV.P",
|
||||||
"STDEV.S",
|
"STDEV.S",
|
||||||
|
"STOCKHISTORY",
|
||||||
"SWITCH",
|
"SWITCH",
|
||||||
"T.DIST",
|
"T.DIST",
|
||||||
"T.DIST.2T",
|
"T.DIST.2T",
|
||||||
@@ -257,6 +259,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
|
|||||||
"TEXTSPLIT",
|
"TEXTSPLIT",
|
||||||
"TOCOL",
|
"TOCOL",
|
||||||
"TOROW",
|
"TOROW",
|
||||||
|
"TRANSLATE",
|
||||||
"TRIMRANGE",
|
"TRIMRANGE",
|
||||||
"UNICHAR",
|
"UNICHAR",
|
||||||
"UNICODE",
|
"UNICODE",
|
||||||
@@ -352,6 +355,13 @@ mod tests {
|
|||||||
assert_eq!(file_function_name("FILTER").as_deref(), Some("_xlfn._xlws.FILTER"));
|
assert_eq!(file_function_name("FILTER").as_deref(), Some("_xlfn._xlws.FILTER"));
|
||||||
assert_eq!(file_function_name("PERCENTOF").as_deref(), Some("_xlfn.PERCENTOF"));
|
assert_eq!(file_function_name("PERCENTOF").as_deref(), Some("_xlfn.PERCENTOF"));
|
||||||
assert_eq!(file_function_name("TRIMRANGE").as_deref(), Some("_xlfn.TRIMRANGE"));
|
assert_eq!(file_function_name("TRIMRANGE").as_deref(), Some("_xlfn.TRIMRANGE"));
|
||||||
|
assert_eq!(file_function_name("stockhistory").as_deref(), Some("_xlfn.STOCKHISTORY"));
|
||||||
|
assert_eq!(file_function_name("TRANSLATE").as_deref(), Some("_xlfn.TRANSLATE"));
|
||||||
|
assert_eq!(file_function_name("DETECTLANGUAGE").as_deref(), Some("_xlfn.DETECTLANGUAGE"));
|
||||||
|
assert_eq!(file_function_name("NETWORKDAYS.INTL").as_deref(), Some("_xlfn.NETWORKDAYS.INTL"));
|
||||||
|
assert_eq!(file_function_name("WORKDAY.INTL").as_deref(), Some("_xlfn.WORKDAY.INTL"));
|
||||||
|
// Excel 16.0 build 20527 saves COPILOT as plain `COPILOT(...)` (it does not know the name yet).
|
||||||
|
assert_eq!(file_function_name("COPILOT"), None);
|
||||||
assert_eq!(file_function_name("SUM"), None);
|
assert_eq!(file_function_name("SUM"), None);
|
||||||
assert_eq!(builtin_format(14), Some("m/d/yyyy"));
|
assert_eq!(builtin_format(14), Some("m/d/yyyy"));
|
||||||
assert_eq!(builtin_id("0.00%"), Some(10));
|
assert_eq!(builtin_id("0.00%"), Some(10));
|
||||||
|
|||||||
+4
-21
@@ -1,16 +1,14 @@
|
|||||||
# Contributors in the About window
|
# Contributors in the About window
|
||||||
|
|
||||||
**About ▸ Contributors** credits everyone who contributed to GridCraft, and **About ▸ Models** credits
|
**About ▸ Contributors** credits everyone who contributed to GridCraft, and **About ▸ Models** credits
|
||||||
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
|
the AI models named in `Co-Authored-By` trailers.
|
||||||
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
|
|
||||||
this page is the local copy of the decision.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||||
disk or the network at run time; the web build has them too):
|
disk or the network at run time; the web build has them too):
|
||||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||||
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
|
username. Never hand-edit.
|
||||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||||
@@ -37,20 +35,5 @@ display_name = "Nickname"
|
|||||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||||
```
|
```
|
||||||
|
|
||||||
or, with craftrules checked out next to `craft-apps/`:
|
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
|
||||||
|
Delete your entry to remove your names. Git author names and emails are never recorded.
|
||||||
```sh
|
|
||||||
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
|
|
||||||
```
|
|
||||||
|
|
||||||
Names show after the next refresh: the script checks that each entry was committed by its own
|
|
||||||
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
|
|
||||||
|
|
||||||
## Refreshing (maintainers)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
|
|
||||||
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
|||||||
|
|
||||||
| Work | Hours |
|
| Work | Hours |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 |
|
| Real-world XLSX corpus run, and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 |
|
||||||
| Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 |
|
| Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 |
|
||||||
| Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 |
|
| Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 |
|
||||||
| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 |
|
| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 |
|
||||||
|
|||||||
+28
-1
@@ -1,6 +1,6 @@
|
|||||||
# Function parity: worksheet functions and formulas
|
# Function parity: worksheet functions and formulas
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-27 · **Change:** minor (ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals, and the other IM* differences from Excel are listed; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||||
|
|
||||||
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
|
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
|
||||||
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
|
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
|
||||||
@@ -86,11 +86,14 @@ This improves series preparation; it does not change the model-fitting or season
|
|||||||
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
|
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
|
||||||
| Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) |
|
| Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) |
|
||||||
| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` |
|
| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` |
|
||||||
|
| YEARFRAC basis 0, February-end start to day 31 | done: Excel 16.0 returns 31/360 for `YEARFRAC(2021-02-28,2021-03-31,0)` (0.08611111111111111); regression also covers leap day and reversed dates |
|
||||||
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
||||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` |
|
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` |
|
||||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened
|
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened
|
||||||
| 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
|
| 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
|
||||||
| IMSQRT and the other IM* functions against Excel's text | IMSQRT follows Excel's polar route, so a negative real gives Excel's stray real part: `IMSQRT("-4")` is `1.22514845490862E-16+2i` (checked in Excel 16.0 on 79 inputs). Differences kept: IMSQRT of a number with a part above about 1E154 or below about 1E-155 gives the exact root (Excel gives `#NUM!` or 0); IMPOWER, IMEXP, IMSIN, IMCOS, IMSINH and IMCOSH keep exact results where Excel shows the same kind of noise (`IMPOWER("-4",2)` is `16-3.92047505570758E-15i`) or different trailing digits near multiples of pi/2; IMCSC and IMCSCH differ by one unit in the 15th digit; IMARGUMENT returns 16–17 digits where Excel returns 15; the text of IM* and COMPLEX results differs for some magnitudes (Excel writes `COMPLEX(1E-10,1)` as `0.0000000001+i` but `COMPLEX(0.000000123456789012345,1)` as `1.23456789012345E-07+i`, and an integer past 15 digits rounded and zero-padded) |
|
| IMSQRT and the other IM* functions against Excel's text | IMSQRT follows Excel's polar route, so a negative real gives Excel's stray real part: `IMSQRT("-4")` is `1.22514845490862E-16+2i` (checked in Excel 16.0 on 79 inputs). Differences kept: IMSQRT of a number with a part above about 1E154 or below about 1E-155 gives the exact root (Excel gives `#NUM!` or 0); IMPOWER, IMEXP, IMSIN, IMCOS, IMSINH and IMCOSH keep exact results where Excel shows the same kind of noise (`IMPOWER("-4",2)` is `16-3.92047505570758E-15i`) or different trailing digits near multiples of pi/2; IMCSC and IMCSCH differ by one unit in the 15th digit; IMARGUMENT returns 16–17 digits where Excel returns 15; the text of IM* and COMPLEX results differs for some magnitudes (Excel writes `COMPLEX(1E-10,1)` as `0.0000000001+i` but `COMPLEX(0.000000123456789012345,1)` as `1.23456789012345E-07+i`, and an integer past 15 digits rounded and zero-padded) |
|
||||||
|
| MOD quotient limit | done: Excel 16.0 returns `#NUM!` when `ABS(number/divisor) >= 1.1259E12`; lower quotients retain the remainder |
|
||||||
|
| PERCENTRANK significance and precision | done: Excel 16.0 returns `#DIV/0!` from significance 309, and rounds to 15 significant digits for significance 15–308 |
|
||||||
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
|
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
|
||||||
| Localized function names and `;` separators | **missing** (#45); en-US only |
|
| Localized function names and `;` separators | **missing** (#45); en-US only |
|
||||||
| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them |
|
| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them |
|
||||||
@@ -98,6 +101,12 @@ This improves series preparation; it does not change the model-fitting or season
|
|||||||
|
|
||||||
## Depth (estimated)
|
## Depth (estimated)
|
||||||
|
|
||||||
|
| Function | Excel parity note |
|
||||||
|
|---|---|
|
||||||
|
| BINOM.DIST / BINOM.DIST.RANGE | Trial count is truncated, then must be at most 2,147,483,646; larger trials return `#NUM!`. |
|
||||||
|
| BETA.DIST | Density mode returns `#NUM!` at the lower or upper interval endpoint when the corresponding shape parameter is less than or equal to 1; cumulative endpoint results remain 0 and 1. |
|
||||||
|
| HARMEAN | Returns `#N/A` when arrays and references contain no numeric observations; zero and negative numeric observations still return `#NUM!`. |
|
||||||
|
|
||||||
**~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many
|
**~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many
|
||||||
assertions each, but there is no conformance suite against Excel-computed results, and user
|
assertions each, but there is no conformance suite against Excel-computed results, and user
|
||||||
reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range
|
reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range
|
||||||
@@ -124,6 +133,24 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid |
|
||||||
|
| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel |
|
||||||
|
| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel |
|
||||||
|
| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel |
|
||||||
|
| 2026-10-31 | minor | UPPER, LOWER and PROPER use single-character casing: `UPPER("Straße")` keeps ß, `LOWER("İ")` returns i, and `PROPER("İSTANBUL")` returns Istanbul, matching Windows Excel 16.0 build 20527 in LCID 1033 |
|
||||||
|
| 2026-10-31 | minor | FIXED and DOLLAR format the rounded number's shortest decimal representation before padding requested fractional zeroes, so 1.1 at 16 places and 1.005 at 20 places do not expose binary noise; measured in Windows Excel 16.0 build 20527, LCID 1033 |
|
||||||
|
| 2026-10-31 | minor | FIXED accepts output through 255 characters; DOLLAR accepts output through 254. Windows Excel 16.0 build 20527 (LCID 1033) confirmed the distinct cutoffs with grouped and ungrouped values, signs, currency layout and decimal places |
|
||||||
|
| 2026-10-31 | minor | DOLLAR uses the input sign for negative amounts that round to zero at nonnegative decimal places (`DOLLAR(-0.0001,0)` is `($0)`); when negative decimal places round to zero, Excel returns `$0`. Exact zero and negative zero stay unsigned; FIXED still returns `0` for `FIXED(-0.0001,0)` |
|
||||||
|
| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail, with a gamma-tail limit when numerator degrees dominate, to preserve inverse accuracy; values above the limit remain invalid |
|
||||||
|
| 2026-10-31 | minor | T.DIST returns `#DIV/0!` in density mode and `#NUM!` in cumulative mode when degrees truncate to zero; negative integer degrees remain `#NUM!` |
|
||||||
|
| 2026-10-31 | minor | Student-t density uses an asymptotic log-gamma ratio for large degrees of freedom, avoiding cancellation while preserving bounded work; checked against Excel at 100 million and 10 billion degrees |
|
||||||
|
| 2026-10-31 | minor | T.INV probability zero returns `#DIV/0!`, while other out-of-range probabilities and invalid degrees remain `#NUM!`; T.INV.2T keeps its separate boundary behavior |
|
||||||
|
| 2026-10-31 | minor | GAMMA.DIST density at x=0 and shape 1 returns `#NUM!` for all positive scales; the cumulative branch and other shape boundaries are unchanged |
|
||||||
|
| 2026-10-31 | minor | WEIBULL.DIST returns zero at x=0 in both density and cumulative modes, including shape below one; negative x remains `#NUM!` |
|
||||||
|
| 2026-10-31 | minor | BINOM.DIST and BINOM.DIST.RANGE reject trial counts above 2,147,483,646 after truncation |
|
||||||
|
| 2026-10-31 | minor | BETA.DIST density returns `#NUM!` at the lower endpoint with alpha less than or equal to 1 and upper endpoint with beta less than or equal to 1; cumulative behavior is unchanged |
|
||||||
|
| 2026-10-31 | minor | HARMEAN returns `#N/A` when array or reference inputs contain no numbers; nonpositive numeric observations remain `#NUM!` |
|
||||||
|
| 2026-10-31 | minor | YEARFRAC basis 0 tests the end-31 rule against the original start day; MOD returns `#NUM!` from a quotient of 1.1259E12; PERCENTRANK returns `#DIV/0!` from significance 309 and 15 significant digits from 15 (Excel 16) |
|
||||||
| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change |
|
| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change |
|
||||||
| 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open |
|
| 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open |
|
||||||
| 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` |
|
| 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` |
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Release playbook
|
|
||||||
|
|
||||||
The release playbook shared by every crafting app lives in craftrules:
|
|
||||||
[`../craftrules/release/playbook.md`](../../../craftrules/release/playbook.md)
|
|
||||||
(`storytold/craftrules`, `release/playbook.md`). It is the canonical copy; don't duplicate it
|
|
||||||
here. The signing runbook is next to it in `release/signing-setup.md`.
|
|
||||||
|
|
||||||
GridCraft's copy was taken from DesignCraft (`.github/workflows/release.yml`, `packaging/`,
|
|
||||||
`cargo xtask version`). GridCraft-specific details are in [releasing.md](releasing.md).
|
|
||||||
+22
-24
@@ -5,8 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o
|
|||||||
**draft** GitHub Release named `GridCraft v<version>`. Nobody sees a draft until a maintainer
|
**draft** GitHub Release named `GridCraft v<version>`. Nobody sees a draft until a maintainer
|
||||||
publishes it.
|
publishes it.
|
||||||
|
|
||||||
This is GridCraft's implementation of the shared [release playbook](release-playbook.md)
|
This process was copied from DesignCraft and renamed. User-facing names say
|
||||||
(craftrules `release/playbook.md`), copied from DesignCraft and renamed. User-facing names say
|
|
||||||
**GridCraft**. Files, binaries and ids stay lowercase
|
**GridCraft**. Files, binaries and ids stay lowercase
|
||||||
(`gridcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.gridcraft`).
|
(`gridcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.gridcraft`).
|
||||||
|
|
||||||
@@ -19,25 +18,24 @@ This is GridCraft's implementation of the shared [release playbook](release-play
|
|||||||
| Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` |
|
| Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` |
|
||||||
| App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `<branding>` |
|
| App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `<branding>` |
|
||||||
|
|
||||||
## Prerequisites still owned by the app crates
|
## Application release prerequisites
|
||||||
|
|
||||||
The pipeline references crates that don't exist yet; the first release run fails until they do:
|
The `apps/gridcraft` and `apps/gridcraft-web` crates are workspace members, and
|
||||||
|
`apps/gridcraft-cli` supports `--version`. The `cargo xtask` alias is defined in
|
||||||
|
`.cargo/config.toml`. The current app-specific release setup includes:
|
||||||
|
|
||||||
- **`apps/gridcraft`** (the GUI binary `gridcraft`) and **`apps/gridcraft-web`** (trunk
|
- **Windows resources:** `apps/gridcraft/build.rs` embeds the icon and version information with
|
||||||
site) must be workspace members. `apps/gridcraft-cli` exists and supports `--version`.
|
`winresource` (instructions at the top of the file). `package.ps1` sets
|
||||||
- **Windows resources:** move `packaging/windows/app-build.rs.in` to `apps/gridcraft/build.rs`
|
`GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon fails, and it checks that
|
||||||
and add `winresource = "0.1"` under `[build-dependencies]` (instructions at the top of the
|
`gridcraft.exe` is a GUI-subsystem binary. `apps/gridcraft/src/main.rs` sets
|
||||||
file). `package.ps1` sets `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon
|
`windows_subsystem = "windows"` for release builds.
|
||||||
fails, and it checks that `gridcraft.exe` is a GUI-subsystem binary, so `main.rs` needs
|
- **Version information:** `gridcraft --version`, `gridcraft-cli --version` and the About dialog
|
||||||
`#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]`.
|
use `env!("CARGO_PKG_VERSION")` from `[workspace.package]`. `packaging/env.sh` also exports
|
||||||
- **Build info:** CI exports `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE`; read them
|
`GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE` for artifact metadata.
|
||||||
with `option_env!` (fallback: "dev build") for `--version` and *Help › About GridCraft*.
|
- **Runtime icon:** `apps/gridcraft/src/main.rs` embeds
|
||||||
- **Runtime icon:** embed `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and
|
`assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and
|
||||||
`hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and
|
`hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and
|
||||||
use `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`.
|
uses `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`.
|
||||||
- **`cargo xtask` alias:** the docs use `cargo xtask …`, which needs
|
|
||||||
`.cargo/config.toml` with `[alias] xtask = "run --package xtask --"`. Until it exists, use
|
|
||||||
`cargo run -p xtask -- …`. The workflows already call `cargo run -p xtask` directly.
|
|
||||||
|
|
||||||
## Cutting a release
|
## Cutting a release
|
||||||
|
|
||||||
@@ -224,7 +222,7 @@ shows a `::warning::`. Never store these as org- or repo-level Actions secrets (
|
|||||||
|
|
||||||
`GITHUB_TOKEN` creates the release; only the final job gets `contents: write`.
|
`GITHUB_TOKEN` creates the release; only the final job gets `contents: write`.
|
||||||
|
|
||||||
The exact commands (from craftrules `release/signing-setup.md`, steps 1–3), run by an org
|
The repository setup commands, run by an org
|
||||||
admin. Done for storytold/gridcraft on 2026-10-07; the `release-managers` team also has write
|
admin. Done for storytold/gridcraft on 2026-10-07; the `release-managers` team also has write
|
||||||
access to the repo, which its ruleset bypass needs.
|
access to the repo, which its ruleset bypass needs.
|
||||||
|
|
||||||
@@ -274,8 +272,7 @@ the publisher.
|
|||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
`assets/app-icon/gridcraft.svg` is a **placeholder** (an original ledger-page drawing on
|
`assets/app-icon/gridcraft.svg` is a **placeholder** (an original ledger-page drawing on
|
||||||
GridCraft green) until the owner makes GridCraft's engraved creature portrait (craftrules
|
GridCraft green) until the owner makes GridCraft's engraved creature portrait. `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px
|
||||||
`standards/icon-design.md`). `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px
|
|
||||||
PNG, the `.icns` (iconutil), the `.ico` (`cargo xtask ico`) and the hicolor PNGs from it; it
|
PNG, the `.icns` (iconutil), the `.ico` (`cargo xtask ico`) and the hicolor PNGs from it; it
|
||||||
needs `resvg`. The outputs are committed, so packaging never needs those tools. Every file has a
|
needs `resvg`. The outputs are committed, so packaging never needs those tools. Every file has a
|
||||||
row in `ATTRIBUTION.md` (`cargo xtask assets`).
|
row in `ATTRIBUTION.md` (`cargo xtask assets`).
|
||||||
@@ -289,7 +286,8 @@ row in `ATTRIBUTION.md` (`cargo xtask assets`).
|
|||||||
installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner.
|
installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner.
|
||||||
- `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`).
|
- `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`).
|
||||||
|
|
||||||
**Only the `release` branch builds.** No build workflow runs on `main` or pull requests; every
|
**CI and release builds.** `.github/workflows/ci.yml` runs the shared `cargo xtask ci` gate on
|
||||||
build job checks `github.ref == 'refs/heads/release'`, so a manual run from another branch is
|
every push to `main` and every pull request. Release artifacts are built on `release` pushes or
|
||||||
skipped. Only `packaging-lint.yml` (no build, no secrets) runs on `main` and PRs.
|
manually; manual runs from other branches are dry runs as described above. `packaging-lint.yml`
|
||||||
|
also runs on `main` and PRs.
|
||||||
- Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check).
|
- Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check).
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
# UI parity
|
# UI parity
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||||
|
|
||||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||||
@@ -194,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text |
|
||||||
| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl |
|
| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl |
|
||||||
| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit |
|
| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit |
|
||||||
| 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift |
|
| 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ runtime-version: '25.08'
|
|||||||
sdk: org.freedesktop.Sdk
|
sdk: org.freedesktop.Sdk
|
||||||
command: gridcraft
|
command: gridcraft
|
||||||
finish-args:
|
finish-args:
|
||||||
|
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
@@ -40,6 +41,11 @@ finish-args:
|
|||||||
- --filesystem=xdg-documents
|
- --filesystem=xdg-documents
|
||||||
- --filesystem=xdg-download
|
- --filesystem=xdg-download
|
||||||
# Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted.
|
# Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted.
|
||||||
|
# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs
|
||||||
|
# on cups-client's unstripped tools, in whichever module comes first after they're installed. The
|
||||||
|
# app's own binaries come from the release tarball, already stripped.
|
||||||
|
build-options:
|
||||||
|
no-debuginfo: true
|
||||||
modules:
|
modules:
|
||||||
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
|
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
|
||||||
# Linked statically against this release's libcups, so a runtime update can't break them;
|
# Linked statically against this release's libcups, so a runtime update can't break them;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ sdk-extensions:
|
|||||||
- org.freedesktop.Sdk.Extension.rust-stable
|
- org.freedesktop.Sdk.Extension.rust-stable
|
||||||
command: gridcraft
|
command: gridcraft
|
||||||
finish-args:
|
finish-args:
|
||||||
|
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
|
|||||||
Reference in New Issue
Block a user