Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 981fad14e0 Merge pull request #3012 from aiongg/docs-tidy
docs: tidy agent, contributor and asset docs
2026-10-31 01:06:39 -04:00
Dylan M. Taylor 7fcf4232b7 Merge pull request #3013 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:49 -04:00
aiongg 79722c7666 updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:26 +00:00
aiongg 2329904024 docs: tidy wording 2026-10-31 04:25:43 +00:00
aiongg 5f7137ca01 docs: tidy agent, contributor and asset docs 2026-10-31 04:17:38 +00:00
Brandon ThomasandClaude Opus 5.5 55850841d3 Release 0.14.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:30 -04:00
Brandon ThomasandClaude Opus 5.5 1f73f62c4f Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:28 -04:00
Brandon Thomas fb1e59ff61 Merge remote-tracking branch 'origin/main' into land-1030b 2026-10-30 23:16:31 -04:00
Brandon ThomasandClaude Opus 5.5 8f59487cbe Release 0.14.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:39 -04:00
Brandon ThomasandClaude Opus 5.5 71d4e2311d Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:37 -04:00
Brandon Thomas 213fe1150e Review fixes: one Depth table for the BINOM, BETA and HARMEAN notes; summarise the function-parity header 2026-10-30 23:07:11 -04:00
Brandon Thomas 0db7d97f2e Merge PR #3008
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 4d1233d0a2 Merge PR #3007
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 07c8201786 Merge PR #3006
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 31d5cc5b41 Merge PR #3003
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas c0fcc7de16 Merge PR #3002
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 5390e230d8 Merge PR #3001
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas c9eb797d8b Merge PR #3000
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas 5441691ca3 Merge PR #2999
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:04:26 -04:00
Brandon Thomas d3f54debd9 Merge PR #2998
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:04:10 -04:00
Brandon Thomas 64ea0cb69c Merge PR #2997
# Conflicts:
#	crates/functions/src/text.rs
#	docs/function-parity.md
2026-10-30 23:04:01 -04:00
Brandon Thomas f887ecf1cf Merge PR #2996
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas ae598a74fe Merge PR #2995
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas bc0fffbe97 Merge PR #2994
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:40 -04:00
Brandon Thomas 1ebef5a694 Merge PR #2992
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:39 -04:00
Brandon Thomas f812c53814 Merge PR #2991
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas b775b3ce32 Merge PR #2990
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas 76aed738e3 Merge PR #2989 2026-10-30 23:03:37 -04:00
Brandon Thomas a44d3d5ee5 Merge PR #2982 2026-10-30 23:03:36 -04:00
Brandon Thomas 2bf5dbac84 Merge PR #2983 2026-10-30 23:03:36 -04:00
Brandon Thomas c269ff483d Merge PR #2993 2026-10-30 23:03:35 -04:00
Brandon ThomasandClaude Opus 5.5 15f6297001 Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:24 -04:00
Harrison Booth 006b7962bd Preserve F distribution tail accuracy 2026-10-30 21:14:19 -05:00
Harrison Booth 7ea3a4c2c3 Merge upstream main into harmonic mean branch 2026-10-30 20:59:56 -05:00
Harrison Booth cae0c9abdb Merge main into function updates 2026-10-30 20:59:41 -05:00
Harrison Booth ed81d7e34e Merge upstream main into binomial distribution branch 2026-10-30 20:59:39 -05:00
Harrison Booth 8f7dff0d04 Merge main into function updates 2026-10-30 20:59:31 -05:00
Harrison Booth 7b7e734524 Merge main into function updates 2026-10-30 20:59:20 -05:00
Harrison Booth 6d5ab57f84 Merge upstream main into Weibull distribution branch 2026-10-30 20:59:18 -05:00
Harrison Booth a04629576d Merge main into function updates 2026-10-30 20:59:12 -05:00
Harrison Booth 3174020a70 Merge main into function updates 2026-10-30 20:59:02 -05:00
Harrison Booth 781149a593 Merge main into function updates 2026-10-30 20:58:53 -05:00
Harrison Booth 322298b202 Merge upstream main into gamma distribution branch 2026-10-30 20:58:48 -05:00
Harrison Booth 11d6ff1b79 Merge main into function updates 2026-10-30 20:58:38 -05:00
Harrison Booth eefa7c4a54 Merge main into function updates 2026-10-30 20:58:24 -05:00
Harrison Booth 45939a8a41 Merge remote-tracking branch 'upstream/main' into gc-build-g718x
# Conflicts:
#	docs/function-parity.md
2026-10-30 20:56:57 -05:00
Harrison Booth 70da8bb3ae Cover strict upper tail in F inverse 2026-10-30 20:53:15 -05:00
Harrison Booth a0da53f576 Match T DIST zero degree errors by mode 2026-10-30 20:53:00 -05:00
Harrison Booth ca44a5d68f Avoid overflow in t density normalization 2026-10-30 20:52:05 -05:00
Harrison Booth 3b61d660e9 Return NUM for invalid T distribution degrees 2026-10-30 20:51:47 -05:00
Harrison Booth cca8a58bfa Stabilize F distribution CDF at extreme degrees 2026-10-30 20:51:07 -05:00
Harrison Booth d3cd393dba G719: return NA for empty harmonic mean input 2026-10-30 20:25:09 -05:00
Harrison Booth 8985685833 G718: match beta density endpoint errors 2026-10-30 20:24:21 -05:00
Harrison Booth 962a5df841 G717: cap binomial trial counts 2026-10-30 20:23:32 -05:00
Harrison Booth fea4c03ca2 G709: use stable tail at F median 2026-10-30 20:18:56 -05:00
Harrison Booth 1d9096e5ec G709: stabilize F upper tail at extreme degrees 2026-10-30 20:05:47 -05:00
Harrison Booth 8e740d97e1 G707: stabilize decimal boundary cases 2026-10-30 19:58:17 -05:00
Harrison Booth a9bacdca4c G711: keep T.DIST validation isolated 2026-10-30 19:56:36 -05:00
Cédric Legrand 53d6de3c16 docs(releasing): M13: correct outdated prerequisites 2026-10-31 01:49:44 +01:00
Harrison Booth 6aa2cb8223 G709: invert upper F probabilities through complementary tail 2026-10-30 19:49:32 -05:00
Harrison Booth 83e6408b71 G707: align signed output boundary regression 2026-10-30 19:47:30 -05:00
Harrison Booth 1b1224c67a G713: simplify zero-shape boundary 2026-10-30 19:42:50 -05:00
Harrison Booth 213b76af10 Expand bit shift boundary regressions 2026-10-30 19:39:26 -05:00
Harrison Booth 4e5c86d842 G711: preserve small density term at huge degrees 2026-10-30 19:39:05 -05:00
Harrison Booth ad69d4cbdd G710: update zero-degree test error 2026-10-30 19:39:00 -05:00
Harrison Booth 32dbf6a224 G705: fix single-character case conversions 2026-10-30 19:37:03 -05:00
Harrison Booth 11b54ebe40 G708: retain dollar sign when rounding to zero 2026-10-30 19:36:23 -05:00
Harrison Booth 9e710199b1 Move base source parity note to history 2026-10-30 19:36:06 -05:00
Harrison Booth 5ee65a8005 Tighten UNICHAR regressions and parity history 2026-10-30 19:35:50 -05:00
Harrison Booth 48c6b57060 G714: return zero Weibull density at zero 2026-10-30 19:34:59 -05:00
Harrison Booth ec2efe3246 G713: correct gamma density at zero 2026-10-30 19:34:36 -05:00
Harrison Booth 5ed6a1b9c7 G707: enforce fixed and dollar output limits 2026-10-30 19:34:32 -05:00
Harrison Booth 5d89b9efdf Move bit shift parity note to revision history 2026-10-30 19:34:19 -05:00
Harrison Booth f1ff38504a G712: return division error for zero T inverse probability 2026-10-30 19:34:12 -05:00
Harrison Booth 0435ff50f9 G711: stabilize large-degree t density 2026-10-30 19:33:50 -05:00
Harrison Booth 550085f8b8 G710: return division error for zero T degrees 2026-10-30 19:33:10 -05:00
Harrison Booth 3a27743a38 Move places parity note to revision history 2026-10-30 19:32:48 -05:00
Harrison Booth dc691d52db G709: accept F distribution boundary degrees 2026-10-30 19:32:22 -05:00
Harrison Booth bd8cf4e14a G706: avoid binary noise in fixed decimal text 2026-10-30 19:32:19 -05:00
Harrison Booth dc75555ee9 G705: match Excel single-character casing 2026-10-30 19:30:32 -05:00
Harrison Booth 4d725e6bc1 Reject Unicode noncharacters in UNICHAR 2026-10-30 19:24:46 -05:00
Harrison Booth 9fccec28af Handle zero-valued oversized bit shifts 2026-10-30 19:24:22 -05:00
Harrison Booth 52d317c8ff Validate places for negative base conversions 2026-10-30 19:23:51 -05:00
Harrison Booth 00359ee93a Merge current main into G701 branch 2026-10-30 19:23:09 -05:00
Harrison Booth c895acf1f7 Fix base conversion whitespace validation 2026-10-30 19:22:39 -05:00
Dylan M. Taylor 1f4c67ef0a fix(flatpak): allow reading launcher version pins (#2985)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:32 -04:00
Michael HatzandClaude Opus 5.5 8cf8425745 Print checkbox test: check that the unchecked box is outlined
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:52:22 +01:00
NetterSilverandClaude Haiku 5.5 59a8b14d4d Print structured refs to columns starting with @ in double brackets
Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 22:26:49 +00:00
Michael HatzandClaude Opus 5.5 8f93ba2e9c Print checkbox cells as boxes, not TRUE/FALSE (#2972)
A checkbox cell drew a box on screen but printed and exported to PDF
its value as TRUE or FALSE. The print path now draws the box as the
grid does: white with a grey outline when unchecked, green with a
white tick when checked. Other values in a checkbox cell still print
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:14:20 +01:00
32 changed files with 522 additions and 181 deletions
+5 -10
View File
@@ -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
View File
@@ -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") |
+4 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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

+2 -5
View File
@@ -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"
+36 -2
View File
@@ -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,
+16
View File
@@ -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");
}
+2 -2
View File
@@ -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));
+9
View File
@@ -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}");
}
}
+56 -7
View File
@@ -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]
+4 -1
View File
@@ -420,7 +420,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>())
+40 -14
View File
@@ -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() })
} }
+102
View File
@@ -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);
@@ -360,6 +373,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 +411,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 +419,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 +484,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 +531,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 +557,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
View File
@@ -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");
+1 -1
View File
@@ -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
+3 -4
View File
@@ -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 -2
View File
@@ -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;
+19 -10
View File
@@ -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 => {}
}
}
} }
+4 -21
View File
@@ -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.
+1 -1
View File
@@ -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 |
+24 -1
View File
@@ -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 (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).
@@ -98,6 +98,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 +130,23 @@ 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-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?` |
-9
View File
@@ -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
View File
@@ -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
View File
@@ -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