Compare commits

...
408 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 d9d09f0baa Release 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:33:30 -04:00
Brandon Thomas 84acf6293b Merge pull request #848 from marcoarnulfo:feat/cell-autocomplete 2026-10-13 02:04:03 -04:00
Brandon ThomasandClaude Opus 5.5 4c70a46377 i18n: Enable AutoComplete for cell values in every catalog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:04:02 -04:00
Brandon Thomas f4bd752bdc Merge main into #848 2026-10-13 02:03:38 -04:00
Brandon ThomasandClaude Opus 5.5 589b59defc Docs: one Last updated line per doc after the merges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:02:41 -04:00
Brandon Thomas fefe3a0c27 Merge pull request #463 from arcanii:recent-modified
# Conflicts:
#	docs/ui-parity.md
2026-10-13 02:02:30 -04:00
Brandon Thomas 3753d7cf08 Merge pull request #855 from edurbs:row-autofit-833 2026-10-13 01:52:13 -04:00
Brandon ThomasandClaude Opus 5.5 a8cd6f4b63 Drop the planning notes from the PR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:52:12 -04:00
Brandon Thomas ebe0138d30 Merge main into #855
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-13 01:52:08 -04:00
Bryan MarkandClaude Opus 5.5 11838c58c6 Merge branch 'main' into recent-modified
Brings the branch up to upstream's main (59 more commits). One conflict:

- docs/ui-parity.md: every line of both sides is kept, as upstream's own
  merges keep them, in the order of their dates, newest first (upstream
  has a change of the 13th now, which goes above this one of the 12th).

Gate: all eight steps, 1580 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:48:48 +09:00
Brandon Thomas f353ec7fa4 Merge pull request #863 from marcoarnulfo:fix/xlsx-xml-maps 2026-10-13 01:40:22 -04:00
Brandon Thomas be003d44af Merge main into #863
# Conflicts:
#	crates/xlsx/src/tests/passthrough_tests.rs
2026-10-13 01:40:22 -04:00
Brandon Thomas a4ca87ab2a Merge pull request #862 from marcoarnulfo:fix/pivot-fields-pane-i18n 2026-10-13 01:40:11 -04:00
Brandon Thomas 72e7590c8c Merge main into #862
# Conflicts:
#	crates/ui-egui/tests/interaction.rs
2026-10-13 01:40:11 -04:00
Brandon Thomas 884585a344 Merge pull request #463 from arcanii:recent-modified 2026-10-13 01:40:00 -04:00
Brandon Thomas 08cbb69585 Merge main into #463
# Conflicts:
#	docs/ui-parity.md
2026-10-13 01:40:00 -04:00
Brandon Thomas 80d282e7a5 Merge pull request #752 from marcoarnulfo:fix/function-excel-diffs-738 2026-10-13 01:39:44 -04:00
Brandon Thomas 5144bd5b17 Merge pull request #742 from marcoarnulfo:fix/edate-eomonth-1900 2026-10-13 01:39:43 -04:00
Brandon ThomasandClaude Opus 5.5 8108dbccc6 Fix-up: close the passthrough tests the merges left open; rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:57 -04:00
Brandon Thomas 8272c991b9 Merge pull request #854 from derechopiano:fix/data-validation-dialog 2026-10-13 01:17:15 -04:00
Brandon Thomas a2bf1b3ad5 Merge main into #854
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-13 01:17:14 -04:00
Brandon Thomas e2a6aacf78 Merge #849 fix-up 2026-10-13 01:16:10 -04:00
Brandon ThomasandClaude Opus 5.5 77f5799bf1 Test: drop the unused Color32 import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:16:10 -04:00
Brandon Thomas 6503df5411 Merge #849 fix-up 2026-10-13 01:13:38 -04:00
Brandon ThomasandClaude Opus 5.5 99f202b18a Tab bar: the scrollbar-reachable test checks #849's scrollbar track; drop the unused Color32 import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:13:38 -04:00
Brandon Thomas 2b36221890 Merge pull request #849 from marcoarnulfo:feat/horizontal-scrollbar 2026-10-13 01:07:18 -04:00
Brandon Thomas 4536bc7fc8 Merge main into #849
# Conflicts:
#	crates/ui-egui/src/grid.rs
#	crates/ui-egui/src/tabs.rs
#	docs/ui-parity.md
2026-10-13 01:07:17 -04:00
Brandon Thomas ebea0c94d1 Merge #811 test fix 2026-10-13 01:06:20 -04:00
Brandon ThomasandClaude Opus 5.5 afb6eca31f Test: drive the linear-time parse check through the uncapped parser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:06:20 -04:00
Brandon Thomas aa02516162 Merge pull request #811 from vangel76:fix/numfmt-parse-linear 2026-10-13 01:05:46 -04:00
Brandon Thomas be45ccab31 Merge main into #811
# Conflicts:
#	ROADMAP.md
#	crates/numfmt/src/parse.rs
#	crates/numfmt/src/tests.rs
2026-10-13 01:05:45 -04:00
Brandon Thomas 0703575b04 Merge #809 test fix 2026-10-13 01:05:06 -04:00
Brandon ThomasandClaude Opus 5.5 499bf2c0a7 Test: the uncached long TEXT() code stays under the 255-character cap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:05:06 -04:00
Brandon Thomas 8d1898866c Merge pull request #809 from vangel76:fix/numfmt-fraction-width-cap 2026-10-13 01:04:29 -04:00
Brandon Thomas 0c488f79b3 Merge main into #809
# Conflicts:
#	ROADMAP.md
#	crates/numfmt/src/lib.rs
#	crates/numfmt/src/tests.rs
#	crates/xlsx/src/tests/malformed.rs
2026-10-13 01:04:29 -04:00
Brandon Thomas a9a6a3e247 Merge pull request #780 from vangel76:fix/hyperlink-scheme-777 2026-10-13 01:02:29 -04:00
Brandon Thomas 2d0a3f8a47 Merge main into #780
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/insert.rs
#	crates/engine/src/tests.rs
2026-10-13 01:02:28 -04:00
Brandon Thomas 61266958ff Merge pull request #766 from vangel76:fix/multi-row-delete-758 2026-10-13 01:01:00 -04:00
Brandon Thomas 20d6923cb6 Merge main into #766
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/sheet.rs
2026-10-13 01:00:59 -04:00
Brandon Thomas c6ab899259 Merge #824 fix-up 2026-10-13 00:57:02 -04:00
Brandon ThomasandClaude Opus 5.5 20309f6e99 Fix-up: close the Save As link test the merge left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:57:02 -04:00
Brandon Thomas 9c773166f9 Merge #594 fix-up 2026-10-13 00:55:57 -04:00
Brandon ThomasandClaude Opus 5.5 623997a19e Fix-up: close the CSV history-name test the merge left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:55:57 -04:00
Brandon Thomas 3a800f2a5f Merge pull request #824 from marcoarnulfo:fix/xlsx-rebase-external-link-paths 2026-10-13 00:53:42 -04:00
Brandon Thomas 9972a75f10 Merge main into #824
# Conflicts:
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/tests.rs
#	crates/xlsx/src/tests/passthrough_tests.rs
2026-10-13 00:53:41 -04:00
Brandon Thomas 699f594718 Merge pull request #830 from bmh129:ui/view-show-group-columns 2026-10-13 00:53:06 -04:00
Brandon Thomas b3ad57982e Merge main into #830
# Conflicts:
#	crates/ui-egui/src/ribbon.rs
2026-10-13 00:53:05 -04:00
Brandon Thomas 84ad5a23cb Merge pull request #856 from Silvertree2010:fix/xlsx-chart-sheets 2026-10-13 00:52:58 -04:00
Brandon Thomas 3c4556bb04 Merge main into #856
# Conflicts:
#	ROADMAP.md
#	crates/model/src/lib.rs
#	crates/xlsx/src/tests/passthrough_tests.rs
#	docs/file-format-parity.md
2026-10-13 00:52:57 -04:00
Bryan MarkandClaude Opus 5.5 b112b56424 Merge branch 'main' into recent-modified
Brings the branch up to upstream's main (180 more commits). One conflict,
and one thing the merge needed that was no conflict:

- docs/ui-parity.md: every line of both sides is kept, as upstream's own
  merges keep them (its heading has three lines now); this change's
  heading line and history row come first.
- crates/ui-egui/src/i18n/pt.rs: upstream's Portuguese catalog gained a
  "Yesterday" of its own, with the same word ("Ontem"). The one this change
  added is left out: a second arm for the same text is unreachable.

Gate: all eight steps, 1548 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:50:13 +09:00
Brandon Thomas e381a8fd01 Merge pull request #600 from MarekSpodBiedry:issue-564 2026-10-13 00:49:40 -04:00
Brandon Thomas cc0b7c18d1 Merge main into #600
# Conflicts:
#	crates/calc/src/eval.rs
2026-10-13 00:49:39 -04:00
Brandon Thomas cba08d1b4b Merge pull request #690 from vedran-dobos:zoom-wheel-ladder 2026-10-13 00:48:42 -04:00
Brandon Thomas fe26486eb5 Merge main into #690
# Conflicts:
#	crates/ui-egui/src/grid.rs
#	docs/ui-parity.md
2026-10-13 00:48:41 -04:00
Brandon Thomas 181915ee57 Merge pull request #649 from MarekSpodBiedry:issue-479 2026-10-13 00:48:39 -04:00
Brandon Thomas 0068c8f517 Merge main into #649
# Conflicts:
#	crates/ui-egui/src/grid.rs
2026-10-13 00:48:39 -04:00
Brandon Thomas 387a3a3150 Merge pull request #594 from MarekSpodBiedry:issue-570 2026-10-13 00:48:37 -04:00
Brandon Thomas b464a7f242 Merge main into #594
# Conflicts:
#	crates/engine/src/io.rs
2026-10-13 00:48:36 -04:00
Brandon Thomas 4f7f67f65d Merge pull request #593 from MarekSpodBiedry:issue-571 2026-10-13 00:48:35 -04:00
Brandon Thomas c888e81d71 Merge main into #593
# Conflicts:
#	crates/functions/src/text.rs
2026-10-13 00:48:34 -04:00
Brandon Thomas 7357a90c6b Merge pull request #591 from MarekSpodBiedry:issue-573 2026-10-13 00:42:00 -04:00
Brandon Thomas 2f33d43546 Merge main into #591
# Conflicts:
#	crates/engine/src/cmd/data.rs
2026-10-13 00:41:59 -04:00
Brandon ThomasandClaude Opus 5.5 6405b00cf7 Fix-up: repair test files after merging the conflicting PRs
- restore closing braces the line-level merges of side-by-side tests dropped
- new ChartData literals carry the palette field
- the filter-menu shortcut passes the row with the column
- #688's tab tests account for #851's All Sheets button
- Serbian margins for the ruler-margin labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:34:01 -04:00
Marco Arnulfo c7cb91ea53 Merge remote-tracking branch 'origin/main' into fix/xlsx-xml-maps 2026-10-13 06:33:03 +02:00
Marco Arnulfo 638cbab9a5 Merge remote-tracking branch 'origin/main' into fix/edate-eomonth-1900
# Conflicts:
#	crates/functions/src/date.rs
2026-10-13 06:29:36 +02:00
Marco Arnulfo ca8fc73281 Merge remote-tracking branch 'origin/main' into fix/function-excel-diffs-738
# Conflicts:
#	crates/calc/src/eval.rs
2026-10-13 06:29:34 +02:00
Marco Arnulfo 6de9cfe4e1 Merge remote-tracking branch 'origin/main' into fix/pivot-fields-pane-i18n 2026-10-13 06:24:55 +02:00
Marco Arnulfo 13bc5ecd11 Merge remote-tracking branch 'origin/main' into feat/cell-autocomplete 2026-10-13 06:21:58 +02:00
Brandon Thomas 7e6aa698fa Merge pull request #871 from marcoarnulfo:fix/check-package-parts 2026-10-13 00:09:09 -04:00
Brandon Thomas 2174db2006 Merge main into #871
# Conflicts:
#	docs/cli.md
2026-10-13 00:09:07 -04:00
Brandon Thomas a6f8818794 Merge pull request #853 from Silvertree2010:fix/csv-export-budget 2026-10-13 00:09:01 -04:00
Brandon Thomas 94ee7efdf9 Merge main into #853
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
2026-10-13 00:08:58 -04:00
Brandon Thomas cbdb2cc794 Merge pull request #850 from marcoarnulfo:fix/enter-pastes-clipboard 2026-10-13 00:08:57 -04:00
Brandon Thomas 2ea8002002 Merge main into #850
# Conflicts:
#	crates/ui-egui/tests/clipboard.rs
2026-10-13 00:08:54 -04:00
Brandon Thomas 06203faa9d Merge pull request #825 from marcoarnulfo:fix/xls-external-name-cached-value 2026-10-13 00:08:51 -04:00
Brandon Thomas b2e2df3fc9 Merge main into #825
# Conflicts:
#	crates/xlsx/src/tests/xls_tests.rs
2026-10-13 00:08:49 -04:00
Brandon Thomas 33ad877b6d Merge pull request #823 from marcoarnulfo:fix/xlsx-keep-default-font 2026-10-13 00:08:47 -04:00
Brandon Thomas 62b7ba1e49 Merge main into #823
# Conflicts:
#	crates/xlsx/src/tests/roundtrip.rs
2026-10-13 00:08:44 -04:00
Brandon Thomas b869863699 Merge pull request #815 from vangel76:fix/pdf-api-robustness 2026-10-13 00:08:43 -04:00
Brandon Thomas 49cb977a73 Merge main into #815
# Conflicts:
#	ROADMAP.md
#	crates/pdf/src/tests.rs
2026-10-13 00:08:40 -04:00
Brandon Thomas be4d754ef4 Merge pull request #814 from vangel76:fix/pdf-info-strings-utf16 2026-10-13 00:08:39 -04:00
Brandon Thomas 5055373149 Merge main into #814
# Conflicts:
#	ROADMAP.md
2026-10-13 00:08:36 -04:00
Brandon Thomas 8e60c2af80 Merge pull request #808 from vangel76:perf/numfmt-text-parse-cache 2026-10-13 00:08:33 -04:00
Brandon Thomas d9e13a953f Merge main into #808
# Conflicts:
#	ROADMAP.md
2026-10-13 00:08:30 -04:00
Brandon Thomas 599fc0fe6d Merge pull request #796 from vangel76:fix/subtotal-hidden-recalc 2026-10-13 00:08:29 -04:00
Brandon Thomas 6dd8f7abe8 Merge main into #796
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/tests.rs
2026-10-13 00:08:27 -04:00
Brandon Thomas 0d4402ba51 Merge pull request #794 from vangel76:feat/ruler-margins-792 2026-10-13 00:08:25 -04:00
Brandon Thomas a67b5d2960 Merge main into #794
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-13 00:08:22 -04:00
Brandon Thomas 76927382bc Merge pull request #788 from brzuchal:format-cells-tr 2026-10-13 00:08:21 -04:00
Brandon Thomas 167ba9b370 Merge main into #788
# Conflicts:
#	docs/localization-parity.md
2026-10-13 00:08:18 -04:00
Brandon Thomas 8488b039a2 Merge pull request #786 from vangel76:feat/forecast-start-785 2026-10-13 00:08:17 -04:00
Brandon Thomas e5f31a02f3 Merge main into #786
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-10-13 00:08:15 -04:00
Brandon Thomas e897ebd93b Merge pull request #753 from marcoarnulfo:fix/cell-width-padding 2026-10-13 00:08:11 -04:00
Brandon Thomas d2bd177b98 Merge main into #753
# Conflicts:
#	crates/calc/src/tests.rs
2026-10-13 00:08:09 -04:00
Brandon Thomas 8b75c7c29f Merge pull request #751 from marcoarnulfo:fix/dget-empty-criteria-results 2026-10-13 00:08:07 -04:00
Brandon Thomas 773bd6c1ae Merge pull request #749 from marcoarnulfo:fix/structured-ref-column-spaces 2026-10-13 00:08:04 -04:00
Brandon Thomas 727bd37983 Merge main into #751
# Conflicts:
#	crates/calc/src/tests.rs
2026-10-13 00:08:04 -04:00
Brandon Thomas 04428cdfed Merge main into #749
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-13 00:08:01 -04:00
Brandon Thomas c8b01a6c37 Merge pull request #747 from marcoarnulfo:fix/xls-calc-iteration 2026-10-13 00:08:00 -04:00
Brandon Thomas 6b28a97f63 Merge main into #747
# Conflicts:
#	crates/xlsx/src/tests/xls_tests.rs
#	docs/file-format-parity.md
2026-10-13 00:07:57 -04:00
Brandon Thomas a8cb5feafd Merge pull request #712 from jonathanwxh-cell:fix/xlsx-ink-roundtrip 2026-10-13 00:07:52 -04:00
Brandon Thomas 29775397ef Merge main into #712
# Conflicts:
#	ROADMAP.md
#	crates/xlsx/src/tests/mod.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-13 00:07:50 -04:00
Brandon Thomas 8fa9a652bc Merge pull request #688 from derechopiano:fix/sheet-tab-navigation 2026-10-13 00:07:45 -04:00
Brandon Thomas 7ebda2525c Merge main into #688
# Conflicts:
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-13 00:07:42 -04:00
Brandon Thomas bf4c88f3db Merge pull request #659 from derechopiano:fix/autofilter-keyboard-access 2026-10-13 00:07:41 -04:00
Brandon Thomas bea16deba8 Merge main into #659
# Conflicts:
#	docs/ui-parity.md
2026-10-13 00:07:38 -04:00
Brandon Thomas 1d716e85b1 Merge pull request #615 from MarekSpodBiedry:issue-543 2026-10-13 00:07:36 -04:00
Brandon Thomas 7415d3b37d Merge main into #615
# Conflicts:
#	crates/chart/src/tests.rs
2026-10-13 00:07:34 -04:00
Brandon Thomas 378382b236 Merge pull request #614 from MarekSpodBiedry:issue-544 2026-10-13 00:07:33 -04:00
Brandon Thomas f075c63207 Merge main into #614
# Conflicts:
#	crates/chart/src/tests.rs
2026-10-13 00:07:31 -04:00
Brandon Thomas 94ccbcd048 Merge pull request #610 from MarekSpodBiedry:issue-550 2026-10-13 00:07:30 -04:00
Brandon Thomas 6a3b462176 Merge main into #610
# Conflicts:
#	crates/xlsx/src/tests/roundtrip.rs
2026-10-13 00:07:26 -04:00
Brandon Thomas c7f04be6d3 Merge pull request #597 from MarekSpodBiedry:issue-567 2026-10-13 00:07:24 -04:00
Brandon Thomas 7baa1664ac Merge main into #597
# Conflicts:
#	crates/formula/src/tests.rs
2026-10-13 00:07:22 -04:00
Brandon Thomas 2a443bb619 Merge pull request #585 from MarekSpodBiedry:issue-579 2026-10-13 00:07:19 -04:00
Brandon Thomas 588e289901 Merge main into #585
# Conflicts:
#	crates/chart/src/tests.rs
2026-10-13 00:07:17 -04:00
Brandon Thomas eb5f366786 Merge pull request #486 from nihilist-narwhal:fix-function-name-arguments 2026-10-13 00:07:16 -04:00
Brandon Thomas 0a0e5add96 Merge main into #486
# Conflicts:
#	crates/calc/src/tests.rs
#	crates/formula/src/tests.rs
2026-10-13 00:07:14 -04:00
Brandon Thomas 19362287bb Merge pull request #460 from neilcawse:fix/xls-array-formulas 2026-10-13 00:07:12 -04:00
Brandon Thomas ad11eb3109 Merge main into #460
# Conflicts:
#	docs/file-format-parity.md
2026-10-13 00:07:10 -04:00
Bryan MarkandClaude Opus 5.5 ed7681ae0c Merge branch 'main' into recent-modified
Brings the branch up to upstream's main (132 commits). Two conflicts, and
one thing the merge needed that was no conflict:

- crates/ui-egui/src/i18n/fr.rs: upstream rewrote the French catalog's
  lines this change sits among. Upstream's lines are kept, and the ten new
  entries are put back where they were, with the plain apostrophe the
  catalog now uses.
- docs/ui-parity.md: both changes are of the same day. Both rows of the
  history are kept, and the heading line names this change.
- crates/ui-egui/src/i18n/sr.rs: upstream gained a Serbian catalog, and
  every string must be in every catalog. The ten new strings are given
  their Serbian (Latin script, as the catalog is). Weeks, months and years
  are shortened, as in the Russian catalog: their endings change with the
  number.

Gate: all eight steps, 1415 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:56:54 +09:00
Brandon ThomasandClaude Opus 5.5 45221f5243 Fix-up: rustfmt extra.rs after merging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:51:11 -04:00
Brandon Thomas f0e158f089 Merge pull request #882 from CouchRiv:fix/check-bool-cached-as-number 2026-10-12 23:50:14 -04:00
Brandon Thomas 147db777b1 Merge pull request #879 from marcoarnulfo:fix/unknown-save-extension 2026-10-12 23:50:12 -04:00
Brandon Thomas d14269a001 Merge pull request #869 from marcoarnulfo:fix/this-row-structured-refs 2026-10-12 23:50:09 -04:00
Brandon Thomas 317df69d8c Merge pull request #851 from marcoarnulfo:fix/sheet-navigator 2026-10-12 23:49:59 -04:00
Brandon Thomas ad0370dada Merge pull request #836 from marcoarnulfo:fix/point-mode-shift-up 2026-10-12 23:49:55 -04:00
Brandon Thomas efed7caa01 Merge pull request #826 from marcoarnulfo:fix/xlsx-external-file-hyperlinks 2026-10-12 23:49:53 -04:00
Brandon Thomas a76759cba6 Merge pull request #822 from marcoarnulfo:fix/workbook-fileversion-calcfeatures 2026-10-12 23:49:47 -04:00
Brandon Thomas c9b36de54c Merge pull request #821 from marcoarnulfo:fix/xls-kept-value-string-result 2026-10-12 23:49:45 -04:00
Brandon Thomas 40166aff60 Merge pull request #773 from marcoarnulfo:fix/numfmt-scientific-exponent 2026-10-12 23:49:29 -04:00
Brandon Thomas 1e29e69b5a Merge pull request #767 from vangel76:fix/selection-border-687 2026-10-12 23:49:28 -04:00
Brandon Thomas d6bb4bdf7a Merge pull request #763 from msaulc:fix/ctrl-shift-arrow-scroll 2026-10-12 23:49:25 -04:00
Brandon Thomas d8561a9ed7 Merge pull request #755 from marcoarnulfo:fix/xludf-prefix 2026-10-12 23:49:23 -04:00
Brandon Thomas 83e9314835 Merge pull request #748 from marcoarnulfo:fix/cfb-short-last-sector 2026-10-12 23:49:16 -04:00
Brandon Thomas 0837cbc44d Merge pull request #746 from marcoarnulfo:fix/xls-format-short-string 2026-10-12 23:49:13 -04:00
Brandon Thomas 45973a6804 Merge pull request #745 from marcoarnulfo:fix/mode-typed-text-bool 2026-10-12 23:49:12 -04:00
Brandon Thomas 705543521e Merge pull request #744 from marcoarnulfo:fix/roman-forms-2-3 2026-10-12 23:49:10 -04:00
Brandon Thomas 70c84e20ba Merge pull request #741 from marcoarnulfo:fix/macos-window-menu 2026-10-12 23:49:08 -04:00
Brandon Thomas 291120a4cd Merge pull request #740 from marcoarnulfo:fix/numfmt-negative-zero 2026-10-12 23:49:06 -04:00
Brandon Thomas 5c9e4842d4 Merge pull request #739 from dantebertuzzi:i18n/pt-br-catalog 2026-10-12 23:49:05 -04:00
Brandon Thomas a435ef6ada Merge pull request #720 from dantebertuzzi:fix/cell-set-numeric-input 2026-10-12 23:49:02 -04:00
Brandon Thomas 98c85c6de4 Merge pull request #718 from neilcawse:fix/pivot-values-on-rows 2026-10-12 23:49:00 -04:00
Brandon Thomas 1830ea76b1 Merge pull request #715 from marcoarnulfo:fix/comments-panel-width 2026-10-12 23:48:58 -04:00
Brandon Thomas 2f38d0162f Merge pull request #704 from isaac-levine:file-export-pdf-menu 2026-10-12 23:48:55 -04:00
Brandon Thomas e548a2e705 Merge pull request #695 from marcoarnulfo:fix/formula-header-point 2026-10-12 23:48:52 -04:00
Brandon Thomas 1d875bdf45 Merge pull request #686 from neilcawse:fix/criteria-over-255 2026-10-12 23:48:47 -04:00
Brandon Thomas decc64a59b Merge pull request #683 from marcoarnulfo:fix/select-inserted-chart 2026-10-12 23:48:45 -04:00
Brandon Thomas 1d4806ff70 Merge pull request #679 from marcoarnulfo:fix/validation-tooltip-list 2026-10-12 23:48:44 -04:00
Brandon Thomas 89f9e24aad Merge pull request #664 from richard1912:fix/top10-filter-threshold 2026-10-12 23:48:43 -04:00
Brandon Thomas 4cc5ba05fd Merge pull request #652 from Losminek:cs-tooltips 2026-10-12 23:48:40 -04:00
Brandon Thomas 335106e867 Merge pull request #648 from MarekSpodBiedry:issue-507 2026-10-12 23:48:37 -04:00
Brandon Thomas 50853e3def Merge pull request #647 from MarekSpodBiedry:issue-508 2026-10-12 23:48:36 -04:00
Brandon Thomas b8e000faa6 Merge pull request #646 from MarekSpodBiedry:issue-509 2026-10-12 23:48:35 -04:00
Brandon Thomas 884b1d2b04 Merge pull request #645 from MarekSpodBiedry:issue-510 2026-10-12 23:48:33 -04:00
Brandon Thomas 1bba0ba35c Merge pull request #644 from MarekSpodBiedry:issue-511 2026-10-12 23:48:32 -04:00
Brandon Thomas 91138569fa Merge pull request #643 from MarekSpodBiedry:issue-512 2026-10-12 23:48:30 -04:00
Brandon Thomas 9c4314b070 Merge pull request #642 from MarekSpodBiedry:issue-513 2026-10-12 23:48:29 -04:00
Brandon Thomas f378bb9cc7 Merge pull request #641 from MarekSpodBiedry:issue-514 2026-10-12 23:48:27 -04:00
Brandon Thomas 02c973536b Merge pull request #640 from MarekSpodBiedry:issue-515 2026-10-12 23:48:26 -04:00
Brandon Thomas 8b845ebb6e Merge pull request #639 from MarekSpodBiedry:issue-516 2026-10-12 23:48:24 -04:00
Brandon Thomas 62100c2542 Merge pull request #638 from MarekSpodBiedry:issue-517 2026-10-12 23:48:23 -04:00
Brandon Thomas 6cddd36f48 Merge pull request #588 from MarekSpodBiedry:issue-576 2026-10-12 23:43:13 -04:00
Brandon ThomasandClaude Opus 5.5 9f9c0ac4a1 Test: open a workbook before setting values in the hyphenated table-name test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:43:11 -04:00
Brandon ThomasandClaude Opus 5.5 284c33a0f5 Fix-up: rustfmt check.rs after merging the check PRs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:37:40 -04:00
Brandon Thomas 467b2090e4 Merge pull request #454 from Dejan-Port:fix-french-header-footer 2026-10-12 23:36:55 -04:00
Brandon ThomasandClaude Opus 5.5 8564ef738b French: drop the two header/footer keys main already has
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:36:54 -04:00
Brandon Thomas 8d22483c9f Merge remote-tracking branch 'origin/main' into HEAD 2026-10-12 23:36:50 -04:00
Brandon Thomas 87f4932f0e Merge pull request #604 from MarekSpodBiedry:issue-559 2026-10-12 23:35:53 -04:00
Brandon Thomas e0012d43fa Merge pull request #603 from MarekSpodBiedry:issue-560 2026-10-12 23:35:52 -04:00
Brandon Thomas e154960ce3 Merge pull request #602 from MarekSpodBiedry:issue-562 2026-10-12 23:35:51 -04:00
Brandon Thomas 8d3e440a8b Merge pull request #599 from MarekSpodBiedry:issue-565 2026-10-12 23:35:48 -04:00
Brandon Thomas 86f83f4ccf Merge pull request #598 from MarekSpodBiedry:issue-566 2026-10-12 23:35:47 -04:00
Brandon Thomas 8891854218 Merge pull request #596 from MarekSpodBiedry:issue-568 2026-10-12 23:35:45 -04:00
Brandon Thomas 24b1b60dbc Merge pull request #595 from MarekSpodBiedry:issue-569 2026-10-12 23:35:44 -04:00
Brandon Thomas 68344a938c Merge pull request #592 from MarekSpodBiedry:issue-572 2026-10-12 23:35:40 -04:00
Brandon Thomas 944e3818f6 Merge pull request #590 from MarekSpodBiedry:issue-574 2026-10-12 23:35:38 -04:00
Brandon Thomas 2d7988d057 Merge pull request #589 from MarekSpodBiedry:issue-575 2026-10-12 23:35:37 -04:00
Brandon Thomas a668b22833 Merge pull request #588 from MarekSpodBiedry:issue-576 2026-10-12 23:35:35 -04:00
Brandon Thomas 35668da062 Merge pull request #587 from MarekSpodBiedry:issue-577 2026-10-12 23:35:34 -04:00
Brandon Thomas ca9b3fbbe8 Merge pull request #586 from MarekSpodBiedry:issue-578 2026-10-12 23:35:32 -04:00
Brandon Thomas 5a6341a0cf Merge pull request #503 from webdevtodayjason:fix/indirect-r1c1-overflow 2026-10-12 23:35:30 -04:00
Brandon Thomas 92b1072ab8 Merge pull request #487 from nihilist-narwhal:fix-merge-across-values 2026-10-12 23:35:29 -04:00
Brandon Thomas 1868a58a6b Merge pull request #485 from nihilist-narwhal:fix-manage-rules-panic 2026-10-12 23:35:27 -04:00
Brandon Thomas 96897b709c Merge pull request #484 from nihilist-narwhal:fix-long-operator-chains 2026-10-12 23:35:26 -04:00
Brandon Thomas 79fed48419 Merge pull request #483 from nihilist-narwhal:fix-autofill-series 2026-10-12 23:35:25 -04:00
Brandon Thomas f91198094c Merge pull request #476 from webdevtodayjason:fix/index-area-overflow 2026-10-12 23:35:23 -04:00
Brandon Thomas 591040ce09 Merge pull request #475 from prxnvt:pr/solver-extras 2026-10-12 23:35:22 -04:00
Brandon Thomas 581a234456 Merge pull request #466 from webdevtodayjason:fix/offset-fn-overflow 2026-10-12 23:35:21 -04:00
Brandon Thomas d43eefe522 Merge pull request #461 from neilcawse:fix/ribbon-paste 2026-10-12 23:35:19 -04:00
Brandon Thomas 81b35afe08 Merge pull request #457 from neilcawse:fix/macos-open-with-xlsb-ods 2026-10-12 23:35:17 -04:00
Brandon Thomas 31eb549204 Merge pull request #454 from Dejan-Port:fix-french-header-footer 2026-10-12 23:35:16 -04:00
Brandon Thomas ed971d8f87 Merge pull request #453 from Dejan-Port:i18n-serbian 2026-10-12 23:35:14 -04:00
Brandon Thomas f267530b7c Merge pull request #452 from tydude001:fix/432-filter-list-scroll 2026-10-12 23:35:13 -04:00
Brandon Thomas f4acdcebe5 Merge pull request #450 from SteamSmith:fix/write-file-temp-cleanup 2026-10-12 23:35:12 -04:00
Brandon Thomas dd165335e1 Merge pull request #448 from Dejan-Port:select-inserted-chart 2026-10-12 23:35:11 -04:00
Brandon Thomas af0a28c0a4 Merge pull request #237 from CyberFox-81:nextcloud-embedding-bridge 2026-10-12 23:35:10 -04:00
Brandon Thomas 3bf29982b7 Merge pull request #99 from KitsuneSemCalda:add-omastore-manifest 2026-10-12 23:35:09 -04:00
Brandon ThomasandClaude Opus 5.5 811a8a139a Fix-up: rustfmt the area-chart label condition after merging the chart PRs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:33:10 -04:00
Brandon Thomas 2df8dd6392 Merge pull request #618 from MarekSpodBiedry:issue-540 2026-10-12 23:29:58 -04:00
Brandon ThomasandClaude Opus 5.5 4941bf8da0 ROUNDUP keeps #NUM! past the number range; only toward-zero rounding returns 0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:29:56 -04:00
Brandon Thomas 59872eca3c Merge pull request #627 from MarekSpodBiedry:issue-531 (test fix) 2026-10-12 23:23:35 -04:00
Brandon ThomasandClaude Opus 5.5 7ea5cd3975 Test: the helper is new_session so a rebound session variable does not shadow it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:23:33 -04:00
Brandon Thomas e5071f382b Merge pull request #637 from MarekSpodBiedry:issue-518 2026-10-12 23:21:59 -04:00
Brandon Thomas 1fa38e583e Merge pull request #636 from MarekSpodBiedry:issue-519 2026-10-12 23:21:58 -04:00
Brandon Thomas 4d8af67d0e Merge pull request #635 from MarekSpodBiedry:issue-520 2026-10-12 23:21:57 -04:00
Brandon Thomas c3546f0c97 Merge pull request #634 from MarekSpodBiedry:issue-521 2026-10-12 23:21:56 -04:00
Brandon Thomas ff16e979cd Merge pull request #633 from MarekSpodBiedry:issue-522 2026-10-12 23:21:54 -04:00
Brandon Thomas a507761696 Merge pull request #631 from MarekSpodBiedry:issue-524 2026-10-12 23:21:53 -04:00
Brandon Thomas 3794892f28 Merge pull request #628 from MarekSpodBiedry:issue-527 2026-10-12 23:21:52 -04:00
Brandon Thomas 46ba36e896 Merge pull request #627 from MarekSpodBiedry:issue-531 2026-10-12 23:21:51 -04:00
Brandon Thomas 739c5b8545 Merge pull request #626 from MarekSpodBiedry:issue-532 2026-10-12 23:21:50 -04:00
Brandon Thomas 3f5b29a07e Merge pull request #625 from MarekSpodBiedry:issue-533 2026-10-12 23:21:49 -04:00
Brandon Thomas 487f736c8f Merge pull request #624 from MarekSpodBiedry:issue-534 2026-10-12 23:21:47 -04:00
Brandon Thomas b28a92baa4 Merge pull request #623 from MarekSpodBiedry:issue-535 2026-10-12 23:21:46 -04:00
Brandon Thomas f6252418d9 Merge pull request #622 from MarekSpodBiedry:issue-536 2026-10-12 23:21:45 -04:00
Brandon Thomas 8474233335 Merge pull request #621 from MarekSpodBiedry:issue-537 2026-10-12 23:21:44 -04:00
Brandon Thomas c8f249fc97 Merge pull request #620 from MarekSpodBiedry:issue-538 2026-10-12 23:21:42 -04:00
Brandon Thomas 9e3a83464f Merge pull request #619 from MarekSpodBiedry:issue-539 2026-10-12 23:21:41 -04:00
Brandon Thomas 09c719623b Merge pull request #618 from MarekSpodBiedry:issue-540 2026-10-12 23:21:40 -04:00
Brandon Thomas 6de925c1b6 Merge pull request #617 from MarekSpodBiedry:issue-541 2026-10-12 23:21:39 -04:00
Brandon Thomas 2f83a04521 Merge pull request #616 from MarekSpodBiedry:issue-542 2026-10-12 23:21:38 -04:00
Brandon Thomas e447fb8c90 Merge pull request #613 from MarekSpodBiedry:issue-545 2026-10-12 23:21:34 -04:00
Brandon Thomas db4fd82595 Merge pull request #612 from MarekSpodBiedry:issue-546 2026-10-12 23:21:33 -04:00
Brandon Thomas a85638ddd1 Merge pull request #611 from MarekSpodBiedry:issue-547 2026-10-12 23:21:32 -04:00
Brandon Thomas 4164ca557e Merge pull request #609 from MarekSpodBiedry:issue-552 2026-10-12 23:21:30 -04:00
Brandon Thomas 51b1b031d4 Merge pull request #608 from MarekSpodBiedry:issue-554 2026-10-12 23:21:29 -04:00
Brandon Thomas 28c5bf1b06 Merge pull request #607 from MarekSpodBiedry:issue-556 2026-10-12 23:21:28 -04:00
Brandon Thomas cd985b7d49 Merge pull request #606 from MarekSpodBiedry:issue-557 2026-10-12 23:21:27 -04:00
Brandon Thomas 8f99be0f2e Merge pull request #605 from MarekSpodBiedry:issue-558 2026-10-12 23:21:25 -04:00
Marco Arnulfo 522ede0fca Save an XLSX opened under an unknown extension back to its own file as XLSX (#878) 2026-10-13 05:14:30 +02:00
CouchRivandClaude Opus 5.5 c186781d8b check: a boolean cached as 1/0 without t="b" matches and is counted apart
Business Central's "Open in Excel" exports write booleans as
<c><f>TRUE()</f><v>1</v></c> with no t="b", so the cached value reads back
as the number 1 and the calc oracle reported every such cell as a
`number → bool` mismatch. A cached 1 or 0 now matches GridCraft's TRUE or
FALSE, and the cells are counted apart (`boolAsNumber` in --json, a note in
the text report) so a real number/logical disagreement stays visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:36:20 -04:00
Marco Arnulfo 9bb85ab9af Refuse to save to an unknown file extension instead of writing XLSX under it (fixes #878) 2026-10-13 04:34:03 +02:00
Marco Arnulfo 6dc9138a64 check: internal links and empty parts aren't package losses (part of #860) 2026-10-13 03:52:00 +02:00
neilcawse 95012e03b4 UI: a cut's text pastes again once the clipboard has held other text 2026-10-12 21:19:05 -04:00
Marco Arnulfo bf2923c4ba check: the package round trip reports relationship kinds the save lost (part of #860) 2026-10-13 03:04:37 +02:00
Marco Arnulfo dab4aa5e79 XLSX: write this-row structured references as [[#This Row],[Col]] (fixes #864)
[@Col] is only Excel's display shorthand. Written verbatim into cell formulas and calculatedColumnFormula, it made Excel repair the file and drop the tables.
2026-10-13 02:58:56 +02:00
nihilist-narwhal 34b39e2f3c Merge remote-tracking branch 'origin/main' into fix-function-name-arguments-m 2026-10-12 19:57:06 -05:00
nihilist-narwhal c930bf7872 Merge Across: keep only the first value of each row
Merge & Center and Merge Cells kept only the upper-left value, but
Merge Across left the other values of each row in place, hidden under
the merge and still counted: with 1 2 / 3 4 in A1:B2, SUM(A1:B2) stayed
10 after Merge Across, where Excel gives 4. Each row's merged area now
keeps only its first value, as the other merges do.
2026-10-12 19:52:59 -05:00
nihilist-narwhal 9da4e4cdf7 Manage Rules: don't index past the list after a delete
home.manageRules took the number of rules once, before deleting, so
{"delete":0,"moveUp":1} with two rules swapped past the end of the
shortened list and panicked (index out of bounds; the session's guard
kept the workbook). The count is now taken after each step.
2026-10-12 19:52:59 -05:00
nihilist-narwhal 6b6aa82be1 Long operator chains: evaluate them without the depth limit
A formula such as =A1+A2+…+A500 nests one level per operator, and the
evaluator recursed once per level, so past its depth limit (about 400
operators) the result was #CALC!. Excel allows such formulas up to its
8,192-character limit. Chains of arithmetic, comparison and & operators
are now folded in a loop.
2026-10-12 19:52:59 -05:00
nihilist-narwhal b5a82554a9 Fill Series: a falling growth series stops at its stop value
A growth series with a step below 1 stopped at once when given a stop
value, since the stop was only checked for rising series. It now bounds
the series in the direction it runs.
2026-10-12 19:52:59 -05:00
nihilist-narwhal 56617b07af AutoFill: times, quarters, ordinals and mixed sources
Following Excel's AutoFill series:
- a single time steps by an hour (9:00, 10:00, 11:00); it was copied.
- Qtr3 and Quarter3 wrap after 4 like Q3; they counted on to Qtr5.
- 1st Period continues as 2nd Period, 3rd Period; it gave 2st, 3st.
- a source without a common pattern (Product 1, On backorder) continues
  each cell's own series; it was copied as is.
2026-10-12 19:52:59 -05:00
neilcawseandClaude Opus 5.5 317ec4388c XLS: array formulas fill their range on recalculation
A legacy array formula in a .xls file ({=FREQUENCY(...)}, {=TREND(...)}) is
stored as one ARRAY record plus a FORMULA record per cell of its range, each
with the cached result. The importer attached the formula to the anchor cell
but kept the cached values in the other cells as plain constants. On
recalculation the formula then found its range occupied and returned #SPILL!.
The .xlsx reader already drops those cached results; the .xls reader now does
the same once the formula is attached, and keeps the values when the formula
could not be imported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:52:03 -04:00
neilcawseandClaude Opus 5.5 0485e7f4ba Packaging: offer GridCraft for .xlsb and .ods files on macOS
GridCraft imports XLSB (#101) and ODS (#100) files, and #321 added both
to the Linux and Windows "Open with" lists, but the macOS Info.plist
still declared only .xlsx, .xlsm, .csv and .tsv, so Finder's Open With
didn't offer GridCraft for them. Add two CFBundleDocumentTypes entries
(Editor, rank Alternate) with the UTIs macOS reports for these files,
plus an imported UTI for .xlsb like the .xlsm one. A test now checks
that the macOS and Windows packaging offer the same file types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:52:03 -04:00
neilcawse 4d654c5185 UI: Cmd/Ctrl+V doesn't re-paste a pasted cut's clipboard text either 2026-10-12 20:51:39 -04:00
neilcawse fd1ed6649b UI: Paste doesn't re-paste a pasted cut's clipboard text; Paste keytip (H,V,P) pastes the system clipboard 2026-10-12 20:51:39 -04:00
neilcawse cc65915d16 UI: keytip Copy/Cut publish to the system clipboard; ribbon Paste keeps a newer programmatic copy 2026-10-12 20:51:39 -04:00
neilcawseandClaude Opus 5.5 fa1dcd73e2 UI: Paste button and menus paste the system clipboard; freeze regression test (#273)
In 0.5.0 a click anywhere on the Home ribbon's Paste button (label,
icon or dropdown arrow) froze the app for good. home() called
paste.secondary_clicked() inside ctx.data_mut(); after any click egui
answers that with ctx.input(), which takes the context lock the closure
already holds, so the UI thread waited on itself. Sampling the hung
0.5.0 process on macOS shows the main thread parked in
RawRwLock::lock_exclusive_slow under Context::write <- data_mut <-
ribbon::home. #272 moved the query out of the closure; it landed after
the 0.5.0 release, which is the build both reporters used.

No test ever clicked the Paste button itself, which is how this
shipped. The new test clicks it, then opens its menu with a secondary
click and picks Paste Values, on a worker thread with a timeout so a
regression fails instead of hanging the run. With #272 reverted it
fails (egui's debug lock check panics after 10 s); with it, it passes.
The Paste button gets an accessible "Paste" name so the test (and
screen readers) can find it, the same way Pictures and Border presets
are named.

With the freeze gone, the button still did not do what it says: it
pasted GridCraft's own copy only (GridState::system_clipboard was never
assigned), so text copied in another app gave "Nothing to paste." The
ribbon button, its options menu's Paste, the cell and header context
menus and the macOS Edit menu now read the system clipboard through a
new paste_text host service and paste it like Cmd/Ctrl+V does, falling
back to GridCraft's own copy when there is no text (or no service, as
on the web). Asking egui for the clipboard instead (RequestPaste) was
tried and stalled the macOS event loop until the next input event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:51:39 -04:00
Marco Arnulfo 2dd832e2d5 XLSX: XML maps survive a save, with their table columns and mapped cells (part of #860) 2026-10-13 02:45:40 +02:00
Marco Arnulfo 5564856feb PivotTables: the PivotTable Fields pane follows the interface language (fixes #858)
The pane's labels, field menus and buttons were hardcoded English strings that never went through i18n, so switching to Spanish left the whole pane in English. They now go through Language::tr, with Spanish entries in the catalog. The Summarize by / Show as items are keyed as whole labels so each one can be translated.
2026-10-13 02:41:49 +02:00
Eduardo Soares 26c6e3df44 #833: pin hidden-row formatting, grow-only restore branch, tighten tests 2026-10-12 20:48:43 -03:00
Eduardo Soares dc3ccc4ea0 #833: docs — track row auto-fit for oversized fonts
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-10-12 20:48:43 -03:00
Eduardo Soares 1086e4aab8 #833: opened files grow non-custom rows to their tallest font 2026-10-12 20:47:31 -03:00
Eduardo Soares a0a17b154c #833: whole-row/column formatting re-fits row heights 2026-10-12 20:47:31 -03:00
NetterSilver 805ffba836 Docs: update the progress docs for this change 2026-10-13 01:46:09 +02:00
NetterSilver c5c1763774 XLSX: keep chart sheets on open and save (#727)
Chart sheets (a sheet that holds one chart; Move Chart > New sheet, F11) were
skipped on open, and their chart sheet, drawing, chart, style and colour parts
were dropped with them because the pass-through treats those kinds as content
GridCraft rewrites. Saving a workbook lost every chart sheet.

They are now kept as they were: the <sheet> entry (name, visibility and its
place among the worksheets), the chartsheet part and everything behind it,
byte for byte, written back in place. The workbook's other sheet positions
(active tab, print areas, scoped names) are computed with the chart sheets
counted. A chart sheet is left out on save when the chart is no longer
valid for the workbook: a sheet it plots was deleted or renamed, or a
worksheet took its name.
2026-10-13 01:45:40 +02:00
derechopiano cf7138b5e1 UI: Show rule-specific data validation fields 2026-10-13 00:31:48 +01:00
NetterSilver daeda9db47 Credit Silvertree2010 in the About window 2026-10-13 01:17:16 +02:00
NetterSilver d1260e1878 Docs: update the progress docs for this change 2026-10-13 01:17:16 +02:00
NetterSilver 39b3d55f8a CSV export: refuse a file over 512 MiB instead of expanding the whole used range (#528)
write_csv visited every position of the used range and appended to one growing
String with no limit, so two cells far apart (A1 and XFD1048576) asked for
about 16 GiB of commas. The output is now capped at 512 MiB: a lower bound
(delimiters and line ends of every row) is checked before anything is
allocated, and the text written so far is checked after each row. Over the
cap, write_csv returns IoError::TooLarge naming the rows, columns and limit,
and Save As / Export CSV and TSV report it without writing a file. Accepted
exports are unchanged. write_csv now returns a Result.
2026-10-13 01:16:05 +02:00
Marco Arnulfo 68a3eabe20 Sheet tabs: All Sheets navigator with a filter field (fixes #839) 2026-10-13 00:25:32 +02:00
Marco Arnulfo 443301f9ee UI: Enter pastes the copied range and ends copy mode (fixes #845) 2026-10-13 00:21:25 +02:00
Bryan MarkandClaude Opus 5.5 a38c58ef48 Merge branch 'main' into recent-modified
Brings in upstream main (0.6.1), which fixes the two CSV tests this
branch failed CI on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:20:22 +09:00
Marco Arnulfo 9c3db37ca2 UI: AutoComplete folds case beyond ASCII and cuts the proposed rest on characters (#842) 2026-10-13 00:18:55 +02:00
Marco Arnulfo a7e22d299f Grid: horizontal scrollbar in the sheet tab bar (fixes #840) 2026-10-13 00:14:40 +02:00
Marco Arnulfo 7a8235a586 UI: AutoComplete for cell values can be turned off, Backspace and Escape drop the proposal (fixes #842) 2026-10-13 00:13:35 +02:00
Marco Arnulfo 1f19bf4a27 Grid: Shift+arrows in point mode extend from the pointed cell, also up and left (fixes #835) 2026-10-12 23:28:26 +02:00
bmh129andClaude Opus 5.5 0153457580 View ribbon: lay out the Show group's checkboxes in two columns
The Show group stacked Ruler, Formula Bar, Gridlines and Headings in
one column, but the ribbon body fits only three rows, so Headings was
cut off at the bottom and could not be reached. The four checkboxes now
sit in two columns of two (Ruler/Gridlines, Formula Bar/Headings), as in
Excel's Show group. Checked in English and German, which has the
longest labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:03:47 -05:00
Marco Arnulfo 52b4f873c7 XLSX: a hyperlink to another file is saved as an external relationship (fixes #800) 2026-10-12 22:57:20 +02:00
Marco Arnulfo 484612f638 XLS: formulas that use a skipped defined name keep their saved results (fixes #803) 2026-10-12 22:56:40 +02:00
Marco Arnulfo 5406fc5629 XLSX: Save As into another folder keeps links on the same workbook (fixes #801)
Excel stores the path of a linked workbook relative to the file that uses it, and rewrites it when the file is saved into another folder. The path was kept as text, so after Save As it named a file next to the new copy: a missing link, or another workbook with the same name. Relative externalLinkPath targets are now resolved against the folder the workbook was read from and written relative to the folder it is saved into (absolute when there is no relative path, on another drive).
2026-10-12 22:55:53 +02:00
Marco Arnulfo 6939d95cae Formula bar: hide _xludf. like Excel, keep it when the edit is confirmed unchanged (#726) 2026-10-12 22:53:10 +02:00
Marco Arnulfo 75305dfedf XLSX: keep the workbook's default font on save (fixes #805)
Cell format 0 always mapped to the model's built-in Calibri 11 default, so the file's Normal font was replaced on save and every default-formatted cell changed size. The Normal style now takes the font of cell format 0, and a Normal font in the theme's body face is written with scheme="minor" again.
2026-10-12 22:51:41 +02:00
Marco Arnulfo 930fa1cada XLSX: keep fileVersion and calcFeatures in workbook.xml on save (fixes #799)
Excel uses the cached cell values of a saved file only when workbook.xml has both <fileVersion> and the calcFeatures extension; without them it recalculates everything on open, and lookups into closed workbooks come back as #CALC!. Both are now read into the workbook's kept content and written back in schema order: fileVersion first, the calcFeatures ext in <extLst> last. A workbook that had neither gets neither.
2026-10-12 22:49:34 +02:00
Marco Arnulfo 9a58f2d086 XLS: a formula kept as its saved value keeps a text result (fixes #802) 2026-10-12 22:40:01 +02:00
Claude 6e2caa9f86 Web: opt-in embedding bridge for host pages (?host=parent)
A page that frames GridCraft on the same origin can now open workbooks
in it and store what it saves, through postMessage, instead of Save and
Export becoming browser downloads. Built for a Nextcloud integration,
but nothing in it is host-specific.

Only the web shell changes:
- gridcraft:open from the parent goes through the inbox, like a picked
  or dropped file
- Services::download sends the bytes to the parent (gridcraft:save); an
  .xlsx workbook handed over counts as saved under that name
- gridcraft:dirty reports unsaved changes; gridcraft:ready and
  gridcraft:failed report start-up
- in host mode no sample opens, and ?author= names the user for notes
  and comments

Enabled only by ?host=parent inside a frame; only the parent window on
the page's own origin is heard or answered. Without it nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp
2026-10-12 16:36:26 -04:00
neilcawse ef1cd55d24 PivotTables: a collapsed item puts its value captions in the Values column (outline, tabular) 2026-10-12 16:29:02 -04:00
vangel76andClaude Sonnet 5.5 a540a7138a PDF: text width cannot overflow; dash, stroke width and NaN sizes are sanitised (#812)
text_width sums in u64; an all-zero or non-finite dash array is written as
solid ([] 0 d); stroke widths are clamped to >= 0 (NaN -> 0); drawing calls
with non-finite or non-positive geometry return before storing anything.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:18:58 +02:00
vangel76andClaude Sonnet 5.5 0042240c05 PDF: title and author are written as UTF-16BE when they are not ASCII (#813)
Info strings were WinAnsi; PDF text strings are PDFDocEncoding or UTF-16BE.
Printable ASCII stays a literal string; anything else is a <FEFF...> hex
string. Info strings are capped at 1024 characters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:18:46 +02:00
vangel76andClaude Sonnet 5.5 a60b49fad0 Numfmt: format-code parsing is linear, and an unclosed [ is treated the same in both passes (#810)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:10:35 +02:00
neilcawse e5b12dab06 Functions: COUNTIF family counts the 255 criterion limit in UTF-16 units, as in Excel 2026-10-12 16:09:52 -04:00
vangel76andClaude Sonnet 5.5 5f992be525 Numfmt: no panic on a fraction denominator with 65,536 zeros; format codes are capped at 255 characters (#807)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:09:29 +02:00
vangel76andClaude Sonnet 5.5 fd53de7c46 Numfmt: TEXT() and chart labels parse each format code once (#806)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:09:09 +02:00
vangel76andClaude Sonnet 5.5 2be04524b3 Calc: SUBTOTAL(10x) recalculates when Row Height, AutoFit Row Height or Ungroup reveal rows
home.rowHeight, home.autofitRowHeight and data.ungroup change row hidden
state without marking the edit structural, so SUBTOTAL(101-111) kept a stale
value. Found by the command-sequence fuzz (seed 3).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:09:27 +02:00
Marco Arnulfo a5f531838b Functions: note the dependency gap of computed criteria (part of #737) 2026-10-12 21:08:20 +02:00
neilcawseandClaude Opus 5.5 aa2a580626 PivotTables: values on rows review fixes (Total header, Σ Values last in Columns)
With values on rows and no column field, the header cell above the data
column reads "Total", as Excel saves it (tabular checked against a real
file).

Σ Values in Columns always comes after the last column field: the column
layout doesn't place it anywhere else, so `pivot.moveField` no longer
stores a position there, the reader ignores where `-2` sits in colFields
and the writer puts it last.

`pivot.changeSource` removes dropped row and column fields one by one so
Σ Values keeps its place ahead of the field it preceded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:05:59 -04:00
vangel76andClaude Sonnet 5.5 bd105a569b Page Layout view: vertical ruler on the right for RTL sheets, header/footer distance clamped to the margins (#792)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:03:37 +02:00
vangel76andClaude Sonnet 5.5 600c25098f Fix footer clamp expectation in the drag test (#792)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:01:22 +02:00
vangel76andClaude Sonnet 5.5 c4ad8e3a9b Page Layout view: header/footer margins on the ruler, live re-pagination, right-to-left rulers (#792)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:01:09 +02:00
vangel76andClaude Sonnet 5.5 339f8ed35b Page Layout view: drag the margins on the rulers (#792)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:57:02 +02:00
Michał Marcin BrzuchalskiandClaude Opus 5.5 26ed66cfe1 i18n: look up the Format Cells dialog labels in the catalog
The Format Cells dialog drew English literals in every language. Its
tabs, labels, options and protection note now go through Language::tr,
as do dialog titles and the shared OK/Cancel buttons. The Fill tab is
keyed Format Cells|Fill because the Alignment tab's Fill is a different
word in some languages. d.tab keeps the English name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:35:10 +02:00
neilcawse 4c2bcd637c Functions: *IFS with two or more criteria pairs match the first 255 characters of a longer criterion, as in Excel 2026-10-12 14:34:18 -04:00
vangel76andClaude Sonnet 5.5 47c0af3f1b Data: Forecast Sheet Forecast Start option (#785)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:34:07 +02:00
Marco Arnulfo 531dc5a5aa functions: MODE comments name Excel 365 as the reference, not POI's cached values (#736) 2026-10-12 20:21:19 +02:00
neilcawse caf3193261 Functions: *IFS with two or more criteria pairs accept criteria over 255 characters, as in Excel 2026-10-12 14:19:29 -04:00
neilcawseandClaude Opus 5.5 5524e2520a Functions: COUNTIF family returns #VALUE! for criteria over 255 characters
Excel rejects a text criterion longer than 255 characters in COUNTIF(S),
SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS with #VALUE!; GridCraft matched
it anyway. Found by the calc oracle on an Excel-saved report where a
268-character concatenated criterion caches #VALUE!.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:19:29 -04:00
vangel76andClaude Sonnet 5.5 2797d468c5 Security: cell hyperlinks open only http, https and mailto, after a confirmation (#777)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:17:30 +02:00
Marco Arnulfo 1767a28371 Numfmt: scientific formats with a literal after E±, a % or a comma match Excel (fixes #768)
The exponent sign goes right before the exponent digits, after any literal that follows
the E; ? pads exponent digits with spaces; % and commas never scale a scientific section;
zero shows 0 in every integer placeholder. Checked against the 248 TEXT() rows of Apache
POI's NumberFormatApproxTests.xlsx, copied as data into a test.
2026-10-12 19:43:33 +02:00
vangel76andClaude Sonnet 5.5 76e7f1de07 Grid: the selection outline stays visible when extended to the last row (#687)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:25:51 +02:00
vangel76andClaude Sonnet 5.5 afce16c8d2 Grid: deleting several Ctrl-selected rows removes them all (#758)
home.deleteRows/deleteColumns used only the last selected area. They now
take every area (or a comma list like "3:3,7:8"), merge them and delete
from the last to the first inside one edit, so it is one undo step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:24:36 +02:00
Marco Arnulfo 4d2e8e3455 Functions: database functions evaluate computed criteria (part of #737)
A formula under a criteria label that names no field is evaluated for each record: its relative references move from the first record to that record, and the database labels name the record's fields, as in Excel. The calc evaluator passes the criteria reference to the database functions through Ctx::computed_criterion.
2026-10-12 19:16:00 +02:00
msaulcandClaude Opus 5.5 77a91a714a Grid: extending the selection from the keyboard scrolls its moving end into view
Ctrl+Shift+Arrow (and Shift+Arrow, Shift+PageUp/PageDown, Alt+Shift+PageUp/PageDown,
Ctrl+Shift+Home/End, Shift+Home) extend the selection by moving its far corner while the
active cell stays on the anchor. After the move the grid set `ensure_visible`, but the
scroll-into-view step in `grid::show` always targeted `selection.active`, which is the
anchor and is already on screen. So the view never followed the extension past the last
visible row or column.

The keyboard extension paths now also set `GridState::reveal_moving_end`, and the
ensure-visible step then scrolls to `Selection::moving_end()`, the corner opposite the
anchor. `selection.move` now uses that same helper (it had the logic inline) to find where
an extension starts. Other ensure-visible callers (Go To, Name Box, plain moves) still
reveal the active cell.

Regression test: crates/ui-egui/tests/selection_scroll.rs drives Ctrl+Shift+Up/Down/
Left/Right, Shift+Down/Right and Ctrl+Shift+Home/End through real egui key events and
checks that the moving end ends up on screen while the anchor and active cell stay put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:07:43 -06:00
Marco Arnulfo f63a437d5b Formulas: keep _xludf. so user-defined calls stay user-defined (fixes #726)
The parser stripped _xludf. like _xlfn., but the two mean opposite things:
_xludf. marks a name Excel must not treat as a built-in. Keeping it in the
call name makes the call an unknown function (#NAME?, as in Excel) and the
writer emits it unchanged, so _xludf.EOMONTH() no longer saves as an
EOMONTH() call Excel repairs away.
2026-10-12 19:07:32 +02:00
Marco Arnulfo f968bb3269 CELL("width") returns the Column Width dialog value, without the padding (#735) 2026-10-12 19:05:41 +02:00
Marco Arnulfo 0a27d4a5d4 Functions: MIRR, COUNTIF, INDIRECT and USDOLLAR match Excel 365 (fixes #738) 2026-10-12 19:04:17 +02:00
Marco Arnulfo eb534b5caf Functions: DGET skips empty result fields and "" criteria match any text (part of #737) 2026-10-12 18:58:34 +02:00
Marco Arnulfo 94014c5594 Tables: structured references keep the spaces at the edges of a column name (fixes #732) 2026-10-12 18:51:40 +02:00
Marco Arnulfo f3e05e0ad8 XLS import: read the calculation mode and precision as displayed (fixes #724)
CalcMode repeats in every worksheet substream like the iteration records and sets wb.calc.mode from the first worksheet; CalcPrecision sits in the globals and sets precision_as_displayed, matching calcMode and fullPrecision on the .xlsx side.
2026-10-12 18:47:53 +02:00
Marco Arnulfo a34f106e93 XLS: open compound files whose last sector isn't padded (fixes #730) 2026-10-12 18:44:23 +02:00
Marco Arnulfo dbfed89a36 XLS import: read the iterative calculation settings (part of #724)
CalcIter, CalcCount and CalcDelta repeat in every worksheet substream and were skipped, so a workbook saved with iterative calculation on opened with it off and its circular references showed #CIRC!. They now set wb.calc from the first worksheet, as <calcPr iterate iterateCount iterateDelta> does for .xlsx.
2026-10-12 18:43:28 +02:00
Marco Arnulfo 3d0ed97d71 XLS: a FORMAT record shorter than its character count keeps the characters it holds (fixes #731) 2026-10-12 18:41:45 +02:00
Marco Arnulfo 0dc76090d7 functions: MODE skips typed text and logical arguments (fixes #736) 2026-10-12 18:38:01 +02:00
Marco Arnulfo 9e28655a8a ROMAN: forms 2 and 3 subtract IC, XM and VM like Excel (fixes #733) 2026-10-12 18:37:51 +02:00
Marco Arnulfo e88d3d7583 EDATE and EOMONTH: serial 0 is day 0 and February 1900 has 28 days (fixes #734)
Excel's EDATE and EOMONTH read serial 0 as 0 January 1900 and end
February 1900 on the 28th (serial 59), although the sheet itself keeps
the Lotus 29 February 1900 (serial 60). GridCraft clamped day 0 up to
day 1 and used the sheet's 29-day February, so EDATE(0,0) was 1,
EDATE(0,1) 32, EDATE(31,1) and EOMONTH(59,0) 60.

EDATE now keeps day 0 and both functions take the real calendar's month
lengths. Serial 60 still reads as 29 February, so EDATE(60,1) stays
29 March. DATE, DAY and the parser keep the leap day.

Tests: the Excel 365 table from the issue; the #124 cases that expected
60 at the end of February 1900 now expect 59.
2026-10-12 18:36:51 +02:00
Marco Arnulfo 15b346e6ca macOS: register the Window menu as NSApp's windowsMenu, so the tiling shortcuts work (fixes #716) 2026-10-12 18:36:00 +02:00
Marco Arnulfo ea5db94fe3 Numfmt: a negative number that rounds to zero shows no minus sign (fixes #729) 2026-10-12 18:35:01 +02:00
dantebertuzziandClaude Opus 5.5 de04cbe917 i18n(pt-BR): translate 546 more labels, dialog strings and tooltips
Brazilian Portuguese had 377 catalog entries, so most dialogs, menus and
tooltips showed in English with the interface set to Português (Brasil).
This adds every key the other catalogs translate (counting the open
Spanish-dialog #669, Russian #676, Czech #652, Polish #650, Turkish #663
and Italian #713 PRs), bringing pt-BR to 923 entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:29:17 -03:00
dantebertuzziandClaude Opus 5.5 857f538c08 cell.set / set_cell: accept a number for input (#697)
`input` is documented as "text, number or =formula", but only a string
was read; a JSON number was dropped and the command failed (or, on 0.5.0,
silently cleared the cell). The undocumented `value` alias already went
through `json_to_input`. Both keys now share that converter, so numbers
and booleans are entered as typed. The MCP `set_cell` schema types
`input` as string, number or boolean to match its description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:15:06 -03:00
neilcawseandClaude Opus 5.5 1f7fd07c0d PivotTables: values on rows (Σ Values in Rows, dataOnRows)
With two or more value fields the Σ Values pseudo-field can sit in Rows
as well as Columns, at any position among the row fields. The model
records it (`PivotTable.values_on_rows`, `values_position`) and the
engine lays it out like Excel in compact, outline and tabular form: one
row item per value field at the Σ Values level, per-value subtotals
("East Sum of Sales") and grand totals ("Total Sum of Sales").

XLSX reads `dataOnRows` and the `-2` field's place in rowFields or
colFields and writes them back; the "values on rows are shown on
columns" warning is gone. `pivot.moveField` moves "Σ Values" between
Rows and Columns or within one, axis field moves keep it in place, and
the PivotTable Fields pane lists it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:30 -04:00
Marco Arnulfo 1a49d18dd1 Comments pane: one Show Comments button, and the pane keeps its width (fixes #710) 2026-10-12 18:02:02 +02:00
jonathanwxh-cell d4874373a4 #551: preserve editable ink through XLSX round trips 2026-10-12 23:54:44 +08:00
Losminek c167d59c70 Merge remote-tracking branch 'upstream/main' into cs-tooltips 2026-10-12 17:53:24 +02:00
Isaac Levine 746a1e0364 File: Export as PDF in the File menu, and the palette asks for a path (#407) 2026-10-12 11:03:13 -04:00
Marco Arnulfo dfb780a544 UI: clicking a column or row header in point mode inserts N:N / n:n (#691) 2026-10-12 15:56:22 +02:00
RichardandClaude Opus 5.5 ae54c84bc9 Key the cached Top 10 cutoff by criterion, not column (#661)
Two Top 10 criteria on one column (possible in native JSON files) shared one
cache entry, so the last cutoff overwrote the first. Reported by @derechopiano.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc
2026-10-13 00:10:54 +11:00
Vedran DobosandClaude Opus 5.5 3830cee850 View: Ctrl+wheel zoom steps along a ladder that never skips 100% (#689)
Each frame multiplied the whole-number zoom by egui's eased wheel
factor and rounded, so a notch out never undid a notch in:
100 -> 122 -> 101 -> 123 -> 102.

A notch now moves to the next step on WHEEL_ZOOM_LADDER (10 ... 85, 100,
120 ... 400; four per doubling, about 20% apart). A step less than 5%
away is skipped, but 100% and the limits never are, so 121% out goes
to 100% and 99% in goes to 100%. The wheel's turning is counted in
notches: parts of a notch add up, and a notch steps once 60% of it has
turned. With animations on the zoom glides to the step, and a notch
turned during a glide steps on from where it's going.

The status bar's Zoom In / Zoom Out keep their 10% steps; that, and
whether the glide needs a setting of its own, is noted in
docs/decisions.md for the maintainers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:57:57 +02:00
derechopiano f7f04df4d7 #681: Reveal the selected worksheet tab after navigation 2026-10-12 13:55:18 +01:00
Marco Arnulfo ef15e22815 Charts: a newly inserted chart is selected, so Delete removes it (#668, #256)
Only the Recommended Charts dialog selected the chart it inserted. The Insert
gallery buttons and Alt+F1 dropped the id insert.chart returns, so the selection
stayed on the source cells and Delete cleared the data instead of the chart.
SheetApp::run now selects the new chart whenever insert.chart succeeds, which
covers every caller.
2026-10-12 13:27:27 +02:00
Marco Arnulfo 2589f3f7d4 Data validation: hide the input message while the cell's list is open (#505)
The input message is a tooltip-order area just below the cell, and the in-cell
dropdown opens at the same spot, so the message covered the first items of the
list. It now stays hidden while the list picker is open on the active cell and
comes back when the list closes.
2026-10-12 13:17:09 +02:00
RichardandClaude Opus 5.5 bf9a202222 Compute the Top 10 filter threshold once per column, not once per row (#661)
apply_filter cloned and sorted the column's numbers for every row it tested,
O(n^2 log n). The threshold is now computed once per criterion with the other
column stats. Adds a test for top/bottom items and percent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc
2026-10-12 20:29:00 +11:00
derechopiano 0514e537ac #441: Open AutoFilter header menus with Alt+Down 2026-10-12 09:56:52 +01:00
LosminekandClaude Opus 5.5 a33a5741d8 i18n(cs): translate 120 labels and tooltips already looked up via tr()
Ribbon tooltips and menu items, cell and header context menus, formula
bar, sheet tabs and status controls had no Czech entry and fell back to
English. Catalog-only: cs.rs, no new tr() call sites, no other language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:25:35 +02:00
MarekSpodBiedry 7d9c0a4dd9 Center Across Selection: center text over the empty cells to its right (#479) 2026-10-12 08:41:31 +02:00
MarekSpodBiedry 8663be2c32 Keep a long outline group's bracket once its first 4096 lines scroll away (#510) 2026-10-12 08:39:55 +02:00
MarekSpodBiedry 85c7fc4d6e Zoom dialog: a Magnification choice sets the percentage to store (#521) 2026-10-12 08:39:42 +02:00
MarekSpodBiedry 9be19c98d8 Keep a literal chart category with a comma or semicolon as one point on save (#519) 2026-10-12 08:39:37 +02:00
MarekSpodBiedry 9da1814cdf Refuse a PivotTable that cannot fit before aggregating all column groups (#515) 2026-10-12 08:39:35 +02:00
MarekSpodBiedry 0ccc94edd3 Save a table that has only a header row and a totals row (#511) 2026-10-12 08:39:27 +02:00
MarekSpodBiedry ba5e871329 Run the fill-handle double-click fill before the handle press returns (#507) 2026-10-12 08:39:20 +02:00
MarekSpodBiedry 5334ae5cbc Keep manually hidden and collapsed rows when removing an AutoFilter (#508) 2026-10-12 08:39:12 +02:00
MarekSpodBiedry 8517c7c48d xlsx: warn when hyperlinks past the 10,000-cell cap are dropped on read (#513) 2026-10-12 08:39:12 +02:00
MarekSpodBiedry 3ceb50f404 Keep a solid data bar solid across an XLSX save and reopen (#550) 2026-10-12 08:39:11 +02:00
MarekSpodBiedry 310cade6e5 Formula: print flat operator chains iteratively so deep trees cannot overflow the stack (#527) 2026-10-12 08:38:48 +02:00
MarekSpodBiedry 2ce94fa581 Data Validation dialog opens on the active cell's current rule (#522) 2026-10-12 08:38:44 +02:00
MarekSpodBiedry 91873e03eb Draw data labels on area and stacked area charts (#545) 2026-10-12 08:38:28 +02:00
MarekSpodBiedry 5fbd726a11 Treat non-object params as empty in themeColors, background and drawBorder (#516) 2026-10-12 08:38:11 +02:00
MarekSpodBiedry dc6f7246a3 Page Setup opens on the sheet's saved margins (#524) 2026-10-12 08:38:09 +02:00
MarekSpodBiedry c20f4c2fcb Fail Web Page export on sheets taller than its row limit instead of dropping cells (#514) 2026-10-12 08:38:04 +02:00
MarekSpodBiedry c8a47c9fb3 Read omitted val on chart smooth and showVal as on (#518) 2026-10-12 08:37:57 +02:00
MarekSpodBiedry baac184ed8 Reject row/column insert counts past the worksheet and clamp delete counts instead of overflowing (#531) 2026-10-12 08:37:46 +02:00
MarekSpodBiedry 53df51310f Check the full PIVOTBY layout size before building column headers (#537) 2026-10-12 08:37:38 +02:00
MarekSpodBiedry 3846376937 Refuse Total Row on a table that ends on the last worksheet row (#533) 2026-10-12 08:37:37 +02:00
MarekSpodBiedry 2d0fb534a2 Fix saving a custom view or script when the stored list is not a JSON object (#517) 2026-10-12 08:37:04 +02:00
MarekSpodBiedry 9a698aa1ff Make gridcraft-cli check compare conditional-format rule contents, not just their count (#559) 2026-10-12 08:36:53 +02:00
MarekSpodBiedry 9bbb263661 HSTACK checks its padded output size before building it (#536) 2026-10-12 08:36:37 +02:00
MarekSpodBiedry 7464ce6e6e Return an error when a weekday fill runs past the supported dates (#535) 2026-10-12 08:36:31 +02:00
MarekSpodBiedry 389c539da4 Honour the gridlines flag on box-and-whisker and stock charts (#543) 2026-10-12 08:36:18 +02:00
MarekSpodBiedry 5fb854be9c Chart: color pie, doughnut, treemap and sunburst points from the workbook theme (#579) 2026-10-12 08:36:14 +02:00
MarekSpodBiedry 125f460cef Paste Special: copy the validation rule with the Validation and All options (#562) 2026-10-12 08:36:13 +02:00
MarekSpodBiedry eec3353e83 Read date text in the workbook date system in arithmetic and VALUE (#564) 2026-10-12 08:35:55 +02:00
MarekSpodBiedry 76d3879a94 Fix data.filterBy inside a table installing the sheet AutoFilter instead of toggling the table button (#547) 2026-10-12 08:35:51 +02:00
MarekSpodBiedry 555635e13e Return zero from ROUNDDOWN and TRUNC at precisions beyond the number range (#540) 2026-10-12 08:35:47 +02:00
MarekSpodBiedry b039465d41 Refuse XLSX export of sheet names XLSX cannot hold (#552) 2026-10-12 08:35:42 +02:00
MarekSpodBiedry 098b9e70b4 Reject oversized TEXTSPLIT results before building the padded grid (#538) 2026-10-12 08:35:32 +02:00
MarekSpodBiedry 3301d59f44 Fix histogram counts disagreeing with interval labels on bin boundaries (#546) 2026-10-12 08:35:31 +02:00
MarekSpodBiedry e6c16472e4 Return #VALUE! when a LAMBDA call has too few argument positions (#557) 2026-10-12 08:35:28 +02:00
MarekSpodBiedry bfe3a9483d MCP: reject invalid format_range borders as invalid params (#560) 2026-10-12 08:35:13 +02:00
MarekSpodBiedry 0ae1eafc9b Open files by their content, not a mismatched extension (#569) 2026-10-12 08:34:52 +02:00
MarekSpodBiedry ff14224ff9 Convert TODAY and NOW to the workbook date system in 1904 workbooks (#578) 2026-10-12 08:34:40 +02:00
MarekSpodBiedry 34125d7e43 Quote sheet names when printing sheet-qualified defined names (#567) 2026-10-12 08:34:24 +02:00
MarekSpodBiedry 8427681d7c Tables: names with a hyphen or other operator characters are rejected (#576) 2026-10-12 08:34:22 +02:00
MarekSpodBiedry 2586ec22df Treat serials past 9999-12-31 as out of range in the 1904 date system (#565) 2026-10-12 08:34:19 +02:00
MarekSpodBiedry a98f6d127e Parse an intersection whose operand became #REF! (#568) 2026-10-12 08:34:07 +02:00
MarekSpodBiedry f8983e25eb Fix SEARCH position when text contains a char with multi-char lowercase (#571) 2026-10-12 08:34:04 +02:00
MarekSpodBiedry 40dfdd8331 Restore frozen panes when showing a custom view (#577) 2026-10-12 08:33:43 +02:00
MarekSpodBiedry 576e625de6 Return #VALUE! for AGGREGATE without a data argument (#572) 2026-10-12 08:33:34 +02:00
MarekSpodBiedry 34e1d373bf fix(engine): CSV named History keeps a valid sheet name (#570) 2026-10-12 08:33:17 +02:00
MarekSpodBiedry a06219aeeb fix(grid): filter dropdown installs the AutoFilter on the clicked table (#509) 2026-10-12 08:33:05 +02:00
MarekSpodBiedry 344888ceb5 Keep every range of a conditional format or validation sqref (#512) 2026-10-12 08:32:20 +02:00
MarekSpodBiedry 3f45edf144 Fix ribbon checkboxes sending the pre-click value (#520) 2026-10-12 08:31:39 +02:00
MarekSpodBiedry c13cda2229 Column sort rejects a zero row key with a parameter error instead of panicking (#534) 2026-10-12 08:31:04 +02:00
MarekSpodBiedry b5827f4198 Spelling: skip suggestions for words longer than 64 characters (#532) 2026-10-12 08:30:19 +02:00
MarekSpodBiedry d64b30b3a4 Funnel chart: hide value labels when data labels are off (#544) 2026-10-12 08:29:56 +02:00
MarekSpodBiedry 358334c755 Return #VALUE! from FIND and SEARCH when the start is past the text end (#541) 2026-10-12 08:29:56 +02:00
MarekSpodBiedry 2eb5ef5b0d Return #NUM! from PROB for zero probabilities (#539) 2026-10-12 08:29:48 +02:00
MarekSpodBiedry 30fe8c4cae Fix chart rasterizer panic on negative rectangle extents (#542) 2026-10-12 08:29:48 +02:00
MarekSpodBiedry 1f16c2e268 fix(calc): recalculate dependents of a cleared spill past 65536 cells (#558) 2026-10-12 08:29:06 +02:00
MarekSpodBiedry afe9a6301f SUBTOTAL 109-111 leave out nested subtotals like 1-11 (#556) 2026-10-12 08:28:53 +02:00
MarekSpodBiedry ea6bb98fc7 fix(xlsx): keep leading and trailing whitespace in document properties on reopen (#554) 2026-10-12 08:28:50 +02:00
MarekSpodBiedry 593776f0de Keep a defined name that shares a table's name when the table is renamed (#574) 2026-10-12 08:28:36 +02:00
MarekSpodBiedry c5d43a33e6 Reject table.resize to a range that does not overlap the table (#575) 2026-10-12 08:27:34 +02:00
MarekSpodBiedry afd61a7c4a Parse two-digit years in spaced month-name dates (#566) 2026-10-12 08:27:32 +02:00
MarekSpodBiedry a4c6ac3eff Reject out-of-range numeric filterBy columns instead of overflowing (#573) 2026-10-12 08:27:08 +02:00
webdevtodayjason 1ea44bf00b INDIRECT: R1C1 offsets past the sheet give #REF! instead of overflowing 2026-10-11 23:45:46 -05:00
nihilist-narwhal c7701f6e13 Functions given by name: GROUPBY(…,SUM), BYROW(…,SUM), MAP(…,ABS)
Excel lets a worksheet function be passed by name where a function is
expected. In GridCraft GROUPBY(A2:A9,B2:B9,SUM) and PIVOTBY gave #NAME?
(their documented form), and BYROW, BYCOL, MAP, REDUCE, SCAN and
MAKEARRAY gave #VALUE!. A name that is a worksheet function, and not a
LET/LAMBDA variable or a defined name, now works as a function there.
Reading a file, the _xleta. prefix Excel writes for these names is
removed.
2026-10-11 22:09:17 -05:00
webdevtodayjason 88cf4cf92d INDEX: a huge area number gives #REF! instead of overflowing 2026-10-11 21:32:01 -05:00
prxnvt 019d5de98e Data: Solver Answer and Limits reports
Builds on the Solver of #327. data.solver takes reports: [answer, limits]
and writes them on new sheets before the model's sheet once a solution
is found. The Answer report lists the objective and variable cells with
their original and final values and each constraint's value, formula,
status (Binding or Not Binding) and slack; cells are named by the
nearest labels to their left and above. The Limits report minimises and
maximises each variable with the others held at the solution, under the
model's constraints, and gives the objective at both ends (Simplex LP
and GRG Nonlinear, without integer constraints).

data.solverReport writes the reports for a given solution and model,
so data.solver's result (which now carries the original values and the
model) can be passed to it as is. The Solver Results dialog offers the
two reports; they are written after Keep Solver Solution or Restore
Original Values.
2026-10-11 22:25:08 -04:00
prxnvt fa2b9566fb Data: Solver Evolutionary method
Builds on the Solver of #327. method: evolutionary runs a genetic search
for models that aren't smooth: tournament selection, blend crossover and
Gaussian mutation inside the variable bounds, feasibility-first ranking
and integer rounding, then a compass search around the best member. It
stops when the population converges, when the best member stops
improving for a number of generations, or at the evaluation budget. A
seeded generator makes runs repeatable. Population size, mutation rate,
random seed, convergence and Require Bounds are options; the method and
its options are saved in the hidden solver_* names. The Solver
Parameters dialog offers the method.
2026-10-11 22:09:10 -04:00
webdevtodayjason fb7bcefaa4 OFFSET: huge offsets and sizes give #REF! instead of overflowing 2026-10-11 20:50:23 -05:00
Bryan MarkandClaude Opus 5.5 481f1c2b8b File view: the recent list tells when each workbook was last changed
The recent workbooks on the File view's Home and Open pages gain a
Modified column: "Just now", "5 min ago", "3 h ago", "Yesterday",
"4 days ago", "3 weeks ago", "5 months ago", "2 years ago", or
"Not available" for a file that can't be reached.

The file system is never asked while drawing. A thread asks for each
file's metadata and the list shows each answer as it comes back, so a
network drive that is asleep or a share that is away doesn't hold up
the view. One thread asks at a time; a view opened anew asks anew. Only
metadata is read: no workbook is opened until the user picks one.

The list tells ages rather than dates because the UI has no time zone
to tell a date by. From two days on a count is never one, so each
language needs one form of "days", "weeks", "months" and "years"; the
ten strings are in the nine catalogs, with the count placed by a {n}
in each, as "Page {page} of {pages}" does.

A long name or folder now stops short of the column instead of running
under it. The web build, which has no files to ask about, and a narrow
window show the list as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:55 +09:00
Dejan PortandClaude Sonnet 5 c59e94297c fix(i18n): correct scattered wrong French translations and add 2 missing keys
The completeness test's failure (missing "Different First Page") was
only the visible tip: fr.rs was missing that key and "Different Odd &
Even Pages" outright, but also had roughly 50 *present* keys mapped to
the wrong value — reading as a real, different English string's French
translation instead of its own. The completeness test only checks that
a translation exists and is non-empty, not that it's correct, so none
of this was caught before.

Examples found by inspection (key => previous wrong value => what that
value actually means):
  "Click to add data"  => "Fermer"                        (= Close)
  "Header"              => "Informations"                  (= Info)
  "Ruler"                => "Enregistrer sous"              (= Save As)
  "Info"                 => "Insérer les cellules copiées…" (= Insert Copied Cells…)
  "Animations"          => "Auteur"                        (= Author)
  "Auto"                 => "Retour"                        (= Back)
  "Previous Comment"    => "Imprimer tout le classeur"      (= Print Entire Workbook)

The wrong values aren't a local shuffle (adjacent keys swapped) — "Info"
drew a value that belongs near the start of the key list while "Info"
itself is near the end, so whatever produced this scattered entries
from unrelated parts of the file onto each other. Given the extent and
that it wasn't contained to one block, patching individual lines risked
missing more of them, so this regenerates every one of the 381 entries
from the English source text (same source list i18n-serbian used),
rather than auditing the existing file entry by entry.

Verified: the existing completeness test (translations_are_unique_...)
now passes end to end — it was failing on a clean main checkout before
this change, purely from the 2 missing keys. Full cargo test -p
gridcraft-ui-egui --lib: 75/75 (was 74/75 on main).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 02:22:00 +02:00
Dejan PortandClaude Sonnet 5 b60d641083 i18n: add Serbian (Latin script) interface translations
Adds Language::Sr ("sr"), following the existing per-language module
pattern (crates/ui-egui/src/i18n/<lang>.rs with a translate(text) ->
Option<&'static str>). All 381 strings in the JAPANESE master list are
covered, verified by the existing completeness test.

Latin script (sr-Latn), ekavian dialect — the common default for
digital/software Serbian. Cyrillic (sr-Cyrl) could follow as a
separate variant if wanted; this keeps the PR to one script, matching
how every other language here is a single variant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 02:14:17 +02:00
tydude001andClaude Opus 5.5 3a8f6f5aa5 AutoFilter: the value list scrolls across the menu's full width (Fixes #432)
The value list kept egui's default auto_shrink, so its scroll bar sat mid-menu after the longest
value and the wheel only scrolled it with the pointer over a value. Keep the height shrinking but
not the width: the scroll bar is at the menu's right edge and the wheel works anywhere over the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:04:55 -05:00
SteamSmithandClaude Opus 5.5 3424229ee3 io: remove the .sctmp temporary file when a save fails
write_file writes `<path>.sctmp` and renames it over the workbook. When the
write or the rename failed (a full disk, or the target locked by another
app such as Excel or a sync client), the temporary file was left beside the
user's workbook. Remove it on either failure and report the same error.

Adds io::tests::failed_save_leaves_no_temporary_file, which fails before
this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:59:42 -04:00
Dejan PortandClaude Sonnet 5 faba681c99 fix: select a newly inserted chart from Recommended Charts
Resubmit of the selection half of #224 against current main, as
requested — the Delete-key half was a duplicate of #219 and is
already merged.

Insert > Recommended Charts left the prior cell/range selection in
place instead of selecting the new chart, unlike every other object
insertion path (see insert.chart in dialogs.rs, which already selects
the chart it creates). With the selection left on the source data,
pressing Del right after inserting deleted that data and left an
empty chart behind (#214).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 01:31:29 +02:00
KitsuneSemCalda fd3d9f615a Fix OmaStore executable paths 2026-10-11 15:13:02 -03:00
KitsuneSemCalda 6d47626534 Add OmaStore manifest 2026-10-09 22:50:39 -03:00
173 changed files with 14291 additions and 1260 deletions
Generated
+18 -16
View File
@@ -1496,7 +1496,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"arboard",
"craft-print",
@@ -1508,6 +1508,8 @@ dependencies = [
"image",
"log",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"pollster 1.0.1",
"rfd",
"serde_json",
@@ -1516,7 +1518,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1528,7 +1530,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1539,7 +1541,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1551,7 +1553,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"js-sys",
"serde",
@@ -1561,7 +1563,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1582,7 +1584,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -1591,7 +1593,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -1599,7 +1601,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -1608,7 +1610,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1620,18 +1622,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.6.1"
version = "0.7.0"
[[package]]
name = "gridcraft-ui-egui"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"craft-ui",
"egui",
@@ -1653,7 +1655,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -1670,7 +1672,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"aes",
"gridcraft-core",
@@ -4524,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"gridcraft-engine",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.6.1"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+22 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,21 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
| 2026-10-12 | Security: cell hyperlinks open only http, https and mailto, after a confirmation; other schemes (file:, smb:, UNC, custom handlers) are refused (#777, part 1) |
| 2026-10-12 | Deleting several Ctrl-selected rows or columns removes them all in one undo step (#758) |
| 2026-10-12 | XLSX chart sheets are kept on open and save: the sheet, its drawing and chart (with style and colours) are written back unchanged in their place among the worksheets, and left out when the sheet they plot is deleted or renamed or a worksheet took their name (#727) |
| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) |
| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) |
| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) |
| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) |
| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) |
| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) |
| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) |
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
@@ -178,6 +193,12 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) |
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
+282 -11
View File
@@ -8,8 +8,9 @@
//! them are skipped.
//!
//! **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is
//! validated (content types cover every part, every internal relationship target exists) and read
//! back, and the two workbooks are compared feature by feature.
//! validated (content types cover every part, every internal relationship target exists, no kind of
//! relationship the source had is missing) and read back, and the two workbooks are compared feature
//! by feature.
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::{Cursor, Read as _};
@@ -38,6 +39,9 @@ const MAX_FILES: usize = 100_000;
const MAX_XML_PART: u64 = 64 * 1024 * 1024;
/// Most zip entries looked at while validating a written package.
const MAX_ENTRIES: usize = 200_000;
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
/// a cache Excel rebuilds, and GridCraft never writes it.
const REBUILT_KINDS: [&str; 1] = ["calcchain"];
#[derive(Clone, Copy, Debug)]
pub struct Options {
@@ -97,6 +101,9 @@ pub struct CalcReport {
/// aren't results, so every formula counts as no oracle.
pub stale_cache: bool,
pub volatile: usize,
/// Matched cells whose producer saved GridCraft's TRUE or FALSE as the number 1 or 0 (no
/// `t="b"`, as Business Central's Excel exports do); counted in `matched` too.
pub bool_as_number: usize,
/// The first `max_report` mismatches, primary ones first.
pub mismatches: Vec<Mismatch>,
pub functions: BTreeMap<String, FnStat>,
@@ -286,7 +293,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
calc.recalc_all(&mut fresh);
let trust_cache = !full_calc_on_load(bytes);
report.calc = oracle(&cached, &fresh, &calc, trust_cache, opts);
report.round_trip = round_trip(&fresh, opts);
report.round_trip = round_trip(&fresh, bytes, opts);
report
}
@@ -347,6 +354,13 @@ fn cell_name(wb: &Workbook, (sheet, c): Key) -> String {
format!("{}!{}", quote_sheet(name), c.a1())
}
/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a
/// logical result `t="b"`; a writer that leaves the type out (`<c><f>TRUE()</f><v>1</v></c>`, as
/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number.
pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool {
matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 })
}
/// Whether GridCraft's `actual` agrees with the producer's `expected`.
pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool {
match (expected, actual) {
@@ -458,7 +472,11 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo
}
None => vec!["(unparsed)".to_string()],
};
let ok = values_match(&cell.value, &actual, opts.tolerance);
let bool_as_number = bool_cached_as_number(&cell.value, &actual);
let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance);
if bool_as_number {
r.bool_as_number += 1;
}
for n in &funcs {
let st = r.functions.entry(n.clone()).or_default();
st.checked += 1;
@@ -514,8 +532,9 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo
// ---------------------------------------------------------------- round trip
/// Writes `wb` with GridCraft's XLSX writer, validates the package, reads it back and compares.
pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip {
/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against
/// `source` (the file `wb` was read from), reads it back and compares.
pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip {
let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") {
Ok(b) => b,
Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] },
@@ -525,6 +544,13 @@ pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip {
Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)),
Err(e) => package.push(|| e),
}
if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) {
for (key, kind) in &before {
if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) {
package.push(|| format!("lost relationship {kind}"));
}
}
}
let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) {
Ok((b, _)) => b,
Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] },
@@ -559,6 +585,14 @@ fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet<String>, b: &BTreeSet<Stri
}
}
/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number
/// itself is left out, so renumbering without changing the order isn't a difference.
fn cond_format_list(s: &Sheet) -> Vec<String> {
let mut cfs: Vec<_> = s.cond_formats.iter().collect();
cfs.sort_by_key(|c| c.priority);
cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect()
}
fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) {
if a != b {
d.push(|| format!("{sheet}: {a} → {b}"));
@@ -634,7 +668,12 @@ pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec<Categor
.collect()
};
diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb));
diff_count(&mut cond_formats, &name, sa.cond_formats.len(), sb.cond_formats.len());
let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb));
if cfa.len() != cfb.len() {
cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len()));
} else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) {
cond_formats.push(|| format!("{name}: {x} → {y}"));
}
diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len());
let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::<Vec<_>>();
let (ca, cb) = (chart_list(sa), chart_list(sb));
@@ -888,6 +927,77 @@ pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
Ok(issues)
}
/// Every relationship kind (last segment of the type, the same in the transitional and strict
/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
let names = zip_names(&mut zip);
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
let mut kinds = BTreeMap::new();
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue };
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
let target = attr(attrs, "Target").unwrap_or("");
if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') {
continue;
}
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
if !external
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
&& !holds_something(&mut zip, part, kind)
{
continue;
}
kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string());
}
}
Ok(kinds)
}
/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the
/// empty string for the package's `_rels/.rels`.
fn rels_source_dir(key: &str) -> Option<String> {
let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?;
Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string())
}
/// Whether a part has content: an element under its root, or for comments (whose authors list
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
use quick_xml::events::Event;
let Ok(f) = zip.by_name(name) else { return true };
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
"comments" => Some(b"comment"),
"vmldrawing" => Some(b"shape"),
_ => None,
};
let mut buf = Vec::new();
let mut in_root = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let found = match wanted {
Some(w) => local_name(e.name().as_ref()) == w,
None => in_root,
};
if found {
return true;
}
in_root = true;
}
Ok(Event::Eof) => return false,
Ok(_) => {}
Err(_) => return true,
}
buf.clear();
}
}
// ---------------------------------------------------------------- output
#[derive(Default)]
@@ -901,6 +1011,7 @@ struct Totals {
mismatched: usize,
no_oracle: usize,
volatile: usize,
bool_as_number: usize,
round_trip_failed: usize,
round_trip_differing: usize,
categories: BTreeMap<&'static str, usize>,
@@ -922,6 +1033,7 @@ fn totals(reports: &[FileReport]) -> Totals {
t.mismatched += r.calc.mismatched;
t.no_oracle += r.calc.no_oracle;
t.volatile += r.calc.volatile;
t.bool_as_number += r.calc.bool_as_number;
if r.round_trip.error.is_some() {
t.round_trip_failed += 1;
}
@@ -982,7 +1094,8 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
"warnings": r.warnings,
"calc": {
"formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched,
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "mismatches": mismatches, "functions": functions_json(&c.functions),
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number,
"mismatches": mismatches, "functions": functions_json(&c.functions),
"signatures": c.signatures,
},
"roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories},
@@ -996,7 +1109,7 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
"files": files,
"totals": {
"files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked,
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile,
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number,
"roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories,
},
"functions": functions_json(&t.functions),
@@ -1035,6 +1148,9 @@ fn print_human(reports: &[FileReport], opts: &Options) {
if c.stale_cache {
outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)");
}
if c.bool_as_number > 0 {
outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number);
}
for m in &c.mismatches {
let tag = if m.primary { "mismatch" } else { " (downstream)" };
outln!(
@@ -1131,6 +1247,9 @@ fn print_human(reports: &[FileReport], opts: &Options) {
t.no_oracle,
t.volatile
);
if t.bool_as_number > 0 {
outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number);
}
let cats: Vec<String> = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect();
outln!(
"round trip: {} of {} files differ{}{}",
@@ -1235,6 +1354,31 @@ mod tests {
assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
}
/// Business Central writes TRUE and FALSE as `<c><f>TRUE()</f><v>1</v></c>`, without `t="b"`:
/// the cache holds the right value under a number type, so it matches and is counted apart.
#[test]
fn booleans_cached_as_numbers_match_and_are_counted_apart() {
let wb = book(&[
("A1", "=TRUE()", n(1.0)),
("B1", "=FALSE()", n(0.0)),
("C1", "=TRUE()", Value::Bool(true)),
("D1", "=1=1", n(0.0)),
("E1", "=TRUE()+0", n(1.0)),
]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1));
// A cached 0 for TRUE is still a mismatch, and a number result is compared as a number.
assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1");
assert_eq!(r.calc.signatures.get("number → bool"), Some(&1));
assert_eq!(r.calc.bool_as_number, 2);
let v = report_json(std::slice::from_ref(&r), &Options::default());
assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2)));
assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true)));
assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false)));
assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true)));
assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0)));
}
#[test]
fn missing_cached_values_and_volatile_formulas_are_skipped() {
let wb =
@@ -1277,7 +1421,8 @@ mod tests {
fn round_trip_of_a_simple_book_is_clean() {
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
let rt = round_trip(&wb, &Options::default());
let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
let rt = round_trip(&wb, &source, &Options::default());
assert_eq!(rt.error, None);
assert!(rt.is_clean(), "{rt:?}");
assert!(rt.categories.iter().any(|c| c.name == "package"));
@@ -1303,6 +1448,27 @@ mod tests {
assert_eq!(get("tables").diffs, 0);
}
#[test]
fn diff_detects_changed_cond_format_rule() {
use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat};
let bar = |gradient: bool, priority: u32| CondFormat {
ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()],
rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true },
priority,
stop_if_true: false,
};
let with = |cf: CondFormat| {
let mut wb = book(&[("A1", "2", n(2.0))]);
wb.sheet_mut(0).unwrap().cond_formats.push(cf);
wb
};
let diffs =
|a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs;
assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0);
}
fn zip_of(parts: &[(&str, &str)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, body) in parts {
@@ -1342,6 +1508,98 @@ mod tests {
assert_eq!(validate_package(&own).unwrap(), Vec::<String>::new());
}
const WB_RELS: &str = "xl/_rels/workbook.xml.rels";
const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels";
const MADE_UP: &str = r#"<Relationship Id="rId90" Type="http://example.com/relationships/madeUp" Target="madeUp.xml"/>"#;
/// Not empty: an empty part isn't a loss.
const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", "<madeUp><item/></madeUp>");
/// `bytes` with relationships added to rels parts (created when missing) and `parts` added.
fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec<u8> {
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut pending: Vec<(&str, &str)> = rels.to_vec();
for i in 0..src.len() {
let mut f = src.by_index(i).unwrap();
let name = f.name().to_string();
let mut body = String::new();
f.read_to_string(&mut body).unwrap();
if let Some(at) = pending.iter().position(|(r, _)| *r == name) {
body = body.replace("</Relationships>", &format!("{}</Relationships>", pending.remove(at).1));
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(body.as_bytes()).unwrap();
}
let created = pending.iter().map(|(r, extra)| {
(*r, format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{extra}</Relationships>"#))
});
for (name, body) in created.collect::<Vec<_>>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) {
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(body.as_bytes()).unwrap();
}
out.finish().unwrap().into_inner()
}
/// The `package` examples of checking `source`.
fn package_of(source: &[u8]) -> Vec<String> {
let r = check_bytes("t.xlsx", source, &Options::default());
r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone()
}
fn plain() -> Vec<u8> {
gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap()
}
#[test]
fn package_reports_relationship_kinds_the_output_lost() {
// A kind GridCraft doesn't know is dropped on save; a calculation chain is never written
// (Excel rebuilds it), so its absence isn't a loss.
let calc_chain = r#"<Relationship Id="rId91" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" Target="calcChain.xml"/>"#;
let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "<calcChain/>")]);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean());
}
// In the three tests below the made-up kind stays lost, so the comparison is known to run.
#[test]
fn an_internal_link_written_as_a_relationship_is_not_a_loss() {
// openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`.
let link = r##"<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="#Sheet1!B2" TargetMode="External"/>"##;
let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn comments_without_a_comment_are_not_a_loss() {
let rels = r#"<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/>"#;
let comments = r#"<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList/></comments>"#;
let vml = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><o:shapelayout><o:idmap data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" path="m,l,21600r21600,l21600,xe"><v:path gradientshapeok="t"/></v:shapetype></xml>"#;
let source = with_rels(
&plain(),
&[(WB_RELS, MADE_UP), (SHEET_RELS, rels)],
&[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)],
);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn empty_drawings_and_person_lists_are_not_a_loss() {
let drawing = r#"<Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>"#;
let person =
r#"<Relationship Id="rId92" Type="http://schemas.microsoft.com/office/2017/10/relationships/person" Target="persons/person.xml"/>"#;
let source = with_rels(
&plain(),
&[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)],
&[
MADE_UP_PART,
("xl/drawings/drawing1.xml", r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"/>"#),
("xl/persons/person.xml", r#"<personList xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"/>"#),
],
);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn unopenable_files_are_reported() {
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
@@ -1362,7 +1620,19 @@ mod tests {
assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"]));
assert_eq!(
keys(&f["calc"]),
set(&["formulaCells", "checked", "matched", "mismatched", "noOracle", "staleCache", "volatile", "mismatches", "functions", "signatures"])
set(&[
"formulaCells",
"checked",
"matched",
"mismatched",
"noOracle",
"staleCache",
"volatile",
"boolAsNumber",
"mismatches",
"functions",
"signatures"
])
);
assert_eq!(f["calc"]["signatures"], json!({"number → number": 1}));
assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"]));
@@ -1401,6 +1671,7 @@ mod tests {
"mismatched",
"noOracle",
"volatile",
"boolAsNumber",
"roundTripFailed",
"roundTripDiffering",
"categoriesDiffering"
+1 -1
View File
@@ -277,7 +277,7 @@ fn convert(args: &[String]) -> Result<(), String> {
let mut s = open(input)?;
activate_sheet(&mut s, a.opt("sheet"))?;
let d = s.doc().map_err(|e| e.to_string())?;
let bytes = gridcraft_engine::io::save_bytes(&d.wb, out).map_err(|e| e.to_string())?;
let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?;
gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", bytes.len());
Ok(())
+1 -1
View File
@@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] }
+166
View File
@@ -0,0 +1,166 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//!
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
//! as PDF. The parent stores it and reports failures itself.
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the page
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
/// counts as saved under that name, as it would after Save on the desktop.
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// Names of the files handed to the parent since the last frame.
saved: Rc<RefCell<Vec<String>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
}
/// Send saved and exported files to the parent instead of downloading them, and hear files
/// from it.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
if let Err(e) = bridge.post_save(name, bytes) {
log::error!("couldn't hand {name} to the page: {e}");
}
}));
}
/// `?author=`: the host's name for the user signs new notes and comments.
pub fn set_author(app: &mut SheetApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.user_name = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
}
/// After each frame: a workbook handed to the parent this frame counts as saved under the
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
pub fn sync(&self, app: &mut SheetApp) {
let saved = std::mem::take(&mut *self.saved.borrow_mut());
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
&& let Some(doc) = app.session.active_mut()
{
doc.saved = doc.wb.clone();
if doc.path.is_none() {
doc.title = name.clone();
}
}
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
}
fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
self.saved.borrow_mut().push(name);
Ok(())
}
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("gridcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn is_workbook(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
+6 -1
View File
@@ -8,9 +8,14 @@
//! (bytes arrive asynchronously through `Services::inbox`); Save downloads the file; dropped
//! files are read asynchronously.
//!
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample.
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;
+33 -5
View File
@@ -25,6 +25,10 @@ pub fn start() {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
// workbooks in it and stores what it saves (`host`).
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
let app_bridge = bridge.clone();
let result = eframe::WebRunner::new()
.start(
canvas,
@@ -32,13 +36,18 @@ pub fn start() {
Box::new(move |cc| {
let inbox: Inbox = Inbox::default();
let mut session = Session::new();
let q = query();
// In host mode the parent sends the workbook to edit; no sample meanwhile.
let q = if app_bridge.is_some() { String::new() } else { query() };
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
let _ = session.execute("file.new", serde_json::json!({"sample": name}));
} else if q.contains("sample") {
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
}
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = SheetApp::new(session, services);
app.ui.system_theme = true;
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
@@ -50,10 +59,19 @@ pub fn start() {
app.ui.language = browser;
}
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
Ok(Box::new(WebShell { app, inbox }))
if let Some(bridge) = &app_bridge {
if let Some(author) = query_param("author") {
crate::host::Bridge::set_author(&mut app, &author);
}
bridge.post_ready();
}
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
}),
)
.await;
if let (Err(e), Some(bridge)) = (&result, &bridge) {
bridge.post_failed(&format!("{e:?}"));
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -67,9 +85,14 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
struct WebShell {
app: SheetApp,
inbox: Inbox,
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
@@ -98,6 +121,9 @@ impl eframe::App for WebShell {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
if let Some(bridge) = &self.bridge {
bridge.sync(&mut self.app);
}
}
}
@@ -144,8 +170,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
}
})),
open_url: Some(Box::new(|url: &str| {
if let Some(w) = web_sys::window() {
let _ = w.open_with_url_and_target(url, "_blank");
if let Some(w) = web_sys::window()
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
{
let _ = w.open_with_url_and_target(&url, "_blank");
}
})),
inbox: Some(inbox),
+10
View File
@@ -42,3 +42,13 @@ muda = { version = "0.21", default-features = false }
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit);
# pinned exactly to the same versions.
[target.'cfg(target_os = "macos")'.dev-dependencies]
objc2 = "=0.6.5"
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSMenuItem", "NSResponder"] }
[[test]]
name = "mac_menu_appkit"
harness = false
+51 -8
View File
@@ -135,10 +135,22 @@ fn save_prefs(app: &SheetApp) {
}
}
/// Read the system clipboard through the shared handle, creating it on first use.
fn read_clipboard<T>(
handle: &RefCell<Option<arboard::Clipboard>>,
read: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
) -> Option<T> {
let mut clipboard = handle.try_borrow_mut().ok()?;
if clipboard.is_none() {
*clipboard = arboard::Clipboard::new().ok();
}
read(clipboard.as_mut()?).ok()
}
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let paste_clipboard = clipboard.clone();
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
Services {
copy_html: Some(Box::new(move |html, text| {
let mut clipboard = clipboard.borrow_mut();
@@ -147,13 +159,8 @@ fn services() -> Services {
}
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
})),
paste_html: Some(Box::new(move || {
let mut clipboard = paste_clipboard.try_borrow_mut().ok()?;
if clipboard.is_none() {
*clipboard = arboard::Clipboard::new().ok();
}
clipboard.as_mut()?.get().html().ok()
})),
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
@@ -194,6 +201,13 @@ fn services() -> Services {
/// Opens a URL or file with the system's default application.
fn open_with_system(target: &str) {
// Last line of defence behind `link.open` and the click prompt: the system only ever gets
// http, https and mailto addresses.
let gridcraft_engine::links::LinkKind::External(target) = gridcraft_engine::links::classify(target) else {
log::warn!("refused to open {target:?}: only http, https and mailto links are opened");
return;
};
let target = target.as_str();
let cmd = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
@@ -369,3 +383,32 @@ fn main() -> eframe::Result<()> {
}
result
}
#[cfg(test)]
mod packaging_tests {
use gridcraft_engine::io::FileKind;
/// The text between `start` and `end` that follows each `marker` in `text`, sorted (e.g. the
/// `xlsb` in `Classes\.xlsb\OpenWithProgids`).
fn extensions(text: &str, marker: &str, start: &str, end: char) -> Vec<String> {
let mut v: Vec<String> = text.split(marker).skip(1).filter_map(|s| s.split_once(start)?.1.split(end).next()).map(str::to_string).collect();
v.sort();
v
}
/// macOS Open With (`CFBundleDocumentTypes`) offers GridCraft for the same files as Windows
/// Open with, and GridCraft opens each of them. #321 added `.xlsb` and `.ods` on Windows and
/// Linux only.
#[test]
fn macos_and_windows_offer_the_same_file_types() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let wxs = include_str!("../../../packaging/windows/gridcraft.wxs");
let mac = extensions(plist, "<key>CFBundleTypeExtensions</key>", "<string>", '<');
let win = extensions(wxs, "Key=\"Software\\Classes\\.", "", '\\');
assert!(mac.iter().any(|e| e == "xlsx"), "Info.plist.in extensions not found: {mac:?}");
assert_eq!(mac, win);
for ext in &mac {
assert!(FileKind::from_path(&format!("book.{ext}")).is_some(), "GridCraft doesn't open .{ext}");
}
}
}
+22
View File
@@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Close", "file.close", Some("CMD+W")),
("Save", "file.save", Some("CMD+S")),
("Save As…", "file.saveAs", Some("CMD+SHIFT+S")),
("Export as PDF…", "file.exportPdf", None),
("Export as CSV…", "dialog:saveCopy:csv", None),
("Save as Web Page…", "dialog:saveCopy:html", None),
("-", "", None),
@@ -215,6 +216,7 @@ impl NativeMenu {
]);
let _ = menu.append(&app_menu);
let mut theme_items = Vec::new();
let mut window_menu = None;
for (title, entries) in tree() {
let sub = Submenu::new(title, true);
for (label, id, acc) in entries {
@@ -237,8 +239,17 @@ impl NativeMenu {
let _ = sub.append(&item);
}
let _ = menu.append(&sub);
if title == "Window" {
window_menu = Some(sub);
}
}
menu.init_for_nsapp();
// NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the
// system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks
// the submenu up in the installed main menu.
if let Some(window) = window_menu {
window.set_as_windows_menu_for_nsapp();
}
let (tx, rx) = channel();
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
@@ -298,6 +309,8 @@ impl NativeMenu {
app.open_dialog(name, params);
} else if id == "edit.copy" || id == "edit.cut" {
app.copy_to_clipboard(ctx, &id);
} else if id == "edit.paste" {
app.paste_from_clipboard();
} else {
app.run_or_alert(&id, json!({}));
}
@@ -315,6 +328,15 @@ impl NativeMenu {
mod tests {
use super::*;
#[test]
fn file_menu_has_export_as_pdf_dispatching_the_command() {
let tree = tree();
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
assert_eq!(item.1, "file.exportPdf");
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
}
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
+34
View File
@@ -0,0 +1,34 @@
//! The macOS menu bar as AppKit sees it, on the main thread (`harness = false`): install it through
//! the real muda backend (`src/native_menu.rs`) and read NSApp back. The Window menu must be NSApp's
//! `windowsMenu`: that is where macOS adds Move & Resize, Fill and Center, the targets of the system
//! window tiling shortcuts (Fn+Ctrl+arrows, Fn+F), and lists the open windows (#716).
// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its
// unit tests compile here too without a harness to run them).
#[cfg(target_os = "macos")]
#[path = "../src/native_menu.rs"]
#[allow(unused_imports, dead_code)]
mod native_menu;
#[cfg(target_os = "macos")]
fn main() {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let nsapp = NSApplication::sharedApplication(mtm);
nsapp.finishLaunching();
let ctx = egui::Context::default();
let _menu = native_menu::NativeMenu::install(&ctx);
let bar = nsapp.mainMenu().expect("a main menu");
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
println!("mac_menu_appkit: ok");
}
#[cfg(not(target_os = "macos"))]
fn main() {}
+4
View File
@@ -61,3 +61,7 @@ sync_github_name = true
real_name = "James Symes"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+157 -52
View File
@@ -88,6 +88,8 @@ pub struct Evaluator<'h> {
struct FnCtx<'a, 'h> {
ev: &'a mut Evaluator<'h>,
/// The criteria argument of a database function, when it is a reference.
criteria: Option<Area>,
}
impl Ctx for FnCtx<'_, '_> {
@@ -100,6 +102,28 @@ impl Ctx for FnCtx<'_, '_> {
fn random(&mut self) -> f64 {
self.ev.host.random()
}
/// Excel's "formula as criteria": the formula is written for the first record, so its relative
/// references move down to `record`'s row, and the database's labels name its fields.
/// Known gap: the shifted references are not dependencies of the calling cell. One that lands
/// outside both the database and the criteria cell's own precedents (say `=G2<H2` with column H
/// outside the database) is read here but not tracked, so editing that cell doesn't recalculate
/// the D-function until something else does.
fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option<bool> {
let area = self.criteria?;
let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?;
let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?;
let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0);
let env = (0..db.cols)
.filter_map(|c| match db.get(0, c) {
Some(Value::Text(t)) if !t.trim().is_empty() => {
Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty))))
}
_ => None,
})
.collect();
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
}
}
impl<'h> Evaluator<'h> {
@@ -383,6 +407,7 @@ impl<'h> Evaluator<'h> {
};
}
let v = self.value(x);
let sys = self.host.workbook().date_system;
Ev::V(map_unary(
&v,
&|n| match op {
@@ -392,6 +417,7 @@ impl<'h> Evaluator<'h> {
UnOp::At | UnOp::Spill | UnOp::Trim(_) => Value::number(n),
},
op == UnOp::Plus,
sys,
))
}
@@ -465,9 +491,22 @@ impl<'h> Evaluator<'h> {
}
}
_ => {
let x = self.value(a);
let y = self.value(b);
Ev::V(binary_values(op, &x, &y))
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
// of one level of recursion per operator (past the depth limit it was #CALC!).
let mut rest = vec![(op, b)];
let mut left = a;
while let Expr::Binary(o, l, r) = left
&& !matches!(o, BinOp::Range | BinOp::Union | BinOp::Intersect)
{
rest.push((*o, r.as_ref()));
left = l.as_ref();
}
let mut acc = self.value(left);
while let Some((o, r)) = rest.pop() {
let y = self.value(r);
acc = binary_values(o, &acc, &y, self.host.workbook().date_system);
}
Ev::V(acc)
}
}
}
@@ -579,7 +618,7 @@ impl<'h> Evaluator<'h> {
let value = self.value(a);
evaluated.push(Arg { value, from_ref: false });
}
let mut ctx = FnCtx { ev: self };
let mut ctx = FnCtx { ev: self, criteria: None };
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
}
let i = match index.to_number() {
@@ -737,6 +776,20 @@ impl<'h> Evaluator<'h> {
Ev::V(Value::Bool(r))
}
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
"GROUPBY" | "PIVOTBY" => {
// The aggregation may be a function given by name (`SUM`): pass it as its name.
let i = if name == "GROUPBY" { 2 } else { 3 };
match args.get(i).and_then(|a| self.function_by_name(a)) {
Some(f) => {
let mut args = args.to_vec();
if let Some(a) = args.get_mut(i) {
*a = Expr::Text(f.into());
}
self.builtin(name, &args)
}
None => self.builtin(name, args),
}
}
"NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => {
self.host.note_volatile();
self.builtin(name, args)
@@ -762,8 +815,17 @@ impl<'h> Evaluator<'h> {
let mut evaluated = Vec::with_capacity(args.len());
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
let mut criteria_shape = None;
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
let mut criteria = None;
for (i, a) in args.iter().enumerate() {
let mut ev = self.eval(a);
if spec.category == gridcraft_functions::Category::Database
&& i == 2
&& let Ev::R(areas) = &ev
&& let [area] = areas.as_slice()
{
criteria = Some(*area);
}
if resizes_sum_range(name, args.len()) {
if i == 0 {
criteria_shape = ev_shape(&ev);
@@ -791,7 +853,7 @@ impl<'h> Evaluator<'h> {
};
evaluated.push(Arg { value, from_ref });
}
let mut ctx = FnCtx { ev: self };
let mut ctx = FnCtx { ev: self, criteria };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
@@ -845,13 +907,13 @@ impl<'h> Evaluator<'h> {
};
let args = [Arg { value, from_ref }];
if kind != Agg::Count {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) {
Value::Number(n) => acc.sum += n,
other => return Err(Ev::V(other)),
}
}
if kind != Agg::Sum {
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) {
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) {
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
other => return Err(Ev::V(other)),
}
@@ -860,7 +922,12 @@ impl<'h> Evaluator<'h> {
}
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
if args.len() > l.params.len() {
// Too many or too few argument positions is #VALUE!. An explicitly empty position, as in
// `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()`
// parses to no arguments, which for a one-parameter lambda can also be read as one empty
// position, so that call still binds the parameter as omitted.
let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1);
if !positions_ok {
return err(CellError::Value);
}
let mut bound = Vec::with_capacity(l.params.len());
@@ -903,7 +970,26 @@ impl<'h> Evaluator<'h> {
r
}
fn lambda_arg(&mut self, e: Option<&Expr>) -> Option<Arc<Lambda>> {
/// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`,
/// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has
/// that name.
fn function_by_name(&self, e: &Expr) -> Option<String> {
let Expr::Name(n) = e else { return None };
if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() {
return None;
}
let u = n.to_ascii_uppercase();
gridcraft_functions::lookup(&u).map(|_| u)
}
/// The function argument of a LAMBDA helper; a function given by name takes `arity`
/// arguments.
fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option<Arc<Lambda>> {
if let Some(f) = e.and_then(|x| self.function_by_name(x)) {
let params: Vec<String> = (0..arity).map(|i| format!("_xleta.{i}")).collect();
let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect());
return Some(Arc::new(Lambda { params, body, env: Vec::new() }));
}
match e.map(|x| self.eval(x)) {
Some(Ev::L(l)) => Some(l),
_ => None,
@@ -922,7 +1008,7 @@ impl<'h> Evaluator<'h> {
if args.len() < 2 {
return err(CellError::Value);
}
let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) };
let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) };
let arrays: Vec<Arc<Array>> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect();
let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1);
let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1);
@@ -945,7 +1031,7 @@ impl<'h> Evaluator<'h> {
}
let mut acc = self.value(&args[0]);
let arr = as_array(self.value(&args[1]));
let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) };
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
if l.params.len() != 2 {
return err(CellError::Value);
}
@@ -969,7 +1055,7 @@ impl<'h> Evaluator<'h> {
return err(CellError::Value);
}
let arr = as_array(self.value(&args[0]));
let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) };
let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) };
let Some(p) = l.params.first().cloned() else { return err(CellError::Value) };
let by_row = name == "BYROW";
let n = if by_row { arr.rows } else { arr.cols };
@@ -998,7 +1084,7 @@ impl<'h> Evaluator<'h> {
if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 {
return err(CellError::Value);
}
let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) };
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
if l.params.len() != 2 {
return err(CellError::Value);
}
@@ -1054,10 +1140,10 @@ impl<'h> Evaluator<'h> {
if h == 0 || w == 0 {
return err(CellError::Ref);
}
let r0 = base.range.start.row as i64 + rows;
let c0 = base.range.start.col as i64 + cols;
let (r0, r1) = if h > 0 { (r0, r0 + h - 1) } else { (r0 + h + 1, r0) };
let (c0, c1) = if w > 0 { (c0, c0 + w - 1) } else { (c0 + w + 1, c0) };
let r0 = (base.range.start.row as i64).saturating_add(rows);
let c0 = (base.range.start.col as i64).saturating_add(cols);
let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) };
let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) };
if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 {
return err(CellError::Ref);
}
@@ -1081,8 +1167,18 @@ impl<'h> Evaluator<'h> {
v.scalar().to_bool().unwrap_or(true)
}
};
let text = if a1 { text } else { r1c1_to_a1(&text, self.at).unwrap_or(text) };
// With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference.
let (text, a1_ok) = if a1 {
(text, true)
} else {
match r1c1_to_a1(&text, self.at) {
Ok(Some(a1_text)) => (a1_text, true),
Ok(None) => (text, false),
Err(e) => return err(e),
}
};
match gridcraft_formula::parse(&text) {
Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref),
Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) {
r @ Ev::R(_) => r,
_ => err(CellError::Ref),
@@ -1152,7 +1248,8 @@ impl<'h> Evaluator<'h> {
}
match src {
Ev::R(areas) => {
let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) };
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
let r = a.range;
// A single row or column with one index picks along it.
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
@@ -1221,7 +1318,7 @@ impl<'h> Evaluator<'h> {
}
}
}
let mut ctx = FnCtx { ev: self };
let mut ctx = FnCtx { ev: self, criteria: None };
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
Value::Number(p) if p >= 1.0 => {
let k = p as u32 - 1;
@@ -1275,7 +1372,7 @@ impl<'h> Evaluator<'h> {
_ => "v",
}))
}
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())),
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
"filename" => Ev::V(Value::text(String::new())),
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
@@ -1290,7 +1387,9 @@ impl<'h> Evaluator<'h> {
}
fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev {
if args.len() < 2 {
// SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1.
let min_args = if name == "AGGREGATE" { 3 } else { 2 };
if args.len() < min_args {
return err(CellError::Value);
}
let code = match self.arg_num(args, 0, 0.0) {
@@ -1315,7 +1414,8 @@ impl<'h> Evaluator<'h> {
// SUBTOTAL documentation); 101-111 also leave out rows hidden by hand.
let skip_filtered = name == "SUBTOTAL";
let skip_errors = matches!(options, 2 | 3 | 6 | 7);
let skip_nested = options <= 3;
// SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3.
let skip_nested = name == "SUBTOTAL" || options <= 3;
let mut values = Vec::new();
let mut k = None;
let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest };
@@ -1406,37 +1506,42 @@ fn same_kind(a: &Value, b: &Value) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty())
}
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)).
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option<String> {
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is
/// not R1C1; `Err` means it is R1C1 but points past the sheet.
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result<Option<String>, CellError> {
let (sheet, body) = match s.rsplit_once('!') {
Some((sh, b)) => (format!("{sh}!"), b),
None => (String::new(), s),
};
let conv = |part: &str| -> Option<String> {
let conv = |part: &str| -> Result<Option<String>, CellError> {
let u = part.to_ascii_uppercase();
let rest = u.strip_prefix('R')?;
let (rpart, cpart) = rest.split_once('C')?;
let num = |p: &str, base: u32| -> Option<(u32, bool)> {
if p.is_empty() {
return Some((base, false));
}
if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
let d: i64 = inner.parse().ok()?;
let v = base as i64 + d;
return if v >= 0 { Some((v as u32, false)) } else { None };
}
let v: u32 = p.parse().ok()?;
Some((v.checked_sub(1)?, true))
let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) };
// `None`: not R1C1. `Some(None)`: R1C1, but past the sheet.
let num = |p: &str, base: u32, max: u32| -> Option<Option<(u32, bool)>> {
let (v, absolute) = if p.is_empty() {
(Some(i64::from(base)), false)
} else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
(i64::from(base).checked_add(inner.parse().ok()?), false)
} else {
(p.parse::<u32>().ok()?.checked_sub(1).map(i64::from), true)
};
Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute)))
};
let (r, ra) = num(rpart, at.row)?;
let (c, ca) = num(cpart, at.col)?;
Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))
let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) };
let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) };
Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1)))
};
let out = match body.split_once(':') {
Some((a, b)) => format!("{}:{}", conv(a)?, conv(b)?),
None => conv(body)?,
Some((a, b)) => match (conv(a)?, conv(b)?) {
(Some(a), Some(b)) => format!("{a}:{b}"),
_ => return Ok(None),
},
None => match conv(body)? {
Some(x) => x,
None => return Ok(None),
},
};
Some(format!("{sheet}{out}"))
Ok(Some(format!("{sheet}{out}")))
}
/// The aggregates that can fold a reference without building an array.
@@ -1492,12 +1597,12 @@ impl Acc {
// ---------------------------------------------------------------- operators
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value {
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value {
match v {
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus)).collect() }),
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }),
Value::Error(e) => Value::Error(*e),
Value::Text(_) if plus => v.clone(),
other => match other.to_number() {
other => match other.to_number_in(sys) {
Ok(n) => f(n),
Err(e) => Value::Error(e),
},
@@ -1550,18 +1655,18 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
Value::from(Array { rows, cols, data })
}
pub fn binary_values(op: BinOp, x: &Value, y: &Value) -> Value {
zip2(x, y, |a, b| binary_scalar(op, a, b))
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value {
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
if let Value::Error(e) = a {
return Value::Error(*e);
}
if let Value::Error(e) = b {
return Value::Error(*e);
}
let num = |f: fn(f64, f64) -> Value| match (a.to_number(), b.to_number()) {
let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) {
(Ok(x), Ok(y)) => f(x, y),
(Err(e), _) | (_, Err(e)) => Value::Error(e),
};
+2 -2
View File
@@ -898,11 +898,11 @@ impl Calc {
// Spill areas of changed anchors also change.
for &k in changed {
if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) {
seeds.extend(r.iter().take(65536).map(|c| (k.0, c)));
seeds.extend(r.iter().map(|c| (k.0, c)));
}
}
for (si, r) in &cleared {
seeds.extend(r.iter().take(65536).map(|c| (*si, c)));
seeds.extend(r.iter().map(|c| (*si, c)));
}
let t0 = prof_now();
let dirty = self.dirty_closure(wb, &seeds);
+299 -1
View File
@@ -157,6 +157,21 @@ fn index_with_arrays_of_positions() {
assert_eq!(t.num("E7"), 6.0);
}
#[test]
fn index_area_number_overflow() {
let mut t = T::new();
t.set("A1", "7");
t.set("D1", "9");
for (area, expected) in [("1", 7.0), ("2", 9.0)] {
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
assert_eq!(t.num("G5"), expected, "area {area}");
}
for area in ["3", "1E300", "-1", "-1E300"] {
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
assert_eq!(t.get("G5"), Value::Error(CellError::Ref), "area {area}");
}
}
#[test]
fn errors_and_cycles() {
let mut t = T::new();
@@ -167,6 +182,11 @@ fn errors_and_cycles() {
assert!(t.get("B1").is_error());
t.set("C1", "=NOSUCHFN(1)");
assert_eq!(t.get("C1"), Value::Error(CellError::Name));
// `_xludf.` marks a user-defined function, never the built-in of the same name (#726).
for f in ["=_xludf.CONCAT(\"a\",\"b\")", "=_xludf.SUM(1,2)", "=_xludf.EOMONTH()"] {
t.set("C3", f);
assert_eq!(t.get("C3"), Value::Error(CellError::Name), "{f}");
}
t.set("C2", "=1+");
assert_eq!(t.get("C2"), Value::Error(CellError::Name));
}
@@ -270,6 +290,26 @@ fn circular_references_without_iterative_calculation() {
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
}
#[test]
fn date_text_coerces_in_the_workbook_date_system() {
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
let mut t = T::new();
t.wb.date_system = sys;
t.set("A1", "'1/1/1904");
t.set("B1", "=A1+0");
t.set("C1", "=VALUE(A1)");
t.set("D1", "=-A1");
t.set("E1", "=DATEVALUE(A1)");
t.set("F1", "=NUMBERVALUE(A1)");
assert_eq!(t.get("A1"), Value::from("1/1/1904"));
assert_eq!(t.num("B1"), serial, "{sys:?}");
assert_eq!(t.num("C1"), serial, "{sys:?}");
assert_eq!(t.num("D1"), -serial, "{sys:?}");
assert_eq!(t.num("E1"), serial, "{sys:?}");
assert_eq!(t.num("F1"), serial, "{sys:?}");
}
}
#[test]
fn operators() {
let mut t = T::new();
@@ -346,6 +386,8 @@ fn arrays_do_not_spill_in_tables() {
last_col: false,
filter_button: false,
filter: vec![],
table_type: None,
connection_id: None,
});
t.set("C4", "=SEQUENCE(2)");
t.set("E1", "=SEQUENCE(4)");
@@ -403,6 +445,22 @@ fn references_functions() {
assert_eq!(t.num("B3"), 50.0);
t.set("B4", "=INDIRECT(\"A\"&4)");
assert_eq!(t.num("B4"), 40.0);
// With a1 = FALSE the text must be R1C1: A1-style text is #REF!, not read as A1.
t.set("C1", "=INDIRECT(\"R3C1\",FALSE)");
assert_eq!(t.num("C1"), 30.0);
t.set("C2", "=INDIRECT(\"A3\",FALSE)");
assert_eq!(t.get("C2"), Value::Error(CellError::Ref));
// Defined names don't depend on the reference style.
t.wb.names.push(gridcraft_model::DefinedName {
name: "Third".into(),
scope: None,
formula: "Sheet1!$A$3".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
t.set("C3", "=INDIRECT(\"Third\",FALSE)");
assert_eq!(t.num("C3"), 30.0);
t.set("B5", "=SUM(A1:INDEX(A1:A4,3))");
assert_eq!(t.num("B5"), 60.0);
t.set("B6", "=SUM(A:A)");
@@ -412,6 +470,37 @@ fn references_functions() {
assert_eq!(t.num("B6"), 105.0);
}
#[test]
fn indirect_r1c1_out_of_bounds() {
for reference in ["R[9223372036854775807]C", "RC[9223372036854775807]", "R[4294967296]C", "R[4294967291]C", "R[2000000]C", "RC[20000]"] {
let mut t = T::new();
t.set("D5", &format!("=ROW(INDIRECT(\"{reference}\",FALSE))"));
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
}
}
#[test]
fn indirect_r1c1_past_the_sheet_does_not_fall_back_to_a1() {
let mut t = T::new();
t.set("RC16385", "78");
for reference in ["RC16385", "rc16385", "RC1:RC16385"] {
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
}
}
#[test]
fn indirect_r1c1_valid_references() {
let mut t = T::new();
t.set("E6", "11");
t.set("A1", "22");
t.set("D4", "33");
for (reference, expected) in [("R[1]C[1]", 11.0), ("R1C1", 22.0), ("R[-1]C", 33.0)] {
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
assert_eq!(t.num("D5"), expected, "{reference}");
}
}
#[test]
fn relative_names() {
let mut t = T::new();
@@ -501,6 +590,59 @@ fn let_lambda() {
assert_eq!(t.num("A4"), 14.0);
}
#[test]
fn functions_given_by_name() {
let mut t = T::new();
for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() {
t.set(&format!("A{}", i + 1), k);
t.set(&format!("B{}", i + 1), &v.to_string());
}
t.set("D1", "=BYROW(B1:B3,SUM)");
assert_eq!(t.num("D2"), -2.0);
t.set("E1", "=MAP(B1:B3,ABS)");
assert_eq!(t.num("E2"), 2.0);
t.set("F1", "=REDUCE(0,B1:B3,SUM)");
assert_eq!(t.num("F1"), 2.0);
t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)");
assert_eq!(t.get("G1"), Value::from("a"));
assert_eq!(t.num("H1"), 4.0);
// A LET variable of that name is still the variable.
t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))");
assert_eq!(t.num("J1"), 20.0);
}
#[test]
fn lambda_argument_count() {
let mut t = T::new();
t.wb.names.push(gridcraft_model::DefinedName {
name: "AddPair".into(),
scope: None,
formula: "LAMBDA(a,b,a+b)".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
let omit = "LAMBDA(x,y,IF(ISOMITTED(y),\"Missing\",x+y))";
t.set("A1", "=LAMBDA(a,b,a+b)(2)");
t.set("A2", "=LAMBDA(a,b,a+b)(2,3,4)");
t.set("A3", &format!("={omit}(1,)"));
t.set("A4", &format!("={omit}(1)"));
t.set("A5", &format!("={omit}(1,2)"));
t.set("A6", "=LAMBDA(7)()");
t.set("A7", "=AddPair(2)");
t.set("A8", "=AddPair(2,3)");
assert_eq!(t.get("A1"), Value::Error(CellError::Value));
assert_eq!(t.get("A2"), Value::Error(CellError::Value));
assert_eq!(t.get("A3"), Value::Text("Missing".into()));
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
assert_eq!(t.num("A5"), 3.0);
assert_eq!(t.num("A6"), 7.0);
assert_eq!(t.get("A7"), Value::Error(CellError::Value));
assert_eq!(t.num("A8"), 5.0);
t.set("A9", "=LAMBDA(x,ISOMITTED(x))()");
assert_eq!(t.get("A9"), Value::Bool(true));
}
#[test]
fn names_and_sheets() {
let mut t = T::new();
@@ -549,6 +691,17 @@ fn text_function() {
assert_eq!(t.get("A1"), Value::from("1,234.50"));
}
#[test]
fn long_operator_chains() {
let mut t = T::new();
t.set("A1", "1");
// 1,000 additions: a formula of 3,000 characters, well within Excel's 8,192.
t.set("B1", &format!("={}", vec!["A1"; 1000].join("+")));
assert_eq!(t.num("B1"), 1000.0);
t.set("B2", &format!("=5{}", "+1-1".repeat(600)));
assert_eq!(t.num("B2"), 5.0);
}
#[test]
fn subtotal_skips_hidden() {
let mut t = T::new();
@@ -562,6 +715,29 @@ fn subtotal_skips_hidden() {
assert_eq!(t.num("B2"), 6.0);
}
#[test]
fn subtotal_109_skips_nested_subtotal() {
let mut t = T::new();
t.set("A1", "2");
t.set("A2", "=SUBTOTAL(9,A1)");
t.set("C1", "=SUBTOTAL(109,A1:A2)");
t.set("C2", "=SUBTOTAL(9,A1:A2)");
t.set("C3", "=SUBTOTAL(109,A1)");
assert_eq!(t.num("C1"), 2.0);
assert_eq!(t.num("C2"), 2.0);
assert_eq!(t.num("C3"), 2.0);
// Manually hidden rows are still left out by 109 only.
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
t.set("A3", "=SUBTOTAL(9,A1:A2)");
t.wb.sheet_mut(0).unwrap().rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
t.set("B1", "=SUBTOTAL(109,A1:A3)");
t.set("B2", "=SUBTOTAL(9,A1:A3)");
assert_eq!(t.num("B1"), 1.0);
assert_eq!(t.num("B2"), 3.0);
}
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
/// under a filtered table adds only the visible rows).
@@ -581,7 +757,14 @@ fn subtotal_skips_filtered_rows() {
range: gridcraft_core::RangeRef::parse("A1:A7").unwrap(),
header_row: true,
totals_row: false,
columns: vec![gridcraft_model::TableColumn { name: "Amount".into(), totals: Default::default(), totals_label: None, formula: None }],
columns: vec![gridcraft_model::TableColumn {
name: "Amount".into(),
totals: Default::default(),
totals_label: None,
formula: None,
unique_name: None,
xml: None,
}],
style: String::new(),
banded_rows: false,
banded_cols: false,
@@ -589,6 +772,8 @@ fn subtotal_skips_filtered_rows() {
last_col: false,
filter_button: true,
filter: crit(),
table_type: None,
connection_id: None,
});
sh.autofilter = Some(AutoFilter { range: gridcraft_core::RangeRef::parse("C1:C7").unwrap(), criteria: crit() });
// Rows 3 and 5 are filtered out; row 9 (outside both filters) is hidden by hand.
@@ -608,6 +793,23 @@ fn subtotal_skips_filtered_rows() {
}
}
#[test]
fn aggregate_without_data_argument_is_an_error() {
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
t.set("D1", "=AGGREGATE(9,0)");
t.set("D2", "=AGGREGATE(4,0)");
t.set("D3", "=AGGREGATE(9,0,A1:A2)");
t.set("D4", "=AGGREGATE(9,0,A3:A4)");
t.set("D5", "=AGGREGATE(9,0,A1:A2,A1:A2)");
assert_eq!(t.get("D1"), Value::Error(CellError::Value));
assert_eq!(t.get("D2"), Value::Error(CellError::Value));
assert_eq!(t.num("D3"), 3.0);
assert_eq!(t.num("D4"), 0.0);
assert_eq!(t.num("D5"), 6.0);
}
#[test]
fn recalc_all_after_load() {
let mut t = T::new();
@@ -968,3 +1170,99 @@ fn trim_references() {
t.set("K20", "=SUM(Q1:.Q9)");
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
}
#[test]
fn cell_width_is_the_column_width_dialog_value_rounded() {
// #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them
// (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64).
let mut t = T::new();
let sh = t.wb.sheet_mut(0).unwrap();
for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] {
sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() });
}
for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] {
t.set(at, &format!("=CELL(\"width\",{col}1)"));
}
assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]);
}
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding
/// a formula under a label that names no field is evaluated for every record.
#[test]
fn database_functions_with_computed_criteria() {
let mut t = T::new();
let db = [
["name", "age", "size", "headgear", "glasses"],
["Huey", "6", "45", "cap~", "FALSE"],
["Dewey", "7", "fourty-six", "cap?", "FALSE"],
["Louie", "8", "47", "cap*", "FALSE"],
["Donald", "32", "70", "sailor's hat", "FALSE"],
["Scrooge", "62", "65", "top hat", "TRUE"],
];
for (r, row) in db.iter().enumerate() {
for (c, v) in row.iter().enumerate() {
t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v);
}
}
t.set("G45", "7");
let cases = [
// Relative references are to the first record's row and move to each record's row;
// `$G$45` stays put.
("G", "=G2<$G$45", "Dewey"),
// Column labels stand for the record's fields.
("H", "=glasses=TRUE", "Scrooge"),
("I", "=age<7", "Huey"),
("J", "=size/7=10", "Donald"),
// An absolute reference to a cell that is not the first record's.
("K", "=H3=$L$1", "Donald"),
];
t.set("L1", "70");
for (i, (col, formula, _)) in cases.iter().enumerate() {
t.set(&format!("{col}46"), "Formula");
t.set(&format!("{col}47"), formula);
t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)"));
}
for (i, (_, formula, want)) in cases.iter().enumerate() {
assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}");
}
// Every database function takes them, ANDed with the other criteria of their row.
// Text sorts after numbers, so Dewey's "fourty-six" is > 40.
t.set("L47", "=size>40");
t.set("M46", "age");
t.set("M47", ">6");
t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)");
t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)");
t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)");
assert_eq!(t.num("B51"), 1.0);
assert_eq!(t.num("B52"), 7.0);
assert_eq!(t.num("B53"), 4.0);
}
#[test]
fn offset_extreme_arguments_return_ref() {
let wb = Workbook::new();
for formula in [
"=OFFSET(B2,1E300,0)",
"=OFFSET(B2,0,1E300)",
"=OFFSET(B2,0,0,1E300,1)",
"=OFFSET(B2,0,0,1,1E300)",
"=OFFSET(B2,-1E300,0)",
"=OFFSET(B2,0,-1E300)",
"=OFFSET(B2,0,0,-1E300,1)",
"=OFFSET(B2,0,0,1,-1E300)",
"=OFFSET(B2,1E300,0,-1E300,1)",
"=OFFSET(B2,0,1E300,1,-1E300)",
"=OFFSET(B2,-1E300,0,-1E300,1)",
] {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Error(CellError::Ref), "{formula}");
}
for (formula, expected) in [
("=ROW(OFFSET(B2,1.9,0))", 3.0),
("=COLUMN(OFFSET(B2,0,-1.9))", 1.0),
("=ROWS(OFFSET(B2,0,0,-2,1))", 2.0),
("=COLUMNS(OFFSET(B2,0,0,1,-2))", 2.0),
("=ROWS(OFFSET(A1,0,0,1048576,1))", 1048576.0),
] {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ impl Fmt {
pub fn new(code: &str) -> Fmt {
let t = code.trim();
let general = t.is_empty() || t.eq_ignore_ascii_case("general");
Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) }
Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) }
}
/// Formats `v`; `step` (axis labels) fixes the decimals for General.
pub fn format(&self, v: f64, step: Option<f64>) -> String {
+3
View File
@@ -124,6 +124,9 @@ impl Measure for ApproxMeasure {
pub struct ChartData {
pub categories: Vec<String>,
pub series: Vec<SeriesData>,
/// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
#[serde(default)]
pub palette: Vec<Rgba>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+2 -1
View File
@@ -138,7 +138,8 @@ fn circle(cx: f32, cy: f32, r: f32) -> Vec<[f32; 2]> {
}
fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> {
let r = r.clamp(0.0, w.min(h) / 2.0);
// A negative or NaN extent gives an upper bound of 0 (clamp panics if max < min or NaN).
let r = r.clamp(0.0, (w.min(h) / 2.0).max(0.0));
if r < 0.5 {
return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
}
+32 -15
View File
@@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
fn point_color(data: &ChartData, j: usize) -> Rgba {
match (j, data.series.first()) {
(0, Some(s)) => s.color,
_ => series_color(&Theme::default(), j),
_ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
}
}
@@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
fn sanitize(d: &ChartData) -> ChartData {
ChartData {
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
series: d
.series
@@ -254,7 +255,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
Doughnut => pie(ctx, chart, data, area, 0.5, false),
Sunburst => pie(ctx, chart, data, area, 0.2, true),
Treemap => treemap(ctx, chart, data, area),
Funnel => funnel(ctx, data, area),
Funnel => funnel(ctx, chart, data, area),
Radar => radar(ctx, chart, data, area),
_ => {
let area = axis_titles(ctx, chart, area);
@@ -266,8 +267,8 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area),
Histogram => histogram(ctx, chart, data, area),
Waterfall => waterfall(ctx, chart, data, area),
BoxWhisker => box_whisker(ctx, data, area),
Stock => stock(ctx, data, area),
BoxWhisker => box_whisker(ctx, chart, data, area),
Stock => stock(ctx, chart, data, area),
_ => cartesian(ctx, chart, data, area, false),
}
}
@@ -970,6 +971,8 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
let nslots = slot_keys.len().max(1) as f32;
let bw = cw / (nslots + 1.5);
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
let mut area_labels = Vec::new();
// Areas first, then columns, then lines on top.
for pass in 0..3 {
@@ -998,6 +1001,13 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
let c = center(j);
top.push([c, vpos(t, &sc)]);
base.push([c, vpos(b, &sc)]);
// Source values (not stack totals), just above the top edge like the line labels.
if labels_on
&& sp.is_some()
&& let Some(v) = s.values.get(j).copied().flatten()
{
area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None)));
}
}
if top.len() == 1 {
// A single point: give it some width.
@@ -1072,6 +1082,9 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
}
}
}
for (x, y, t) in area_labels.drain(..) {
ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
}
}
// Trendlines on top: x is the category number 1..n.
let mut trend_labels = Vec::new();
@@ -1201,10 +1214,12 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
bins = 1;
}
let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 };
// Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value
// equal to an edge belongs to the bin that ends there.
let edges: Vec<f64> = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect();
let mut counts = vec![0usize; bins];
for v in &vals {
let b = if width > 0.0 { ((v - lo) / width).floor() } else { 0.0 };
let b = if b.is_finite() && b > 0.0 { (b as usize).min(bins - 1) } else { 0 };
let b = edges.partition_point(|e| e < v).min(bins - 1);
if let Some(c) = counts.get_mut(b) {
*c += 1;
}
@@ -1213,7 +1228,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let labels: Vec<String> = (0..bins)
.map(|b| {
let a = lo + b as f64 * width;
let e = if b + 1 == bins { hi.max(a) } else { a + width };
let e = edges.get(b).copied().unwrap_or(a);
let open = if b == 0 { '[' } else { '(' };
format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0)))
})
@@ -1301,7 +1316,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
// ---------------------------------------------------------------- funnel
fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) {
fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let n = data.categories.len().max(s.values.len()).min(MAX_CATS);
if n == 0 {
@@ -1331,9 +1346,11 @@ fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) {
}
if show {
let yc = y + bh / 2.0;
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
if chart.data_labels {
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
}
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
}
@@ -1557,12 +1574,12 @@ fn quantile(sorted: &[f64], q: f64) -> f64 {
a + (b - a) * frac
}
fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) {
fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let labels: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true);
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1601,7 +1618,7 @@ fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) {
// ---------------------------------------------------------------- stock
fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) {
fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
@@ -1610,7 +1627,7 @@ fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) {
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true);
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
+5 -2
View File
@@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16;
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
/// `series_color` repeats after this many colours (six accents × the tints).
pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len();
/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them.
pub fn series_color(theme: &Theme, i: usize) -> Rgba {
@@ -145,7 +147,7 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
if let Some(c) = at {
let code = &wb.styles.get(s.style_id(c)).num_fmt.0;
if !code.eq_ignore_ascii_case("general") {
return format_value(v, &NumberFormat::parse(code), wb.date_system).text;
return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text;
}
}
}
@@ -239,5 +241,6 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
}
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
ChartData { categories, series }
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
ChartData { categories, series, palette }
}
+167 -12
View File
@@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData {
let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2);
b.secondary = kind == ChartKind::Combo;
ChartData {
palette: vec![],
categories: cats.iter().map(|s| s.to_string()).collect(),
series: vec![
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
@@ -362,7 +363,7 @@ fn scatter_with_text_x_values_numbers_the_points() {
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x, None, "{kind:?}");
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
// Numeric X values are kept.
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
@@ -420,6 +421,16 @@ fn rasterize_bar_center_has_bar_color() {
assert!(rasterize(&[], 100_000, 100_000, [0; 4]).is_empty());
}
#[test]
fn rasterize_does_not_panic_on_negative_rect_extents() {
let red = [255, 0, 0, 255];
for (w, h) in [(-10.0, 10.0), (10.0, -10.0), (-10.0, -10.0), (f32::NAN, f32::NAN)] {
let rect = Prim::Rect { x: 5.0, y: 5.0, w, h, fill: Some(red), stroke: Some((red, 2.0)), radius: 0.0 };
let img = rasterize(&[rect], 20, 20, [0, 0, 0, 0]);
assert_eq!(img.len(), 20 * 20 * 4);
}
}
#[test]
fn pie_starts_at_twelve_clockwise() {
let c = chart(ChartKind::Pie);
@@ -445,7 +456,8 @@ fn pie_starts_at_twelve_clockwise() {
#[test]
fn negative_values_put_axis_below_zero() {
let c = chart(ChartKind::ColumnClustered);
let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
let d =
ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let t = texts(&prims);
assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}");
@@ -456,8 +468,13 @@ fn negative_values_put_axis_below_zero() {
fn edge_cases_never_panic() {
let m = ApproxMeasure;
let empty = ChartData::default();
let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] };
let zeros = ChartData {
palette: vec![],
categories: vec!["a".into(), "b".into()],
series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)],
};
let weird = ChartData {
palette: vec![],
categories: vec![String::new(); 3],
series: vec![SeriesData {
name: "w".repeat(500),
@@ -488,7 +505,7 @@ fn thousands_of_points_are_decimated() {
let vals: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect();
let mut c = chart(ChartKind::Line);
c.data_labels = true;
let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure);
let max_line = prims
.iter()
@@ -513,7 +530,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = smooth;
let d = ChartData { categories: cats.clone(), series: vec![s] };
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
.iter()
.filter_map(|p| match p {
@@ -530,7 +547,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
// Rendering either way must stay finite and inside the canvas.
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
let d = ChartData { categories: cats, series: vec![s] };
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
}
@@ -540,7 +557,7 @@ fn smooth_line_splits_at_gaps() {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
s.values[1] = None; // gap between two data points
let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
// Two separate runs (before/after the gap) → at least two line primitives.
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
@@ -617,7 +634,7 @@ fn fuzz_random_data_and_sizes() {
})
.collect();
let ncat = (r.next() % 50) as usize;
let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
let mut c = chart(kind);
c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize];
c.data_labels = r.next().is_multiple_of(2);
@@ -792,7 +809,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
s.trendlines =
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let mut c = chart(ChartKind::Line);
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
let prims = render(&c, &d, 600.0, 400.0, &m);
@@ -816,7 +833,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
assert!(lines_of(&pie, tc).is_empty());
// One point: nothing to fit, nothing drawn.
s.values.truncate(1);
let one = ChartData { categories: vec!["a".into()], series: vec![s] };
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
}
@@ -836,7 +853,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
];
let d = ChartData { categories: vec![], series: vec![s] };
let d = ChartData { palette: vec![], categories: vec![], series: vec![s] };
let mut c = chart(ChartKind::Scatter);
c.legend = LegendPos::None;
let prims = render(&c, &d, 600.0, 400.0, &m);
@@ -851,6 +868,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
#[test]
fn treemap_and_histogram_shapes() {
let d = ChartData {
palette: vec![],
categories: vec!["a".into(), "b".into(), "c".into(), "d".into()],
series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)],
};
@@ -871,12 +889,34 @@ fn treemap_and_histogram_shapes() {
assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}");
let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
assert_eq!(bins, 10);
}
#[test]
fn histogram_boundary_values_match_interval_labels() {
let heights = |vals: &[f64]| -> Vec<f32> {
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect()
};
// Labels are "[0, 7.5]" and "(7.5, 15.0]": 7.5 belongs to the first bar, so the counts are 2 and 1.
let h = heights(&[0.0, 7.5, 15.0]);
assert_eq!(h.len(), 2);
assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}");
let h = heights(&[-15.0, -7.5, 0.0]);
assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}");
// A value off the boundary still goes right: [0, 7.5] holds 1, (7.5, 15.0] holds 2.
let h = heights(&[0.0, 7.75, 15.0]);
assert!((h[1] / h[0] - 2.0).abs() < 0.01, "{h:?}");
// Five values, three bins [0,5] (5,10] (10,15]: counts 3, 1, 1.
let h = heights(&[0.0, 5.0, 5.0, 10.0, 15.0]);
assert_eq!(h.len(), 3);
assert!((h[0] / h[1] - 3.0).abs() < 0.01 && (h[1] - h[2]).abs() < 0.01, "{h:?}");
}
#[test]
fn union_series_references() {
let mut wb = Workbook::new();
@@ -902,3 +942,118 @@ fn union_series_references() {
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
let _ = resolve(&wb, 0, &c);
}
#[test]
fn box_and_stock_charts_honour_the_gridlines_flag() {
// Horizontal full-width lines in the gridline colour are the value-axis gridlines.
let grid: Rgba = [217, 217, 217, 255];
let is_grid = |p: &Prim| match p {
Prim::Line { color, pts, .. } => *color == grid && pts.len() == 2 && pts[0][1] == pts[1][1] && pts[1][0] - pts[0][0] > 200.0,
_ => false,
};
let grids = |prims: &[Prim]| prims.iter().filter(|p| is_grid(p)).count();
for kind in [ChartKind::BoxWhisker, ChartKind::Stock, ChartKind::ColumnClustered] {
let mut d = sample(kind);
d.series.push(series("Close", &[10000.0, 12000.0, 9000.0, 14000.0, 20000.0, 16000.0], [90, 90, 90, 255], kind));
let mut c = chart(kind);
let on = render(&c, &d, 500.0, 350.0, &ApproxMeasure);
c.gridlines = false;
let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure);
assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines");
assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them");
}
}
#[test]
fn funnel_value_labels_follow_the_data_labels_flag() {
let data = ChartData {
palette: vec![],
categories: vec!["a".into(), "b".into(), "c".into()],
series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)],
};
let mut c = chart(ChartKind::Funnel);
c.data_labels = false;
let off = render(&c, &data, 500.0, 350.0, &ApproxMeasure);
c.data_labels = true;
let on = render(&c, &data, 500.0, 350.0, &ApproxMeasure);
let values = ["$11,111", "$22,222", "$33,333"];
for v in values {
assert!(texts(&on).contains(&v), "{v} missing with labels on");
assert!(!texts(&off).contains(&v), "{v} drawn with labels off");
}
for cat in ["a", "b", "c"] {
assert!(texts(&off).contains(&cat));
assert!(texts(&on).contains(&cat));
}
}
#[test]
fn pie_points_follow_workbook_theme() {
let mut wb = Workbook::new();
wb.theme.colors[4] = 0x4F6D8A;
wb.theme.colors[5] = 0x8AA1B8;
let mut c = chart(ChartKind::Pie);
c.series = vec![Series {
name: None,
categories: None,
values: "{5,3}".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 0, &c);
let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)];
assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]);
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
assert_eq!(legend, expected);
let fills: Vec<Rgba> = render(&c, &d, 480.0, 288.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { fill, .. } => Some(*fill),
_ => None,
})
.collect();
assert_eq!(fills, expected);
for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] {
c.kind = kind;
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
assert_eq!(legend, expected, "{kind:?}");
}
// Past the 36-colour cycle the points keep following the workbook theme.
c.kind = ChartKind::Pie;
c.series[0].values = format!("{{{}}}", ["1"; 40].join(","));
let d = resolve(&wb, 0, &c);
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
assert_eq!(legend.len(), 40);
assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37)));
assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37));
}
#[test]
fn area_charts_draw_source_value_labels_only_when_enabled() {
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
let d = sample(kind);
let mut c = chart(kind);
c.data_labels = false;
let off = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
c.data_labels = true;
let on = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let count = |p: &[Prim], v: &str| texts(p).iter().filter(|t| **t == v).count();
// One label per point: 3 series x 6 categories.
assert_eq!(texts(&on).len(), texts(&off).len() + 18, "{kind:?}");
for v in ["$12,000", "$38,000", "$9,000", "$27,000"] {
assert_eq!(count(&on, v), count(&off, v) + 1, "{kind:?}: {v}");
}
// Stacked areas label the source values, not cumulative totals (12,000 + 38,000).
assert_eq!(count(&on, "$50,000"), count(&off, "$50,000"), "{kind:?}: cumulative total shown");
let polys = |p: &[Prim]| p.iter().filter(|p| matches!(p, Prim::Polygon { .. })).cloned().collect::<Vec<_>>();
assert_eq!(polys(&on), polys(&off));
// The first series' labels are drawn after every area, so the next area can't cover them.
let last_poly = on.iter().rposition(|p| matches!(p, Prim::Polygon { .. }));
let first_label = on.iter().position(|p| matches!(p, Prim::Text { text, .. } if text == "$12,000"));
assert!(first_label > last_poly, "{kind:?}: label drawn under a later area");
}
}
+22 -2
View File
@@ -59,6 +59,14 @@ pub fn serial_from_unix_ms(ms: f64) -> f64 {
25569.0 + ms / 86_400_000.0
}
/// Converts a 1900-system serial (as the clock supplies it) to the serial of the same instant in `sys`.
pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
match sys {
DateSystem::D1900 => serial_1900,
DateSystem::D1904 => serial_1900 - 1462.0,
}
}
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
@@ -95,6 +103,14 @@ pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 {
if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) }
}
/// Last serial that is a valid date in a date system: 9999-12-31 (2958465 in the 1900 system, 2957003 in the 1904 system).
pub fn max_serial(sys: DateSystem) -> i64 {
match sys {
DateSystem::D1900 => 2_958_465,
DateSystem::D1904 => 2_958_465 - 1462,
}
}
/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next
/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900
/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60.
@@ -117,7 +133,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
DateSystem::D1904 => first - UNIX_1904_01_01,
};
let serial = first.checked_add(day)?.checked_sub(1)?;
if !(0..=2_958_465).contains(&serial) {
if !(0..=max_serial(sys)).contains(&serial) {
return None;
}
Some(serial as f64)
@@ -125,7 +141,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
/// Date/time from a serial. `None` for negative or too-large serials.
pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) {
if !serial.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&serial) {
return None;
}
let mut day = serial.floor() as i64;
@@ -197,6 +213,10 @@ mod tests {
assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0));
assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0));
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 31), Some(2957003.0));
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 32), None);
assert!(datetime_from_serial(DateSystem::D1904, 2957003.5).is_some());
assert!(datetime_from_serial(DateSystem::D1904, 2957004.0).is_none());
assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1));
assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29));
assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1));
+20 -1
View File
@@ -41,7 +41,12 @@ pub fn parse_input(s: &str, sys: DateSystem) -> Parsed {
/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00").
pub fn parse_number_text(s: &str) -> Option<f64> {
parse_numeric(s.trim(), DateSystem::D1900).map(|(n, _)| n)
parse_number_text_in(s, DateSystem::D1900)
}
/// Like [`parse_number_text`], with date text read in the given workbook date system.
pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
parse_numeric(s.trim(), sys).map(|(n, _)| n)
}
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
@@ -252,10 +257,14 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
// "Mar 4, 2026" / "March 4 2026" / "4 March 2026"
let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect();
if words.len() == 3 {
// Two-digit years follow the same 2029 rule as the dashed forms above.
let expand = |w: &str, y: i64| if w.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) {
let y = expand(words[2], y);
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
}
if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) {
let y = expand(words[2], y);
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
}
}
@@ -317,6 +326,16 @@ mod tests {
parse_input(s, DateSystem::D1900)
}
#[test]
fn month_name_short_year() {
for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
assert_eq!(p(s).value, Value::Number(46085.0), "{s}");
}
assert_eq!(p("Mar 4, 99").value, p("Mar 4, 1999").value);
assert_eq!(p("Mar 4, 1776").value, Value::text("Mar 4, 1776"));
assert_eq!(p("Feb 30, 26").value, Value::text("Feb 30, 26"));
}
#[test]
fn numbers() {
assert_eq!(p("42").value, Value::Number(42.0));
+6 -2
View File
@@ -224,13 +224,17 @@ impl Value {
/// Coercion to a number as arithmetic does it: empty = 0, TRUE = 1, numeric text parses
/// (including dates, times, percentages and currency), other text = `#VALUE!`.
pub fn to_number(&self) -> Result<f64, CellError> {
self.to_number_in(crate::DateSystem::D1900)
}
/// [`Value::to_number`] with date text read in the given workbook date system.
pub fn to_number_in(&self, sys: crate::DateSystem) -> Result<f64, CellError> {
match self {
Value::Empty => Ok(0.0),
Value::Number(n) => Ok(*n),
Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
Value::Text(t) => crate::parse::parse_number_text(t).ok_or(CellError::Value),
Value::Text(t) => crate::parse::parse_number_text_in(t, sys).ok_or(CellError::Value),
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(|v| v.to_number()).unwrap_or(Ok(0.0)),
Value::Array(a) => a.data.first().map(|v| v.to_number_in(sys)).unwrap_or(Ok(0.0)),
}
}
/// Coercion to text as `&` does it.
+1 -1
View File
@@ -184,7 +184,7 @@ impl CfCache {
},
CfRule::TimePeriod { period, style } => match v.as_f64() {
Some(n) => {
let today = gridcraft_calc::now_serial().floor();
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let d = n.floor();
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
let week_start = today - wd;
+159 -41
View File
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn col_param(v: Option<&Json>, base: u32) -> Option<u32> {
match v? {
Json::String(s) => gridcraft_core::letters_to_col(s.trim_start_matches('$')),
Json::Number(n) => n.as_u64().map(|o| base + o as u32),
Json::Number(n) => n.as_u64().and_then(|o| u32::try_from(o).ok()).and_then(|o| base.checked_add(o)),
_ => None,
}
}
@@ -198,27 +198,29 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let by_cols = str_param(p, "orientation") == Some("columns");
let case = bool_param(p, "matchCase").unwrap_or(false);
let keys: Vec<SortKey> = p
.get("keys")
.and_then(Json::as_array)
.map(|a| {
a.iter()
.filter_map(|k| {
let col = if by_cols {
k.get("row").and_then(Json::as_u64).map(|r| r as u32 - 1).or_else(|| col_param(k.get("column"), r.start.row))?
} else {
col_param(k.get("column"), r.start.col)?
};
Some(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
})
})
.collect()
})
.unwrap_or_default();
let mut keys: Vec<SortKey> = Vec::new();
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
let col = if by_cols {
let row = match k.get("row").and_then(Json::as_u64) {
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
Some(n) => {
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
}
None => None,
};
row.or_else(|| col_param(k.get("column"), r.start.row))
} else {
col_param(k.get("column"), r.start.col)
};
let Some(col) = col else { continue };
keys.push(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
});
}
if keys.is_empty() {
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
return ok();
@@ -454,14 +456,24 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
if has {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
// Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden.
let filtered: Vec<u32> = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect();
sh.autofilter = None;
for info in sh.rows.values_mut() {
info.hidden = false;
for r in filtered {
if let Some(info) = sh.rows.get_mut(&r) {
info.hidden = false;
}
}
cx.structural = true;
Ok(json!({"filter": false}))
});
}
install_filter(s, p)
}
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let mut r = data_range(s, p)?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
@@ -484,11 +496,16 @@ fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let af = match &sh.autofilter {
Some(a) => a.clone(),
None => {
toggle_filter(s, &json!({}))?;
install_filter(s, &json!({}))?;
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
}
};
let col = col_param(p.get("column"), af.range.start.col).ok_or_else(|| bad("data.filterBy", "missing `column`"))?;
let col = match col_param(p.get("column"), af.range.start.col) {
Some(c) => c,
// A number too large to be a column is outside the filter range, not a missing parameter.
None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")),
None => return Err(bad("data.filterBy", "missing `column`")),
};
if col < af.range.start.col || col > af.range.end.col {
return Err(bad("data.filterBy", "column outside the filter range"));
}
@@ -570,19 +587,28 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(af) = sh.autofilter.clone() else { return };
let r = af.range;
let mut hide = Vec::new();
// Precompute per-column numbers for top10/average.
let mut col_stats: std::collections::HashMap<u32, (Vec<f64>, f64)> = std::collections::HashMap::new();
for (off, c) in &af.criteria {
// Precompute per-criterion top10 threshold and average once, not per row.
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
for (i, (off, c)) in af.criteria.iter().enumerate() {
if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {
let col = r.start.col + off;
let nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };
col_stats.insert(*off, (nums, avg));
let thr = match c {
FilterCriterion::Top10 { bottom, percent, count } => {
nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
let k = k.clamp(1, nums.len().max(1));
nums.get(k - 1).copied()
}
_ => None,
};
col_stats.insert(i, (thr, avg));
}
}
for row in r.start.row + 1..=r.end.row {
let mut visible = true;
for (off, crit) in &af.criteria {
for (i, (off, crit)) in af.criteria.iter().enumerate() {
let c = CellRef::new(row, r.start.col + off);
let v = sh.value(c);
let text = crate::display::cell_text(wb, sh, c);
@@ -604,18 +630,14 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
None => x,
}
}
FilterCriterion::Top10 { bottom, percent, count } => match (v.as_f64(), col_stats.get(off)) {
(Some(n), Some((nums, _))) => {
let mut sorted = nums.clone();
sorted.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
let k = if *percent { ((sorted.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
let k = k.clamp(1, sorted.len().max(1));
let thr = sorted.get(k - 1).copied().unwrap_or(n);
FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((thr, _))) => {
let thr = thr.unwrap_or(n);
if *bottom { n <= thr } else { n >= thr }
}
_ => false,
},
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) {
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((_, avg))) => {
if *above {
n > *avg
@@ -896,7 +918,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
}
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
pub(super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
@@ -1046,6 +1068,18 @@ fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
}
}
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
if first == to {
return f.to_string();
}
let moved = formula_at(f, first, to);
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
let eq = if f.starts_with('=') { "=" } else { "" };
if has_relative { format!("{eq}{moved}") } else { f.to_string() }
}
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
let Some(a) = a else { return false };
match op {
@@ -1129,6 +1163,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
{
e.collapsed = false;
}
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
@@ -1278,6 +1313,38 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "B1:C3"})).unwrap();
for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] {
let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err();
assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}");
}
let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap();
assert_eq!(ok["hiddenRows"], json!(1));
}
#[test]
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap();
s.execute("selection.set", sel.clone()).unwrap();
s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}");
assert!(sh.tables[0].filter_button, "{sel}");
assert!(!sh.is_row_hidden(1), "{sel}");
assert!(sh.is_row_hidden(2), "{sel}");
assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}");
}
}
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
@@ -1300,6 +1367,45 @@ mod tests {
assert!(!ok(&mut s, "B1", "150"));
}
#[test]
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("data.group", json!({"rows": "2:4"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
let hidden = |s: &Session| -> Vec<u32> {
let sh = s.doc().unwrap().wb.active().unwrap();
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
};
let before = hidden(&s);
assert_eq!(before, [1, 2, 3, 9]);
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none());
assert_eq!(hidden(&s), before);
assert_eq!(row_state(&s, 4..=4), [(false, true)]);
}
#[test]
fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap();
s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
s.execute("selection.set", json!({"range": "E1"})).unwrap();
// The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice.
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::<Vec<_>>(), [2, 9]);
}
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
fn nested_rows() -> Session {
let mut s = Session::new();
@@ -1375,6 +1481,18 @@ mod tests {
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
for row in [0u64, 1 << 32] {
let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]});
let err = s.execute("data.sort", params).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}");
}
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
+35 -14
View File
@@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
Some(c) => c,
None => s.doc()?.selection.active,
};
let input = str_param(p, "input")
.or_else(|| str_param(p, "value"))
.map(str::to_string)
.or_else(|| p.get("value").map(json_to_input))
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
// Both spellings take a string, number or boolean, as typed (#697).
let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
let array = bool_param(p, "array").unwrap_or(false);
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
@@ -420,14 +417,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let sel = &mut d.selection;
// Extension moves the far corner, not the active cell.
let from = if extend {
let r = sel.current();
let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row };
let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col };
CellRef::new(far_row, far_col)
} else {
sel.active
};
let from = if extend { sel.moving_end() } else { sel.active };
// Skip hidden rows/columns and step over merged areas.
let mut to = if jmp {
jump(sheet, from, dr.signum(), dc.signum())
@@ -513,7 +503,7 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
Ok(json!({"selection": d.selection.a1()}))
}
fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
// A defined name or table?
let d = s.doc()?;
@@ -1111,6 +1101,20 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
@@ -1583,6 +1587,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn cell_set_takes_numbers_for_input_and_value() {
// #697: a JSON number for `input` used to clear the cell instead of entering it.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.5));
assert_eq!(get(&mut s, "A2")["value"], json!(77.0));
assert_eq!(get(&mut s, "A3")["value"], json!(true));
assert_eq!(get(&mut s, "A4")["value"], json!(200.5));
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+37 -8
View File
@@ -701,6 +701,12 @@ fn stash_get(wb: &Workbook, key: &str) -> Json {
.unwrap_or(json!({}))
}
/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty.
fn stash_map(wb: &Workbook, key: &str) -> Json {
let v = stash_get(wb, key);
if v.is_object() { v } else { json!({}) }
}
fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
let text = v.to_string();
let formula = format!("\"{}\"", text.replace('"', "\"\""));
@@ -723,7 +729,7 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_get(&d.wb, VIEWS_NAME);
let mut views = stash_map(&d.wb, VIEWS_NAME);
if let Some(name) = str_param(p, "save") {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
@@ -743,6 +749,17 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(z) = v["zoom"].as_u64() {
s.execute("view.zoom", json!({"percent": z}))?;
}
if let Some(f) = v.get("freeze") {
let freeze = match (f[0].as_u64(), f[1].as_u64()) {
(Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()),
_ => None,
};
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = freeze;
Ok(())
})?;
}
} else if let Some(name) = str_param(p, "delete") {
if let Some(o) = views.as_object_mut() {
o.remove(name);
@@ -812,7 +829,7 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = cx.sheet_mut(si)?;
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row {
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
return Err(EngineError::Other(
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
));
@@ -821,7 +838,14 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
for (i, h) in headers.iter().enumerate() {
let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() };
let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned();
cols.push(existing.unwrap_or(TableColumn { name, totals: TotalsFn::None, totals_label: None, formula: None }));
cols.push(existing.unwrap_or(TableColumn {
name,
totals: TotalsFn::None,
totals_label: None,
formula: None,
unique_name: None,
xml: None,
}));
}
t.columns = cols;
t.range = r;
@@ -938,7 +962,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("automate.newScript", "`commands` must be an array"));
}
edit(s, |cx| {
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
all[name.as_str()] = cmds.clone();
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(json!({"name": name}))
@@ -977,7 +1001,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
if let Some(o) = all.as_object_mut() {
o.remove(&name);
}
@@ -1052,8 +1076,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null).
fn object_params(p: &Json) -> Json {
Json::Object(p.as_object().cloned().unwrap_or_default())
}
fn theme_colors(s: &mut Session, p: &Json) -> Result<Json> {
let mut params = p.clone();
let mut params = object_params(p);
if params.get("name").is_none() {
params["name"] = json!(s.doc()?.wb.theme.name.clone());
}
@@ -1074,7 +1103,7 @@ fn background(s: &mut Session, p: &Json) -> Result<Json> {
Ok(Json::Null)
});
}
let mut params = p.clone();
let mut params = object_params(p);
params["alt"] = json!("Sheet background");
params["at"] = json!("A1");
s.execute("insert.picture", params)
@@ -1090,7 +1119,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
"outline" => "outside",
_ => "all",
};
let mut params = p.clone();
let mut params = object_params(p);
params["preset"] = json!(preset);
s.execute("home.borders", params)
}
+17 -5
View File
@@ -56,7 +56,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
};
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
let imported =
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
@@ -75,7 +76,11 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
{
s.close_document(0);
}
let i = s.add_document(DocState::new(wb, path, title));
let mut doc = DocState::new(wb, path, title);
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
doc.content_kind = Some(crate::io::FileKind::Xlsx);
}
let i = s.add_document(doc);
// The decrypted-workbook notice is for the info bar, not a message box.
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
if !shown.is_empty() {
@@ -105,11 +110,18 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
let bytes = crate::io::save_bytes(&d.wb, &path)?;
// The document's own file keeps the format its content had when opened; any other path
// goes by its extension. Relative paths to linked workbooks are rewritten for the new folder.
let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
let rebased = crate::io::source_dir(&path).and_then(|f| gridcraft_xlsx::rebase_links(&d.wb, &f));
let wb = rebased.as_ref().unwrap_or(&d.wb);
let bytes = match kind {
Some(kind) => crate::io::save_bytes_as(wb, &path, kind)?,
None => crate::io::save_bytes(wb, &path)?,
};
crate::io::write_file(&path, &bytes)?;
let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx);
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) {
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
d.saved = d.wb.clone();
d.path = Some(path.clone());
}
+96 -21
View File
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Forecast Sheet",
["Data", "Forecast"],
None,
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
has_doc,
forecast_sheet
)]
@@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
return Err(EngineError::Other("There are no values to forecast.".into()));
}
let end = match p.get("end") {
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
Value::Number(end) => Some(end),
_ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))),
},
_ => None,
let time_param = |key: &str| -> Result<Option<f64>> {
match p.get(key) {
Some(Json::Number(n)) => Ok(n.as_f64()),
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
Value::Number(x) => Ok(Some(x)),
_ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))),
},
None | Some(Json::Null) => Ok(None),
Some(Json::String(_)) => Ok(None),
Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))),
}
};
let end = time_param("end")?;
// Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the
// usual forecast after the history.
let fit = match time_param("forecastStart")? {
None => rows.len(),
Some(start) => {
let last_t = times.last().copied().unwrap_or(f64::NAN);
let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) {
rows.len()
} else {
let i = times
.iter()
.position(|t| tol(*t, start))
.ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?;
if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) {
return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into()));
}
i
}
}
};
let cap = max_periods(rows.len());
let periods = match end {
@@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
// Columns: A timeline, B values, C forecast, D/E bounds.
let n = rows.len() as u32;
let last = n + 1; // 1-based row of the last history point
let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to
let end_row = last + periods; // 1-based row of the last forecast
let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col));
let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col));
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
if let Some(a) = opts.aggregate {
tail.push_str(&format!(",{a}"));
@@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
put(&mut sheet, r, 1, v.clone(), vstyle);
}
}
// The last history point starts the forecast lines, so they join the values.
formula(&mut sheet, n, 2, format!("=B{last}"), vstyle);
// The last point the model is fit to starts the forecast lines, so they join the values.
let join = fit_last - 1; // 0-based row
formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle);
if opts.confidence.is_some() {
formula(&mut sheet, n, 3, format!("=C{last}"), vstyle);
formula(&mut sheet, n, 4, format!("=C{last}"), vstyle);
formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle);
formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle);
}
let (tl, vl) = (hist(0), hist(1));
let forecast_row = |sheet: &mut Sheet, r: u32| {
formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
if let Some(c) = opts.confidence {
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
}
};
// Hindcast: forecasts over the actuals from the start to the last history point.
for r in (fit_last + 1)..=last {
forecast_row(&mut sheet, r);
}
for (k, t) in future.iter().enumerate() {
let r = last + k as u32 + 1; // 1-based
put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
if let Some(c) = opts.confidence {
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
}
forecast_row(&mut sheet, r);
}
if opts.statistics {
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
@@ -351,7 +386,10 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
range,
header_row: true,
totals_row: false,
columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
columns: headers
.iter()
.map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
.collect(),
style: "TableStyleMedium2".into(),
banded_rows: true,
banded_cols: false,
@@ -359,6 +397,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
last_col: false,
filter_button: true,
filter: vec![],
table_type: None,
connection_id: None,
});
let q = gridcraft_formula::quote_sheet(&sheet_name);
let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
@@ -407,7 +447,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if let Ok(d) = s.doc_mut() {
d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods}))
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit}))
}
/// Forecast rows needed to reach `end` (rounded up to whole steps).
@@ -545,4 +585,39 @@ mod tests {
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
}
#[test]
fn forecast_start_hindcasts_over_the_actuals() {
let mut s = session_with_history(); // t = 1..16 in A2:A17
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap();
assert_eq!(r["fitted"], 12);
// Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19.
assert_eq!(formula(&s, 0, "C13"), "B13");
assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13"));
assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)");
assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)");
// The exact seasonal series is recovered, and the actual is still shown next to it.
let c14 = value(&s, 0, "C14").as_f64().unwrap();
assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3
assert!(value(&s, 0, "B14").as_f64().is_some());
// Start at the last point or one step later: as without forecastStart.
for start in [json!(16), json!(17)] {
let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap();
assert_eq!(r["fitted"], 16);
}
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
}
#[test]
fn forecast_start_invalid_values_are_errors() {
let mut s = session_with_history();
let sheets = s.doc().unwrap().wb.sheets.len();
for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] {
assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
// Blank or null behaves like no start; empty params unchanged.
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap();
assert_eq!(r["fitted"], 16);
}
}
+31 -6
View File
@@ -237,10 +237,12 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
// Existing cells in those lines.
let cells: Vec<(CellRef, StyleId)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
for (c, st) in cells {
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
cx.sheet_mut(sheet)?.set_style(c, new);
for (c, st) in &cells {
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
cx.sheet_mut(sheet)?.set_style(*c, new);
}
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
cx.fit_rows.extend(cells.iter().map(|(c, _)| (sheet, c.row)));
continue;
}
if r.count() > 4_000_000 {
@@ -574,6 +576,16 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
let m = RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col));
if !m.is_single() {
sh.merges.push(m);
// As below: only the first value of each row survives.
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
for c in others {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
touched.push((sheet, c));
}
}
}
}
@@ -953,6 +965,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
e.custom = true;
}
}
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
@@ -992,9 +1005,7 @@ pub fn chars_to_points(c: f64) -> f64 {
}
}
pub fn points_to_chars(px: f64) -> f64 {
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
}
pub use gridcraft_model::points_to_chars;
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; the UI refines with real measurement when it autofits).
@@ -1069,6 +1080,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
}
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
}
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
@@ -1112,6 +1124,19 @@ fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn merge_across_keeps_the_first_value_of_each_row() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(A1:B2)"})).unwrap();
s.execute("home.mergeAcross", json!({"range": "A1:B2"})).unwrap();
let v = |s: &mut crate::Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "B1"), Json::Null);
assert_eq!(v(&mut s, "B2"), Json::Null);
assert_eq!(v(&mut s, "D1"), json!(4.0));
}
#[test]
fn decimal_steps() {
let v = gridcraft_core::Value::Number(1.5);
+102 -14
View File
@@ -153,10 +153,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Link",
["Insert", "Links"],
Some("Cmd+K"),
"{cell?, target: \"https://…\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
"{cell?, target: \"https://…\" | \"Book.xlsx\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
has_doc,
insert_link
),
cmd!(
"link.open",
"Open Hyperlink",
[],
None,
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening",
has_doc,
open_link
),
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
s, p, true
@@ -240,7 +249,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name")
.map(str::to_string)
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
@@ -310,7 +319,10 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
range: r,
header_row: true,
totals_row: false,
columns: names.into_iter().map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
columns: names
.into_iter()
.map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
.collect(),
style,
banded_rows: true,
banded_cols: false,
@@ -318,6 +330,8 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
last_col: false,
filter_button: true,
filter: vec![],
table_type: None,
connection_id: None,
});
cx.structural = true;
Ok(json!({"table": name, "range": r.a1()}))
@@ -349,6 +363,9 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"totals" => {
let v = on.unwrap_or(!t.totals_row);
if v != t.totals_row {
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
}
t.totals_row = v;
if v {
t.range.end.row += 1;
@@ -448,14 +465,19 @@ fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a
/// structured reference, and the writer would rename the table on save.
fn valid_table_name(name: &str) -> bool {
name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
if name.is_empty()
|| name.contains(' ')
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
|| gridcraft_core::CellRef::parse(&name).is_some()
{
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) {
@@ -470,6 +492,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
for i in 0..cx.wb.sheets.len() {
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
let shadowed = cx.wb.name(&old, i).is_some();
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
for (c, f) in keys {
let Some(e) = f.expr() else { continue };
@@ -478,7 +502,7 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
st.table = name.clone();
gridcraft_formula::Expr::Struct(st)
}
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
o => o,
});
if let Some(cell) = sh.cells.get_mut(c) {
@@ -1156,6 +1180,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
};
let text = str_param(p, "text").map(str::to_string);
let tooltip = str_param(p, "tooltip").map(str::to_string);
let external = links_outside(&s.doc()?.wb, sheet, &target);
edit(s, |cx| {
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
st.font.color = Color::Theme(10, 0);
@@ -1167,12 +1192,55 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
} else {
sh.set_style(at, link_style);
}
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone() });
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
cx.touch(sheet, at);
Ok(Json::Null)
})
}
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
/// dialog; the interactive click asks for confirmation first, in the UI.
fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
Some(t) => t.to_string(),
None => {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {
return Err(EngineError::Other("There's no hyperlink in this cell.".into()));
};
h.target.clone()
}
};
match crate::links::classify(&target) {
crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
crate::links::LinkKind::External(u) => {
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
Ok(json!({"opened": u}))
}
crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)),
}
}
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
/// Page") is outside the workbook and saved as a relationship.
fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
let t = target.trim();
if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
return false;
}
let (on, body) = split_sheet(t);
// Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
// and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
let on_sheet = on.is_none_or(|name| {
wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
|| (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
});
!(on_sheet && RangeRef::parse(body).is_some())
}
fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
@@ -1420,20 +1488,20 @@ fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
}
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let n = sh.cond_formats.len();
// The count is taken again after each step: a delete shortens the list.
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
&& i < n
&& i < sh.cond_formats.len()
{
sh.cond_formats.remove(i);
}
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
&& i > 0
&& i < n
&& i < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i - 1);
}
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
&& i + 1 < n
&& i + 1 < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i + 1);
}
@@ -1466,3 +1534,23 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"shape": id}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manage_rules_delete_then_move() {
let mut s = Session::new();
s.new_workbook();
for v in ["1", "2"] {
let rule = json!({"type": "cellIs", "operator": "greaterThan", "value": v, "preset": "lightRedFill"});
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
}
// Deleting first left two rules' worth of indices for the move: it panicked.
s.execute("home.manageRules", json!({"delete": 0, "moveUp": 1})).unwrap();
s.execute("home.manageRules", json!({"delete": 0, "moveDown": 0})).unwrap();
let rules = s.execute("home.manageRules", json!({})).unwrap();
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
}
}
+290 -21
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Field",
[],
None,
"{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
"{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
has_doc,
move_field
),
@@ -333,6 +333,71 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool {
pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field))
}
/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more).
const VALUES_FIELD: &str = "Σ Values";
/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name.
fn is_values_field(src: &pv::Source, field: &str) -> bool {
let f = field.trim();
f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none())
}
/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of
/// Rows (`rows`) or Columns.
fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) {
if pt.values_on_rows != rows {
return;
}
let Some(p) = pt.values_position else { return };
// Σ Values stays just ahead of the field it preceded.
let p = match inserted {
true if i <= p => p + 1,
false if i < p => p - 1,
_ => p,
};
let n = if rows { pt.rows.len() } else { pt.columns.len() };
pt.values_position = (p < n).then_some(p);
}
/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry.
fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option<PivotField> {
let mut first = None;
for rows in [true, false] {
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break };
let f = list.remove(i);
shift_values(pt, rows, i, false);
first.get_or_insert(f);
}
}
first
}
/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end).
fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option<usize>) {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let at = position.unwrap_or(list.len()).min(list.len());
list.insert(at, f);
shift_values(pt, rows, at, true);
}
/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end).
/// In Columns it always comes after the last column field: the column layout doesn't place it
/// anywhere else yet.
fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option<usize>) -> Result<Json> {
let current = if pt.values_on_rows { "rows" } else { "columns" };
if from != current {
return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area")));
}
if !matches!(to, "rows" | "columns") {
return Err(other("The field you are moving cannot be placed in that PivotTable area."));
}
pt.values_on_rows = to == "rows";
pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len());
Ok(json!({"field": VALUES_FIELD, "to": to}))
}
/// The canonical source field name for `field`.
fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
@@ -384,21 +449,11 @@ fn add_to_area(
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS)));
}
let existing = pt
.rows
.iter()
.chain(pt.columns.iter())
.find(|f| f.source_col.eq_ignore_ascii_case(&field))
.cloned()
.unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
// A report filter that selects nothing in particular goes away; a selection stays
// as the field's hidden items.
pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none()));
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
let at = position.unwrap_or(list.len()).min(list.len());
list.insert(at, existing);
insert_axis_field(pt, area == "rows", existing, position);
}
"values" => {
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
@@ -421,8 +476,7 @@ fn add_to_area(
pt.values.insert(at, v);
}
_ => {
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
take_from_axes(pt, &field);
let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
let entry = match existing {
Some(i) => pt.filters.remove(i),
@@ -439,12 +493,14 @@ fn add_to_area(
fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result<String> {
match area {
"rows" | "columns" => {
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
let rows = area == "rows";
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let i = list
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(field))
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?;
let f = list.remove(i);
shift_values(pt, rows, i, false);
pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col));
Ok(f.source_col)
}
@@ -699,6 +755,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"style": pt.style,
"showHeaders": pt.show_headers,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
"valuesOnRows": pt.values_on_rows,
"valuesPosition": pt.values_position,
})
}
@@ -890,18 +948,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let to = area_param(C, str_param(p, "to"))?;
let pos = position_param(p);
update(s, p, |pt, src, _| {
if is_values_field(src, &field) {
return move_values_field(C, pt, from, to, pos);
}
if from == to {
// Reorder within the area.
match from {
"rows" | "columns" => {
let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns };
let rows = from == "rows";
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let i = list
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(&field))
.ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?;
let f = list.remove(i);
let at = pos.unwrap_or(list.len()).min(list.len());
list.insert(at, f);
shift_values(pt, rows, i, false);
insert_axis_field(pt, rows, f, pos);
}
"values" => {
let i = pt
@@ -1270,8 +1332,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
}
ok
};
pt.rows.retain(|f| keep(&f.source_col, &mut dropped));
pt.columns.retain(|f| keep(&f.source_col, &mut dropped));
// Axis fields leave one by one so Σ Values keeps its place among the rest.
for rows in [true, false] {
let mut i = 0;
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(f) = list.get(i) else { break };
if keep(&f.source_col, &mut dropped) {
i += 1;
} else {
list.remove(i);
shift_values(pt, rows, i, false);
}
}
}
pt.values.retain(|f| keep(&f.source_col, &mut dropped));
pt.filters.retain(|f| keep(&f.source_col, &mut dropped));
Ok(json!({"source": source, "dropped": dropped}))
@@ -1722,6 +1796,167 @@ mod tests {
assert_eq!(text(&s, "A5"), "Apples");
}
#[test]
fn values_on_rows_layouts() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap();
assert_eq!(text(&s, "C3"), "Sum of Units");
// Σ Values after Region: each region holds one line per value field.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
let p = pt(&s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(&s, "A4"), "East");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A6"), "Sum of Units");
assert_eq!(val(&s, "B6"), Value::Number(33.0));
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
assert_eq!(text(&s, "A14"), "Total Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(72.0));
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Total");
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "East");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A8"), "Sum of Units");
assert_eq!(text(&s, "A9"), "East");
assert_eq!(val(&s, "B9"), Value::Number(33.0));
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(&s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
assert_eq!(text(&s, "A5"), "Sum of Units");
assert_eq!(val(&s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(&s).values_on_rows);
assert_eq!(text(&s, "A3"), "Sum of Sales");
assert_eq!(text(&s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
}
#[test]
fn collapsed_item_puts_values_in_the_values_column() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap();
for layout in ["tabular", "outline"] {
s.execute("pivot.layout", json!({"layout": layout})).unwrap();
assert_eq!(text(&s, "C3"), "Values", "{layout}");
let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap();
let first = if layout == "tabular" { east } else { east + 1 };
assert_eq!(text(&s, &format!("B{first}")), "", "{layout}");
assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}");
assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}");
assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}");
}
}
#[test]
fn values_stay_last_in_columns() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap();
let p = pt(&s);
assert!(!p.values_on_rows);
assert_eq!(p.values_position, None);
// Σ Values to Rows and back with a position: still last in Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, None);
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C5"), "Sum of Units");
}
#[test]
fn change_source_keeps_values_ahead_of_its_field() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// The new source has no Region: Σ Values stays ahead of Product.
s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap();
let p = pt(&s);
assert_eq!(p.rows.len(), 1);
assert_eq!(p.values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(text(&s, "A5"), "Apples");
}
#[test]
fn values_on_rows_between_row_fields() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(text(&s, "A6"), "Apples");
assert_eq!(val(&s, "B6"), Value::Number(160.0));
assert_eq!(text(&s, "A9"), "Sum of Units");
assert_eq!(text(&s, "A10"), "Apples");
assert_eq!(val(&s, "B10"), Value::Number(16.0));
assert_eq!(text(&s, "A13"), "East Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(415.0));
assert_eq!(text(&s, "A14"), "East Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Product");
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C6"), "Apples");
assert_eq!(val(&s, "D6"), Value::Number(160.0));
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "B4"), "Sum of Sales");
assert_eq!(text(&s, "C4"), "Apples");
assert_eq!(val(&s, "D4"), Value::Number(160.0));
assert_eq!(text(&s, "B7"), "Sum of Units");
assert_eq!(text(&s, "C7"), "Apples");
assert_eq!(val(&s, "D7"), Value::Number(16.0));
assert_eq!(text(&s, "A10"), "East Sum of Sales");
assert_eq!(val(&s, "D10"), Value::Number(415.0));
assert_eq!(text(&s, "A11"), "East Sum of Units");
let r = pt(&s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
}
#[test]
fn refresh_after_source_edits() {
let mut s = sales();
@@ -2039,4 +2274,38 @@ mod tests {
assert!(e.contains("field name is not valid"), "{e}");
assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err());
}
#[test]
fn too_many_value_columns_are_refused() {
let mut s = Session::new();
s.new_workbook();
let mut records: Vec<Json> = vec![json!(["Cat", "Sub", "V"])];
for r in 0..64 {
for c in 0..64 {
records.push(json!([format!("c{r}"), format!("s{c}"), 1.0]));
}
}
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
let req = |n: usize| {
let values: Vec<Json> = (0..n).map(|_| json!({"field": "V"})).collect();
json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values})
};
let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string();
assert!(e.contains("will not fit"), "{e}");
assert!(s.execute("insert.pivotTable", req(128)).is_ok());
}
#[test]
fn collapsed_column_items_count_as_one_column_group() {
let mut s = Session::new();
s.new_workbook();
let mut records: Vec<Json> = vec![json!(["Grp", "Sub", "V"])];
records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0])));
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
let values: Vec<Json> = (0..256).map(|_| json!({"field": "V"})).collect();
s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap();
s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap();
// 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit.
s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap();
}
}
+64 -4
View File
@@ -391,7 +391,7 @@ pub struct HfContext {
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
let now = gridcraft_calc::now_serial();
let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial());
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
(date, time)
@@ -1027,6 +1027,21 @@ fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
}
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
let mut ext = 0.0;
let mut col = c.col + 1;
while col < MAX_COLS && ext < 20_000.0 {
let nc = CellRef::new(c.row, col);
if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross {
break;
}
ext += sh.col_width(col);
col += 1;
}
ext
}
fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f32, f32), merged: bool, merges: &[RangeRef], limits: (f32, f32)) {
let (x, y, w, h) = rect;
let sh = b.sh;
@@ -1110,8 +1125,11 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h,
};
// Overflow into empty neighbours (single-line text, not merged).
let (mut clip_l, mut clip_r) = (x, x + w);
if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w {
// Center Across Selection spans the empty Center Across cells to its right; the text stays within that run.
let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0;
let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 };
let (mut clip_l, mut clip_r) = (x, x + w + across);
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
let need = tw + 2.0 * pad + indent - w;
let grow = |dir: i64, mut need: f32| -> f32 {
let mut ext = 0.0;
@@ -1183,7 +1201,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
let lw = gridcraft_pdf::text_width(font, size, line);
let lx = match h_align {
HAlign::Right => x + w - pad - lw - indent,
HAlign::Center => x + (w - lw) / 2.0,
HAlign::Center => x + (w + across - lw) / 2.0,
_ => x + pad + indent,
};
let ly = first_baseline + i as f32 * line_h;
@@ -1765,6 +1783,14 @@ mod tests {
}
}
#[test]
fn header_footer_date_follows_the_workbook_date_system() {
let mut wb = Workbook::new();
let date_1900 = hf_date_time(&wb).0;
wb.date_system = gridcraft_core::DateSystem::D1904;
assert_eq!(hf_date_time(&wb).0, date_1900);
}
#[test]
fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() {
let mut s = s();
@@ -2046,6 +2072,23 @@ mod tests {
assert!(s.execute("file.exportPdf", json!({})).is_ok());
}
#[test]
fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() {
let mut s = s();
for params in [json!({}), json!({"path": ""}), json!({"path": null})] {
let r = s.execute("file.exportPdf", params).unwrap();
assert!(r.get("path").is_none(), "no file is written without a path");
let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
}
assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog");
// Headless callers (CLI, MCP) get the same bytes, not an error.
assert!(s.execute_headless("file.exportPdf", json!({})).is_ok());
// With no workbook open it is a clear error, not a panic.
let mut none = Session::new();
assert!(none.execute("file.exportPdf", json!({})).is_err());
}
#[test]
fn bands_unit() {
let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100);
@@ -2063,6 +2106,23 @@ mod tests {
assert!(w.len() > 2);
}
#[test]
fn center_across_spans_following_empty_center_across_cells() {
let mut styles = StyleTable::default();
let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() });
let mut sh = Sheet::new("S");
for col in 0..3 {
sh.set_style(CellRef::new(0, col), across);
}
sh.set_value(CellRef::new(0, 0), Value::Text("Title".into()));
let a1 = CellRef::new(0, 0);
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2));
sh.set_value(CellRef::new(0, 2), Value::Text("x".into()));
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1));
let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))];
assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0);
}
#[test]
fn rotated_wrapped_text_prints_rotated() {
// Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text.
+65 -16
View File
@@ -249,10 +249,14 @@ fn cancel_copy_mode(s: &mut Session) {
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
if at.checked_add(count).is_none_or(|end| end > limit) {
let cmd = if axis == Axis::Rows { "home.insertRows" } else { "home.insertColumns" };
return Err(bad(cmd, "the inserted lines would extend past the end of the worksheet"));
}
check_lines_protection(s, p, axis, at, count, true)?;
let sheet = target_sheet(s, p)?;
// Refuse to push data off the sheet.
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
@@ -288,27 +292,65 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
Ok(out)
}
/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the
/// last line to the first so deleting one never shifts the next. A comma-separated list
/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form.
fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
let key = if axis == Axis::Rows { "rows" } else { "cols" };
let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
// Deleting past the last line only removes up to the end of the worksheet.
let count = count.min(limit.saturating_sub(at)).max(1);
vec![(at, at.saturating_add(count - 1))]
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
t.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<_>>()?
} else {
s.doc()?.selection.ranges.iter().map(|r| if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()
};
let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect();
spans.sort_unstable();
let mut merged: Vec<(u32, u32)> = Vec::new();
for (a, b) in spans {
match merged.last_mut() {
Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect())
}
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
check_lines_protection(s, p, axis, at, count, false)?;
let areas = delete_areas(s, p, axis)?;
for &(at, count) in &areas {
check_lines_protection(s, p, axis, at, count, false)?;
}
let sheet = target_sheet(s, p)?;
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
cx.protection_checked = true;
{
let sh = cx.sheet_mut(sheet)?;
let block = match axis {
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
};
sh.take_cells(block);
match axis {
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
// Last area first, so the earlier ones keep their positions.
for (at, count) in areas {
{
let sh = cx.sheet_mut(sheet)?;
let block = match axis {
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
};
sh.take_cells(block);
match axis {
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
}
shift_sheet_features(sh, axis, at, count, false);
}
shift_sheet_features(sh, axis, at, count, false);
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
cx.structural = true;
Ok(Json::Null)
})?;
@@ -982,7 +1024,14 @@ fn shift_table_columns(t: &mut gridcraft_model::Table, at: u32, count: u32, inse
break name;
}
};
let col = gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None };
let col = gridcraft_model::TableColumn {
name,
totals: Default::default(),
totals_label: None,
formula: None,
unique_name: None,
xml: None,
};
t.columns.insert((idx + k).min(t.columns.len()), col);
}
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -64,7 +64,14 @@ fn known(s: &Session, w: &str) -> bool {
false
}
/// Longest word we generate suggestions for. Candidate text grows with the square of the word length, so longer
/// tokens (no real word is this long) get no suggestions instead of an unbounded allocation.
const MAX_SUGGEST_LEN: usize = 64;
fn edits1(w: &str) -> Vec<String> {
if w.chars().count() > MAX_SUGGEST_LEN {
return Vec::new();
}
let chars: Vec<char> = w.chars().collect();
let mut out = Vec::new();
let letters = "abcdefghijklmnopqrstuvwxyz";
@@ -177,3 +184,16 @@ fn thesaurus(_: &mut Session, p: &Json) -> Result<Json> {
related.truncate(20);
Ok(json!(related))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits1_is_capped_for_very_long_words() {
assert_eq!(edits1("helo").len(), 54 * 4 + 25);
assert_eq!(edits1(&"a".repeat(MAX_SUGGEST_LEN)).len(), 54 * MAX_SUGGEST_LEN + 25);
assert!(edits1(&"a".repeat(MAX_SUGGEST_LEN + 1)).is_empty());
assert!(edits1(&"a".repeat(1_000_000)).is_empty());
}
}
+4 -2
View File
@@ -10,11 +10,13 @@ use gridcraft_numfmt::{Formatted, NumberFormat, format_value};
/// Parsed number formats are cached by code.
fn parsed(code: &str) -> NumberFormat {
static CACHE: std::sync::LazyLock<Mutex<HashMap<String, NumberFormat>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
let mut m = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(f) = m.get(code) {
let lock = || CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(f) = lock().get(code) {
return f.clone();
}
// Parse outside the lock, so a slow code never blocks the other threads' lookups.
let f = NumberFormat::parse(code);
let mut m = lock();
if m.len() > 4096 {
m.clear();
}
+87 -14
View File
@@ -6,6 +6,7 @@ use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
use crate::cmd::Ctx;
@@ -65,21 +66,25 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
let detected = Pattern::detect(
&src_cells,
&custom,
cx.wb.date_system,
cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(),
);
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
} else {
Pattern::Copy
};
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
// own series, one step per repeat of the source.
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
} else {
Vec::new()
};
for (k, t) in targets.iter().enumerate() {
let step = k as i64 + 1;
let idx = k % src_cells.len().max(1);
let (sc, scell) = src_cells.get(if forward { idx } else { src_cells.len() - 1 - idx }).cloned().unwrap_or((src.start, None));
let src_i = if forward { idx } else { src_cells.len() - 1 - idx };
let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));
let mut cell = scell.clone().unwrap_or_default();
match mode {
FillMode::Formats => {
@@ -96,7 +101,10 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
cell.value = Value::Empty;
}
} else if let Some(v) = pattern.value(step, forward, src_cells.len()) {
} else if let Some(v) = match per_cell.get(src_i) {
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
None => pattern.value(step, forward, src_cells.len()),
} {
cell.value = v;
}
cell.style = style;
@@ -173,8 +181,14 @@ impl Pattern {
let last = nums.last().copied().unwrap_or(0.0);
let is_date = fmt.contains('d') || fmt.contains('y') || (fmt.contains('m') && !fmt.contains('h'));
if nums.len() == 1 {
// A single number copies; a single date increments by a day.
return if is_date { Pattern::Linear { last, first, step: 1.0 } } else { Pattern::Copy };
// A single number copies; a single date increments by a day, a single time by an hour.
return if is_date {
Pattern::Linear { last, first, step: 1.0 }
} else if NumberFormat::parse(fmt).kind() == FormatKind::Time {
Pattern::Linear { last, first, step: 1.0 / 24.0 }
} else {
Pattern::Copy
};
}
// Dates that differ by whole months.
if is_date && nums.len() >= 2 {
@@ -260,8 +274,12 @@ impl Pattern {
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
let base = if forward { *last } else { *first };
let n = base + step * k * sign;
let n = n.abs();
Some(Value::text(format!("{prefix}{n:0width$}{suffix}", width = *width)))
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
let quarter = suffix.is_empty()
&& (1..=4).contains(&base)
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
}
Pattern::List { list, last, first, step, upper, lower } => {
let len = list.len() as i64;
@@ -309,6 +327,23 @@ fn round15(x: f64) -> f64 {
if !p.is_finite() || p == 0.0 { x } else { (x * p).round() / p }
}
/// `1st Period` → `2nd Period`: an English ordinal ending is rewritten to suit the new number.
fn ordinal_suffix(n: i64, suffix: &str) -> String {
let (Some(end), Some(rest)) = (suffix.get(..2), suffix.get(2..)) else { return suffix.to_string() };
if !["st", "nd", "rd", "th"].contains(&end.to_ascii_lowercase().as_str()) || rest.starts_with(char::is_alphanumeric) {
return suffix.to_string();
}
let new = match (n % 10, n % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
let new = if end.chars().all(|c| c.is_ascii_uppercase()) { new.to_ascii_uppercase() } else { new.to_string() };
format!("{new}{rest}")
}
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
let chars: Vec<char> = t.chars().collect();
@@ -338,6 +373,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
let style = sh.style_id(first_cell);
let mut v = start;
for i in 1..len {
let prev = v;
v = match kind {
"growth" => v * step,
"date" => {
@@ -350,7 +386,10 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
let mut left = step.abs() as i64;
while left > 0 {
n += step.signum();
if datetime_from_serial(sys, n).is_some_and(|d| d.weekday != 0 && d.weekday != 6) {
let Some(day) = datetime_from_serial(sys, n) else {
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
};
if day.weekday != 0 && day.weekday != 6 {
left -= 1;
}
}
@@ -361,8 +400,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
}
_ => v + step,
};
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
if let Some(s) = stop
&& ((step >= 0.0 && v > s) || (step < 0.0 && v < s))
&& ((v >= prev && v > s) || (v < prev && v < s))
{
break;
}
@@ -579,4 +619,37 @@ mod tests {
assert_eq!(split_num("Q003x"), Some(("Q".into(), 3, "x".into(), 3)));
assert_eq!(split_num("abc"), None);
}
#[test]
fn times_quarters_ordinals_and_mixed_sources() {
let mut s = crate::Session::new();
s.new_workbook();
let mut fill = |values: serde_json::Value, source: &str, target: &str| {
s.execute("range.setValues", serde_json::json!({"range": source.get(..2).unwrap(), "values": values})).unwrap();
s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let r = RangeRef::parse(target).unwrap();
r.iter().map(|c| crate::display::cell_text(&s.doc().unwrap().wb, sh, c)).collect::<Vec<_>>()
};
assert_eq!(fill(serde_json::json!([["9:00"]]), "A1", "A1:A3"), ["9:00", "10:00", "11:00"]);
assert_eq!(fill(serde_json::json!([["Qtr3"]]), "B1", "B1:B3"), ["Qtr3", "Qtr4", "Qtr1"]);
assert_eq!(fill(serde_json::json!([["Quarter 4"]]), "C1", "C1:C2"), ["Quarter 4", "Quarter 1"]);
assert_eq!(fill(serde_json::json!([["1st Period"]]), "D1", "D1:D4"), ["1st Period", "2nd Period", "3rd Period", "4th Period"]);
assert_eq!(
fill(serde_json::json!([["Product 1"], ["On backorder"]]), "E1:E2", "E1:E6"),
["Product 1", "On backorder", "Product 2", "On backorder", "Product 3", "On backorder"]
);
}
#[test]
fn falling_growth_series_stops_at_its_stop_value() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "growth", "step": 0.5, "stop": 1})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
}
}
+145 -14
View File
@@ -56,7 +56,9 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
/// quote the password.
pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec<String>)> {
if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted {
return open_plain(name, bytes);
let (mut wb, warnings) = open_plain(name, bytes)?;
fit_imported_rows(&mut wb);
return Ok((wb, warnings));
}
// A scheme no password opens is said straight away, rather than after asking for one.
let decrypt_error = |e: gridcraft_xlsx::DecryptError| EngineError::Other(decrypt_message(name, e));
@@ -68,11 +70,36 @@ pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>
if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted {
return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file).")));
}
let (wb, mut warnings) = open_plain(name, &plain)?;
let (mut wb, mut warnings) = open_plain(name, &plain)?;
fit_imported_rows(&mut wb);
warnings.insert(0, DECRYPTED_NOTICE.into());
Ok((wb, warnings))
}
/// Excel auto-fits rows to their tallest font when opening a file: rows without a user-set
/// height grow to fit their content. Rows the file made taller keep their height — our
/// line-height estimate can differ from Excel's, and shrinking files that already look
/// right would be a visible regression. Custom-height rows are left alone entirely.
fn fit_imported_rows(wb: &mut Workbook) {
for si in 0..wb.sheets.len() {
let Some(sh) = wb.sheet(si) else { continue };
let mut rows: Vec<u32> = sh.cells.iter().map(|(c, _)| c.row).collect();
rows.sort_unstable();
rows.dedup();
for row in rows {
let before = wb.sheet(si).and_then(|sh| sh.rows.get(&row)).and_then(|i| i.size);
crate::cmd::auto_row_height(wb, si, row);
let Some(before) = before else { continue };
let after = wb.sheet(si).and_then(|sh| sh.rows.get(&row)).and_then(|i| i.size);
if after.is_none_or(|a| before > a + 0.5)
&& let Some(sh) = wb.sheet_mut(si)
{
sh.rows.entry(row).or_default().size = Some(before);
}
}
}
}
/// What to tell the user when an encrypted workbook can't be decrypted. Never quotes the password.
fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
match e {
@@ -102,6 +129,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let import = match sniffed {
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
// An XLSX package is never read as ODS/XLSB just because of its name.
gridcraft_xlsx::Format::Xlsx => None,
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
};
if let Some(import) = import {
@@ -109,7 +138,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
@@ -144,7 +174,9 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
let clean = clean.trim_matches('\'').to_string();
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
if !clean.is_empty()
&& !clean.eq_ignore_ascii_case("History")
&& let Some(sh) = wb.sheet_mut(0)
{
sh.name = clean;
@@ -153,8 +185,19 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
/// Encodes a workbook in the format chosen by `path`'s extension.
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
// An unknown or missing extension is refused rather than written as an XLSX under that name:
// a typo like `.cvs` would otherwise produce a file no other app recognises.
let Some(kind) = FileKind::from_path(path) else {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
};
save_bytes_as(wb, path, kind)
}
/// Encodes a workbook as `kind`, whatever `path`'s extension says.
pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
@@ -164,17 +207,31 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()),
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()),
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Html => Ok(to_html(wb, sheet).into_bytes()),
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
}
}
/// [`save_bytes`] for the file at `path`: relative paths to linked workbooks are rewritten for
/// its folder, so they still name the same files.
pub fn save_file_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
match source_dir(path).and_then(|f| gridcraft_xlsx::rebase_links(wb, &f)) {
Some(rebased) => save_bytes(&rebased, path),
None => save_bytes(wb, path),
}
}
/// The folder of the file at `path`, which relative paths to linked workbooks are relative to.
pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
}
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
/// transferred to other file formats*): number formats applied, never column-width `#####`.
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
gridcraft_xlsx::write_csv(sh, delimiter, |c| {
// General's grid-width formatting rounds large identifiers to scientific notation.
// Delimited files have no column width, so preserve the whole numeric value instead.
@@ -187,6 +244,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
crate::display::value_text(wb, sh, c)
}
})
.map_err(|e| EngineError::Other(e.to_string()))
}
fn esc(s: &str) -> String {
@@ -296,9 +354,12 @@ impl std::fmt::Write for ClipboardHtml {
}
}
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
/// A sheet as a standalone HTML table (Save as Web Page).
pub fn to_html(wb: &Workbook, sheet: usize) -> String {
let Some(sh) = wb.sheet(sheet) else { return String::new() };
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
let mut out = String::new();
let _ = write!(
out,
@@ -306,7 +367,13 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
esc(&sh.name)
);
if let Some(r) = sh.used_range() {
for row in r.start.row..=r.end.row.min(r.start.row + 100_000) {
if r.height() > HTML_EXPORT_MAX_ROWS {
return Err(EngineError::Other(format!(
"the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.",
r.height()
)));
}
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
@@ -329,7 +396,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
}
}
out.push_str("</table></body></html>");
out
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
@@ -350,8 +417,12 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
// A write or rename that fails (full disk, the workbook locked by another app) leaves
// no temporary file beside the user's workbook.
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})
}
#[cfg(target_arch = "wasm32")]
@@ -429,6 +500,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
mod tests {
use super::*;
#[test]
fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near"));
sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far"));
assert!(save_bytes(&wb, "book.html").is_err());
let sh = wb.sheet_mut(0).unwrap();
sh.remove_cell(CellRef::new(149_999, 0));
sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far"));
let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap();
assert!(html.contains(">near</td>") && html.contains(">far</td>"));
}
#[test]
fn macros_are_saved_only_into_xlsm() {
let mut wb = Workbook::new();
@@ -445,6 +530,37 @@ mod tests {
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
#[test]
fn csv_named_history_does_not_get_the_reserved_sheet_name() {
for file in ["History.csv", "hIsToRy.csv", "history.tsv"] {
let (wb, _) = open_bytes(file, b"1\n").unwrap();
let name = wb.sheets[0].name.clone();
assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}");
}
let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap();
assert_eq!(wb.sheets[0].name, "Sales");
}
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn failed_save_leaves_no_temporary_file() {
let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let blocked = dir.join("Book1.xlsx");
std::fs::create_dir(&blocked).unwrap();
assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err());
assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed");
// An ordinary save still works and leaves only the workbook.
let ok = dir.join("Book2.xlsx");
write_file(ok.to_str().unwrap(), b"workbook").unwrap();
assert_eq!(std::fs::read(&ok).unwrap(), b"workbook");
assert!(!dir.join("Book2.xlsx.sctmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
@@ -608,4 +724,19 @@ mod tests {
"{types}"
);
}
#[test]
fn content_wins_over_a_mismatched_extension() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0));
let package = save_bytes(&wb, "book.xlsx").unwrap();
for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] {
let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}");
}
for name in ["data.xlsx", "data.csv"] {
let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
}
}
}
+5
View File
@@ -13,6 +13,7 @@ pub mod cmd;
pub mod display;
pub mod fill;
pub mod io;
pub mod links;
pub mod outline;
pub mod paper;
pub mod pivot;
@@ -108,6 +109,9 @@ pub struct DocState {
pub sheet_selections: Vec<Selection>,
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
pub unlocked_ranges: Vec<(String, String)>,
/// The format `path`'s content was recognised as when its extension doesn't name one (an
/// XLSX called `budget.dat`): Save writes it back in that format.
pub content_kind: Option<io::FileKind>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -132,6 +136,7 @@ impl DocState {
title,
sheet_selections: vec![],
unlocked_ranges: vec![],
content_kind: None,
}
}
pub fn is_dirty(&self) -> bool {
+83
View File
@@ -0,0 +1,83 @@
//! Which hyperlink targets may be opened. A cell's link comes from the document (often a file
//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
//! the like are refused. Everything without a scheme stays an in-workbook reference.
use gridcraft_core::RangeRef;
/// What a link target is.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LinkKind {
/// A cell, range, name or table of the workbook (`Sheet2!A1`, `Total`).
Internal(String),
/// An address to hand to the system, trimmed: `http`, `https` or `mailto` only.
External(String),
/// Refused, with the reason to show.
Refused(String),
}
const ALLOWED: [&str; 3] = ["http", "https", "mailto"];
/// Classifies a link target. Never panics; the single place that decides what may be opened.
pub fn classify(target: &str) -> LinkKind {
let t = target.trim();
if t.chars().any(char::is_control) {
return LinkKind::Refused("The link contains control characters.".into());
}
if t.starts_with("\\\\") || t.starts_with("//") {
return LinkKind::Refused("Links to network locations aren't opened.".into());
}
if RangeRef::parse(t).is_some() {
return LinkKind::Internal(t.to_string());
}
let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) };
let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if !scheme_like {
return LinkKind::Internal(t.to_string());
}
let lower = scheme.to_ascii_lowercase();
if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() {
LinkKind::External(t.to_string())
} else {
LinkKind::Refused(format!("Links of type \"{lower}:\" aren't opened. Only http, https and mailto links are allowed."))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_http_https_and_mailto_leave_the_workbook() {
for ok in ["http://a.example/x", "HTTPS://A.example", " https://a.example ", "MailTo:a@b.example"] {
assert!(matches!(classify(ok), LinkKind::External(_)), "{ok}");
}
assert_eq!(classify(" https://a.example "), LinkKind::External("https://a.example".into()));
for bad in [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"file:///etc/passwd",
"smb://host/share",
"ms-excel:ofe|u|http://x",
"\\\\host\\share\\x.exe",
"//host/share",
"C:\\Windows\\calc.exe",
"\tjavascript:1",
"java\nscript:1",
"\u{1}http://a.example",
"http:",
"mailto:",
"x-custom://a",
] {
assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad));
}
}
#[test]
fn references_stay_internal() {
for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", " "] {
assert!(matches!(classify(r), LinkKind::Internal(_)), "{r:?} -> {:?}", classify(r));
}
}
}
+219 -34
View File
@@ -560,6 +560,8 @@ struct Line {
values: bool,
/// (column, value, indent, number format)
labels: Vec<(usize, Value, u8, Option<String>)>,
/// The value field of the line when Σ Values sits in Rows (`None`: one per data column).
vi: Option<usize>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -579,6 +581,10 @@ struct Ctx<'a> {
rows: Vec<&'a PivotField>,
row_items: Vec<FieldItems>,
row_fmts: Vec<String>,
/// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields).
vpos: Option<usize>,
/// Value field captions.
val_names: Vec<String>,
}
impl Ctx<'_> {
@@ -588,26 +594,105 @@ impl Ctx<'_> {
(it.value.clone(), fmt, it.label.clone())
}
fn flatten(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
/// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form.
fn level_cell(&self, level: usize) -> (usize, u8) {
if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) }
}
/// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above).
fn values_here(&self, depth: usize, vi: Option<usize>) -> bool {
vi.is_none() && self.vpos == Some(depth)
}
/// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales").
fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec<Line>) {
for (vi, name) in self.val_names.iter().enumerate() {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.to_vec(),
values: true,
labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)],
vi: Some(vi),
});
}
}
/// The Σ Values level at row field depth `depth`: one item per value field, each followed by
/// the row fields below it (`nodes`) or, innermost, holding that value.
fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
let (col, indent) = self.level_cell(depth);
for (vi, name) in self.val_names.iter().enumerate() {
let labels = vec![(col, Value::text(name.as_str()), indent, None)];
let leaf = nodes.is_empty();
out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) });
if !leaf {
self.flatten(nodes, depth, Some(vi), prefix, out);
}
}
}
fn flatten(&self, nodes: &[Node], depth: usize, vi: Option<usize>, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
if self.values_here(depth, vi) {
self.flatten_values(nodes, depth, prefix, out);
return;
}
let n = self.rows.len();
let outline = self.pt.layout == PivotLayout::Outline;
// Σ Values still to come below this level: totals need one line per value field.
let values_below = vi.is_none() && self.vpos.is_some();
let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some()));
for node in nodes {
prefix.push(node.item);
let (v, fmt, label) = self.label_cell(depth, node.item);
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
let leaf = depth + 1 >= n || collapsed || node.children.is_empty();
if leaf && !values_below {
out.push(Line {
kind: LineKind::Item(depth + 1 < n),
leaf: true,
path: prefix.clone(),
values: true,
labels: vec![(col, v, indent, fmt)],
vi,
});
} else if leaf {
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: false,
labels: vec![(col, v, indent, fmt)],
vi,
});
// Outline gives each level its own column, so the captions go in the Values
// column even when the levels between are collapsed away.
let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 };
self.flatten_values(&[], level, prefix, out);
} else if values_below {
// Subtotals per value field, below the group (one line can't hold them all).
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: false,
labels: vec![(col, v, indent, fmt)],
vi,
});
self.flatten(&node.children, depth + 1, None, prefix, out);
if self.pt.subtotals {
self.value_subtotals(col, indent, &label, prefix, out);
}
} else {
let top = self.pt.subtotals && self.pt.subtotals_top;
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
self.flatten(&node.children, depth + 1, prefix, out);
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: top,
labels: vec![(col, v, indent, fmt)],
vi,
});
self.flatten(&node.children, depth + 1, vi, prefix, out);
if self.pt.subtotals && !self.pt.subtotals_top {
out.push(Line {
kind: LineKind::Subtotal,
@@ -615,6 +700,7 @@ impl Ctx<'_> {
path: prefix.clone(),
values: true,
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
vi,
});
}
}
@@ -622,7 +708,8 @@ impl Ctx<'_> {
}
}
fn flatten_tabular(
/// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels.
fn flatten_tabular_values(
&self,
nodes: &[Node],
depth: usize,
@@ -630,24 +717,57 @@ impl Ctx<'_> {
pending: &[(usize, Value, u8, Option<String>)],
out: &mut Vec<Line>,
) {
for (vi, name) in self.val_names.iter().enumerate() {
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if vi == 0 { pending.to_vec() } else { vec![] };
labels.push((depth, Value::text(name.as_str()), 0, None));
if nodes.is_empty() {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) });
} else {
self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out);
}
}
}
fn flatten_tabular(
&self,
nodes: &[Node],
depth: usize,
vi: Option<usize>,
prefix: &mut Vec<u32>,
pending: &[(usize, Value, u8, Option<String>)],
out: &mut Vec<Line>,
) {
if self.values_here(depth, vi) {
self.flatten_tabular_values(nodes, depth, prefix, pending, out);
return;
}
let n = self.rows.len();
let values_below = vi.is_none() && self.vpos.is_some();
let col = depth + usize::from(vi.is_some() && self.vpos.is_some());
for (i, node) in nodes.iter().enumerate() {
prefix.push(node.item);
let (v, fmt, label) = self.label_cell(depth, node.item);
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if i == 0 { pending.to_vec() } else { vec![] };
labels.push((depth, v, 0, fmt));
labels.push((col, v, 0, fmt));
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
if values_below {
self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out);
} else {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi });
}
} else {
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
if self.pt.subtotals {
self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out);
if self.pt.subtotals && values_below {
self.value_subtotals(col, 0, &label, prefix, out);
} else if self.pt.subtotals {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
labels: vec![(col, Value::text(format!("{label} Total")), 0, None)],
vi,
});
}
}
@@ -693,6 +813,12 @@ enum ValSrc {
Calc(usize),
}
/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields
/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count.
fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool {
(col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize
}
/// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
@@ -745,6 +871,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let k = vals.len();
let empty = rows.is_empty() && cols.is_empty() && vals.is_empty();
// Σ Values in Rows (it exists with two or more value fields): its depth among the row fields
// found in the source.
let vpos: Option<usize> = (pt.values_on_rows && k > 1).then(|| {
let p = pt.values_position.unwrap_or(pt.rows.len());
pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count()
});
let on_rows = vpos.is_some();
let row_levels = rows.len() + usize::from(on_rows);
// ---- aggregate
let mut row_items: Vec<FieldItems> = rows.iter().map(|_| FieldItems::default()).collect();
@@ -752,6 +886,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let mut aggs: AggMap = HashMap::new();
let mut row_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_leaves: HashSet<Vec<u32>> = HashSet::new();
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
@@ -788,8 +923,22 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
}
}
// The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`).
let shown = cols
.iter()
.zip(col_items.iter())
.zip(cp.iter())
.position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it)))
.map_or(cp.len(), |d| d + 1);
if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec());
}
row_set.insert(rp);
col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) {
return Err("The PivotTable report will not fit on the sheet.".into());
}
if aggs.len() > 5_000_000 {
return Err("The PivotTable report is too large.".into());
}
@@ -806,24 +955,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
collect_siblings(&row_tree, &mut Vec::new(), &mut siblings);
let level0: Vec<u32> = row_tree.iter().map(|n| n.item).collect();
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
let val_names: Vec<String> = vals.iter().map(|(_, v)| v.name.clone()).collect();
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() };
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() {
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
if row_levels == 0 {
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None });
} else {
if pt.layout == PivotLayout::Tabular {
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines);
} else {
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines);
}
if pt.grand_totals_cols {
lines.push(Line {
// With only Σ Values in Rows its items are the totals already.
if pt.grand_totals_cols && !rows.is_empty() {
let grand = |text: String, vi: Option<usize>| Line {
kind: LineKind::Grand,
leaf: false,
path: vec![],
values: true,
labels: vec![(0, Value::text("Grand Total"), 0, None)],
});
labels: vec![(0, Value::text(text), 0, None)],
vi,
};
if on_rows {
lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi))));
} else {
lines.push(grand("Grand Total".into(), None));
}
}
}
let mut specs: Vec<ColSpec> = Vec::new();
@@ -837,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
let mut data_cols: Vec<(usize, Option<usize>)> = Vec::new();
for (si, _) in specs.iter().enumerate() {
if k == 0 {
if on_rows {
// Each line names its value field; one data column per column item.
data_cols.push((si, None));
} else if k == 0 {
if !cols.is_empty() {
data_cols.push((si, None));
}
@@ -852,12 +1012,12 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
let compact = pt.layout == PivotLayout::Compact;
let l = if rows.is_empty() && cols.is_empty() {
let l = if row_levels == 0 && cols.is_empty() {
0
} else if compact {
1
} else {
rows.len().max(1)
row_levels.max(1)
};
let mut width = l + data_cols.len();
if !shown_filters.is_empty() {
@@ -903,14 +1063,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
let headers_on = pt.show_headers;
let row_header = |g: &mut Grid, r: usize| {
if rows.is_empty() || !headers_on {
if row_levels == 0 || !headers_on {
return;
}
if compact {
g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None);
} else {
for (i, f) in row_fields.iter().enumerate() {
g.set(r, i, Value::text(f.source_col.as_str()), CellKind::Header, 0, None);
// One column per row field, Σ Values ("Values") at its place.
let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect();
if let Some(p) = vpos {
names.insert(p.min(names.len()), "Values");
}
for (i, name) in names.into_iter().enumerate() {
g.set(r, i, Value::text(name), CellKind::Header, 0, None);
}
}
};
@@ -920,8 +1085,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let r = g.push_row(RowKind::Header)?;
row_header(&mut g, r);
for (j, (_, vi)) in data_cols.iter().enumerate() {
if let Some(vi) = vi {
g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None);
match vi {
Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None),
// Values on rows, no column field: the one data column is the grand total.
None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None),
None => {}
}
}
} else {
@@ -933,7 +1101,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() };
g.set(cap, l, Value::text(caption), CellKind::Header, 0, None);
}
let levels = cols.len() + usize::from(k > 1);
let levels = cols.len() + usize::from(k > 1 && !on_rows);
let first_level = g.rows.len();
for _ in 0..levels {
g.push_row(RowKind::Header)?;
@@ -1048,17 +1216,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
&& (k > 1 || parts.is_empty())
{
parts.push(val_name(*vi));
} else if parts.is_empty() {
parts.push("Total".into());
}
Some(((l + j) as u32, parts.join(" - ")))
})
.filter(|_| k > 0)
.collect();
let label_col = if rows.is_empty() {
let label_col = if row_levels == 0 {
None
} else if compact {
Some(0)
} else {
Some(rows.len().saturating_sub(1) as u32)
Some(row_levels.saturating_sub(1) as u32)
};
for line in &lines {
let rk = match line.kind {
@@ -1082,8 +1252,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
continue;
}
let rp = line.path.as_slice();
for (j, (si, vi)) in data_cols.iter().enumerate() {
let (Some(spec), Some(vi)) = (specs.get(*si), vi) else { continue };
for (j, (si, col_vi)) in data_cols.iter().enumerate() {
let line_vi = col_vi.or(line.vi);
let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue };
let Some((_, vdef)) = vals.get(*vi) else { continue };
let cp = spec.path.as_slice();
let Some(raw) = get(rp, cp, *vi) else { continue };
@@ -1428,3 +1599,17 @@ pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
p.last_range = None;
}
}
#[cfg(test)]
mod fit_tests {
use super::data_cols_exceed_sheet;
#[test]
fn column_budget_counts_value_fields_and_grand_total() {
assert!(!data_cols_exceed_sheet(64, true, 128));
assert!(data_cols_exceed_sheet(64, true, 256));
assert!(!data_cols_exceed_sheet(64, false, 256));
assert!(!data_cols_exceed_sheet(16_384, false, 0));
assert!(data_cols_exceed_sheet(16_385, false, 0));
}
}
+7
View File
@@ -43,6 +43,13 @@ impl Selection {
pub fn is_single_cell(&self) -> bool {
self.ranges.len() == 1 && self.current().is_single()
}
/// The corner of the current area opposite the anchor: the end that Shift+arrows move.
pub fn moving_end(&self) -> CellRef {
let r = self.current();
let row = if self.anchor.row == r.start.row { r.end.row } else { r.start.row };
let col = if self.anchor.col == r.start.col { r.end.col } else { r.start.col };
CellRef::new(row, col)
}
/// Extends the current area from the anchor to `to` (Shift+click / Shift+arrows).
pub fn extend_to(&mut self, to: CellRef) {
let r = RangeRef::new(self.anchor, to);
+487
View File
@@ -560,6 +560,28 @@ fn insert_delete_rows_adjust() {
assert_eq!(v(&s, "A1"), Value::Number(10.0));
}
#[test]
fn deleting_a_multi_area_row_selection_removes_every_area_and_undoes_once() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A6"})).unwrap();
// Ctrl-select rows 2, 4 and 5:6 (A1 refs are 1-based).
let sel = &mut s.doc_mut().unwrap().selection;
sel.ranges = vec![gridcraft_core::RangeRef::rows(1, 1), gridcraft_core::RangeRef::rows(3, 3), gridcraft_core::RangeRef::rows(4, 5)];
sel.active = CellRef::new(4, 0);
s.doc_mut().unwrap().undo.clear();
s.execute("home.deleteRows", json!({})).unwrap();
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(3.0), Value::Empty));
assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=#REF!");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((1..=6).map(|r| v(&s, &format!("A{r}"))).collect::<Vec<_>>(), (1..=6).map(|n| Value::Number(n as f64)).collect::<Vec<_>>());
assert!(s.doc().unwrap().undo.is_empty(), "one undo step restored all rows");
// The explicit list form behaves the same, for columns too.
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("home.deleteColumns", json!({"cols": "A:A,C:C"})).unwrap();
assert_eq!((v(&s, "A1"), v(&s, "B1"), v(&s, "C1")), (Value::Number(2.0), Value::Number(4.0), Value::Empty));
}
#[test]
fn move_columns_reorders_without_overwriting_and_undoes_once() {
let mut s = s();
@@ -805,6 +827,46 @@ fn sort_and_filter() {
assert_eq!(r["hiddenRows"], 1);
}
/// Top 10 keeps the top or bottom N items or percent, ties at the threshold included.
#[test]
fn filter_top10_items_and_percent() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Score"], [5], [1], [9], [3], [9], [7], ["x"]]})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
// Top 2 items: 9, 9 (the "x" text row is hidden too).
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2}})).unwrap();
assert_eq!(r["hiddenRows"], 5);
// Bottom 2 items: 1, 3.
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2, "bottom": true}})).unwrap();
assert_eq!(r["hiddenRows"], 5);
// Top 50 percent of 6 numbers = 3 items; the threshold 7 keeps 9, 9, 7.
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 50, "percent": true}})).unwrap();
assert_eq!(r["hiddenRows"], 4);
}
/// Two Top 10 criteria on one column (possible in a native JSON file) each keep their own
/// cutoff: Top 3 and Bottom 4 of 1..5 keep 3 and 4.
#[test]
fn filter_top10_two_criteria_on_one_column() {
use gridcraft_model::FilterCriterion;
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["N"], [1], [2], [3], [4], [5]]})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
if let Some(af) = sh.autofilter.as_mut() {
af.criteria = vec![
(0, FilterCriterion::Top10 { bottom: false, percent: false, count: 3 }),
(0, FilterCriterion::Top10 { bottom: true, percent: false, count: 4 }),
];
}
s.execute("data.reapply", json!({})).unwrap();
let hidden: Vec<u32> = s.active().unwrap().wb.sheet(0).unwrap().rows.iter().filter(|(_, r)| r.hidden).map(|(k, _)| *k).collect();
// Rows are 0-based: values 1, 2 and 5 sit on rows 1, 2 and 5.
assert_eq!(hidden, vec![1, 2, 5]);
}
/// Turning the filter on with only the header row selected filters the data below it (#201).
#[test]
fn filter_on_a_selected_header_row_covers_the_data_below() {
@@ -1007,6 +1069,87 @@ fn spill_references_roundtrip_through_xlsx() {
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0)));
}
/// Fixture for the open-time row fit (issue #833): `row_info` = (size, custom, hidden) for
/// row 5 (index 4), `None` = no row entry at all. Cell A5 = "plain big bold" — whole-cell
/// font 20pt with `big_font`, plain 11pt without (a tall plain row's fit removes its entry).
/// 11pt cell in A1 for contrast rows. Built with the model API, never through commands —
/// commands would auto-fit the rows — and exported with `write_xlsx`, which omits `ht` for
/// rows without a size: exactly the bug's input.
fn big_font_workbook(row_info: Option<(f32, bool, bool)>, big_font: bool) -> Vec<u8> {
let mut wb = gridcraft_model::Workbook::new();
let big = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |s| s.font.size = 20.0);
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(0, 0), Value::text("small"));
sh.set_value(CellRef::new(4, 0), Value::text("plain big bold"));
if big_font {
sh.set_style(CellRef::new(4, 0), big);
}
if let Some((size, custom, hidden)) = row_info {
let e = sh.rows.entry(4).or_default();
e.size = Some(size);
e.custom = custom;
e.hidden = hidden;
}
gridcraft_xlsx::write_xlsx(&wb).unwrap()
}
#[test] // A — the issue's repro
fn open_grows_default_height_rows_to_their_font() {
let default_h = gridcraft_model::Workbook::new().active().unwrap().row_height(0);
let mut s = Session::new();
s.execute("file.open", json!({"name": "bigfont.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(None, true))})).unwrap();
let h = s.doc().unwrap().wb.active().unwrap().row_height(4);
assert!(h >= 30.0, "20pt cell must grow its default-height row on open, got {h}");
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), default_h, "11pt row stays default");
}
#[test] // B — Review Focus 1
fn open_keeps_custom_height_rows() {
let mut s = Session::new();
s.execute("file.open", json!({"name": "custom.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((25.0, true, false)), true))}))
.unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 25.0, "custom-height rows are left alone on open");
}
#[test] // C — Review Focus 2 (Some→Some: the fit shrinks the row, the clamp keeps the taller height)
fn open_never_shrinks_taller_auto_rows() {
let mut s = Session::new();
s.execute("file.open", json!({"name": "tall.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((60.0, false, false)), true))}))
.unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 60.0, "open is grow-only: a 60px auto-height row keeps its height");
}
#[test] // C′ — Review Focus 2 (Some→None: a plain tall row's entry is removed by the fit, the clamp restores it)
fn open_restores_height_of_plain_rows_the_fit_removed() {
let mut s = Session::new();
s.execute(
"file.open",
json!({"name": "tall-plain.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((60.0, false, false)), false))}),
)
.unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 60.0, "a tall plain row keeps its height after the fit removes its entry");
}
#[test] // D — Review Focus 5
fn open_fitting_keeps_hidden_rows_hidden() {
let mut s = Session::new();
s.execute("file.open", json!({"name": "hidden.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((20.0, false, true)), true))}))
.unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let info = sh.rows.get(&4).unwrap();
assert!(info.size.is_some_and(|h| h >= 20.0), "hidden row grows or keeps its height, got {:?}", info.size);
assert!(info.hidden, "fitting a hidden row must not unhide it");
}
#[test] // E — Review Focus 4
fn open_leaves_plain_workbooks_untouched() {
let mut s = Session::new();
s.execute("file.open", json!({"name": "plain.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(None, false))})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(sh.rows.get(&0).is_none_or(|i| i.size.is_none()), "11pt default rows get no height entry");
assert!(sh.rows.values().all(|i| i.size.is_none()), "no row gains a height entry");
}
#[test]
fn tables_and_structured_refs() {
let mut s = s();
@@ -1018,6 +1161,51 @@ fn tables_and_structured_refs() {
assert_eq!(v(&s, "B4"), Value::Number(7.0));
}
#[test]
fn structured_refs_to_columns_with_edge_spaces() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap();
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
assert_eq!(v(&s, "B4"), Value::Number(5.0));
for (cell, input) in [
("F1", "=SUM(Table1[Qty ])"),
("F2", "=Table1[[#Totals],[Qty ]]"),
("F3", "=SUBTOTAL(109,Table1[Qty ])"),
("F4", "=SUM(Table1[[Qty ]])"),
("F5", "=SUM(Table1[Qty])"),
("C2", "=Table1[@[Qty ]]*10"),
] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let n = Value::Number;
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]);
assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref));
assert_eq!(v(&s, "C2"), n(20.0));
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]);
}
#[test]
fn table_rename_keeps_a_defined_name_with_the_same_name() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "100"})).unwrap();
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Data"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Data", "refersTo": "=Sheet1!$D$1"})).unwrap();
s.execute("cell.set", json!({"cell": "E1", "input": "=Data"})).unwrap();
s.execute("cell.set", json!({"cell": "F1", "input": "=SUM(Data[Qty])"})).unwrap();
s.execute("table.rename", json!({"table": "Data", "name": "Later"})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "E1"})).unwrap()["formula"], "=Data");
assert_eq!(s.execute("cell.get", json!({"cell": "F1"})).unwrap()["formula"], "=SUM(Later[Qty])");
assert_eq!((v(&s, "E1"), v(&s, "F1")), (Value::Number(100.0), Value::Number(7.0)));
}
#[test]
fn slicer_filters_table() {
let mut s = s();
@@ -1170,6 +1358,22 @@ fn insert_and_delete_cells_never_shift_part_of_a_table() {
assert!(e.to_string().contains("shift cells in a table"), "{e}");
}
#[test]
fn table_names_with_a_hyphen_are_rejected() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
let e = s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "A-B"})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{e}");
assert!(s.doc().unwrap().wb.table("A-B").is_none());
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Sales"})).unwrap();
let e = s.execute("table.rename", json!({"table": "Sales", "name": "A-B"})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{e}");
assert!(s.doc().unwrap().wb.table("Sales").is_some());
assert!(s.execute("table.rename", json!({"table": "Sales", "name": ".AB"})).is_err());
s.execute("table.rename", json!({"table": "Sales", "name": "A_B"})).unwrap();
assert!(s.doc().unwrap().wb.table("A_B").is_some());
}
#[test]
fn undo_and_redo_keep_the_watch_window_list() {
// Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so
@@ -1519,6 +1723,141 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
std::fs::remove_dir_all(dir).unwrap();
}
/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target).
fn linked_workbook(link: &str) -> Vec<u8> {
use std::io::{Cursor, Write};
const NS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/externalLinks/externalLink1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml"/></Types>"#.to_string();
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>"#
);
let book = format!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{NS_REL}"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets><externalReferences><externalReference r:id="rId2"/></externalReferences></workbook>"#
);
let book_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="{NS_REL}/externalLink" Target="externalLinks/externalLink1.xml"/></Relationships>"#
);
let sheet = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="B1"><f>[1]Sheet1!A1</f><v>10</v></c></row></sheetData></worksheet>"#.to_string();
let link_part = format!(
r#"<externalLink xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{NS_REL}"><externalBook r:id="rId1"><sheetNames><sheetName val="Sheet1"/></sheetNames><sheetDataSet><sheetData sheetId="0"><row r="1"><cell r="A1"><v>10</v></cell></row></sheetData></sheetDataSet></externalBook></externalLink>"#
);
let link_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/externalLinkPath" Target="{link}" TargetMode="External"/></Relationships>"#
);
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, data) in [
("[Content_Types].xml", types),
("_rels/.rels", rels),
("xl/workbook.xml", book),
("xl/_rels/workbook.xml.rels", book_rels),
("xl/worksheets/sheet1.xml", sheet),
("xl/externalLinks/externalLink1.xml", link_part),
("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// The target of the linked workbook in a saved file.
fn link_target(path: &std::path::Path) -> String {
use std::io::Read;
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(path).unwrap())).unwrap();
let mut rels = String::new();
zip.by_name("xl/externalLinks/_rels/externalLink1.xml.rels").unwrap().read_to_string(&mut rels).unwrap();
let (_, after) = rels.split_once("Target=\"").unwrap();
after.split_once('"').unwrap().0.to_string()
}
/// Save As into another folder keeps the link on the same file, as Excel does: the relative path
/// is rewritten from the new folder (#801).
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let a = std::env::temp_dir().join(format!("gridcraft-links-{}-{nonce}", std::process::id()));
let b = a.join("b");
std::fs::create_dir_all(&b).unwrap();
let dest = a.join("Dest.xlsx");
std::fs::write(&dest, linked_workbook("Source.xlsx")).unwrap();
let mut s = s();
s.execute("file.open", json!({"path": dest})).unwrap();
s.execute("file.saveAs", json!({"path": b.join("Dest.xlsx")})).unwrap();
assert_eq!(link_target(&b.join("Dest.xlsx")), "../Source.xlsx");
// Saved again into the folder it was read from, the path is the one it was read with.
s.execute("file.saveAs", json!({"path": a.join("Copy.xlsx")})).unwrap();
assert_eq!(link_target(&a.join("Copy.xlsx")), "Source.xlsx");
// Read from a subfolder and saved into its parent, the path goes down into the subfolder.
let deep = b.join("Deep.xlsx");
std::fs::write(&deep, linked_workbook("Source.xlsx")).unwrap();
let mut s = Session::new();
s.execute("file.open", json!({"path": deep})).unwrap();
s.execute("file.saveAs", json!({"path": a.join("Deep.xlsx")})).unwrap();
assert_eq!(link_target(&a.join("Deep.xlsx")), "b/Source.xlsx");
std::fs::remove_dir_all(a).unwrap();
}
/// A typo like `report.cvs` must not quietly write an XLSX under that name and adopt it as the
/// document's file.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn saving_to_an_unknown_extension_is_refused() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
for name in ["report.cvs", "report"] {
let path = dir.join(name);
let e = s.execute("file.save", json!({"path": path})).unwrap_err().to_string();
assert!(e.contains(".xlsx"), "{name}: {e}");
assert!(!path.exists(), "{name} was written");
}
let d = s.doc().unwrap();
assert!(d.path.is_none());
assert!(d.is_dirty());
assert!(s.execute("file.saveBytes", json!({"format": "foo"})).is_err());
s.execute("file.save", json!({"path": dir.join("report.csv")})).unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
/// An XLSX opened by its content under an unknown extension (`budget.dat`) saves back to its own
/// file as XLSX; a Save As to another unknown extension is still refused.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn an_xlsx_opened_under_an_unknown_extension_saves_back_as_xlsx() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-dat-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("budget.dat");
let mut src = s();
src.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
std::fs::write(&path, crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap()).unwrap();
let mut s = s();
s.execute("file.open", json!({"path": path})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
s.execute("file.save", json!({})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
let bytes = std::fs::read(&path).unwrap();
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsx);
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
assert_eq!(wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(2.0));
assert!(s.execute("file.saveAs", json!({"path": dir.join("copy.foo")})).is_err());
assert!(!dir.join("copy.foo").exists());
// A text file under an unknown extension opens as CSV: Save doesn't turn it into an XLSX.
let notes = dir.join("notes.dat");
std::fs::write(&notes, "a,b\r\n").unwrap();
s.execute("file.open", json!({"path": notes})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "c"})).unwrap();
assert!(s.execute("file.save", json!({})).is_err());
assert_eq!(std::fs::read_to_string(&notes).unwrap(), "a,b\r\n");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validation_formulas_are_relative_and_see_the_new_value() {
let mut s = s();
@@ -1617,6 +1956,17 @@ fn replacing_a_spilling_formula_clears_its_spill() {
assert_eq!((v(&s, "A2"), v(&s, "B1")), (Value::Empty, Value::Number(0.0)));
}
#[test]
fn clearing_a_spill_past_65536_cells_updates_a_late_dependent() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(65537)"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A65536"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=A65537"})).unwrap();
assert_eq!(v(&s, "C2"), Value::Number(65537.0));
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(0.0), Value::Number(0.0)));
}
#[test]
fn range_ending_in_index_recalculates() {
let mut s = s();
@@ -1696,6 +2046,23 @@ fn every_command_survives_empty_params() {
}
}
#[test]
fn non_object_params_do_not_panic_in_wrapper_commands() {
// Issue #516.
for cmd in ["pageLayout.themeColors", "pageLayout.background", "home.drawBorder"] {
for params in [json!([]), json!(5), json!("x"), json!(true)] {
let mut s = s();
let r = s.execute(cmd, params.clone());
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{cmd} {params}: {r:?}");
}
}
// A saved script step forwards its params unchanged into the same call.
let mut s = s();
s.execute("automate.newScript", json!({"name": "bad", "commands": [{"command": "home.drawBorder", "params": []}]})).unwrap();
let r = s.execute("automate.runScript", json!({"name": "bad"}));
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "script: {r:?}");
}
fn fmt_of(s: &Session, a: &str) -> String {
let wb = &s.doc().unwrap().wb;
let sh = wb.active().unwrap();
@@ -2505,6 +2872,15 @@ fn fill_series_month_clamps_to_month_end() {
assert_eq!(v(&s, "A4"), ymd(2026, 4, 15));
}
#[test]
fn fill_series_weekday_out_of_range_step_errors() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "45000"})).unwrap();
let r = s.execute("edit.fillSeries", json!({"range": "A1:A2", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1e12}));
assert!(r.is_err());
assert_eq!(v(&s, "A2"), Value::Empty);
}
#[test]
fn fill_series_month_leap_year() {
let s = series_date("2028-01-31", "A1:A3", "month");
@@ -2549,6 +2925,56 @@ fn notes_comments_and_links_survive_a_json_save() {
assert_eq!(after.cells, before.cells);
}
/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is
/// a place in this document, anything else an existing file or web page. A file saved as a
/// `location` opens nothing in Excel (#800).
#[test]
fn links_to_files_are_saved_as_external_relationships() {
use std::io::Read;
let mut s = s();
s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap();
s.execute("insert.table", json!({"range": "F1:G3", "name": "Sales"})).unwrap();
s.execute("home.insertSheet", json!({"name": "Q#1"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let links = [
("A1", "Source.xlsx", true),
("A2", r"C:\Temp\Book.xlsx", true),
("A3", r"..\Reports\Q1.xlsx", true),
("A4", "Other.xlsx#Sheet1!A2", true),
("A5", "https://example.com/a.xlsx", true),
("A6", "Sheet1!B2", false),
("A7", "#Sheet1!B3", false),
("A8", "Price", false),
("A9", "'Sheet1'!C4:D5", false),
("A10", "Sales", false),
("A11", "'Q#1'!B2", false),
];
for (cell, target, _) in links {
s.execute("insert.link", json!({"cell": cell, "target": target})).unwrap();
}
let bytes = gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
let mut part = |name: &str| {
let mut text = String::new();
zip.by_name(name).unwrap().read_to_string(&mut text).unwrap();
text
};
let (sheet, rels) = (part("xl/worksheets/sheet1.xml"), part("xl/worksheets/_rels/sheet1.xml.rels"));
for (cell, target, external) in links {
let file = target.split_once('#').map_or(target, |(f, _)| f);
let as_rel = format!("Target=\"{file}\" TargetMode=\"External\"");
let as_location = format!("location=\"{}\"", target.trim_start_matches('#'));
assert_eq!(rels.contains(&as_rel), external, "{cell} {target}: {rels}");
assert_eq!(sheet.contains(&as_location), !external, "{cell} {target}: {sheet}");
}
assert!(sheet.contains("location=\"Sheet1!A2\""), "{sheet}");
let (back, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap();
for (cell, target, external) in links {
let h = &back.sheet(0).unwrap().hyperlinks[&CellRef::parse(cell).unwrap()];
assert_eq!((h.target.as_str(), h.external), (target.trim_start_matches('#'), external), "{cell}");
}
}
#[test]
fn vertical_alignment_commands_and_roundtrip() {
let mut s = s();
@@ -2795,3 +3221,64 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn link_open_refuses_every_scheme_but_http_https_and_mailto() {
let mut s = s();
s.execute("insert.link", json!({"cell": "A1", "target": "file:///etc/passwd"})).unwrap();
s.take_ui_requests();
for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] {
assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}");
}
assert!(s.execute("link.open", json!({"cell": "A1"})).is_err());
assert!(s.take_ui_requests().is_empty());
s.execute("link.open", json!({"url": "HTTPS://example.org/a"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("HTTPS://example.org/a".into())]);
s.execute("link.open", json!({"url": "B2"})).unwrap();
assert!(s.take_ui_requests().is_empty());
}
#[test]
fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap();
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(9.0));
// A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL).
s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(15.0));
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(9.0));
s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(15.0));
}
#[test]
fn paste_special_validation_copies_the_rule() {
let mut s = s();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "E5"})).unwrap();
assert!(ok(&mut s, "E5", "-1"));
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
assert!(!ok(&mut s, "C3", "-1"));
assert!(ok(&mut s, "C3", "1"));
assert!(!ok(&mut s, "A1", "-1"));
assert_eq!(s.execute("cell.get", json!({"cell": "C3"})).unwrap()["text"], json!("7"));
}
#[test]
fn paste_special_validation_moves_relative_references() {
let mut s = s();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap();
s.execute("data.validation", json!({"range": "A1", "type": "custom", "formula1": "=A1>10"})).unwrap();
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1");
assert!(ok(&mut s, "C3", "20"));
}
+45
View File
@@ -0,0 +1,45 @@
//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as
//! big as the whole rectangle between them. A refused export is an error and writes nothing.
use gridcraft_engine::Session;
use serde_json::json;
fn two_far_cells() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap();
s
}
#[test]
fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() {
let mut s = two_far_cells();
for format in ["csv", "tsv"] {
let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string();
assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}");
}
// Save As and Export write no file, and the document stays as it was.
let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.csv");
let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string();
assert!(err.contains("over the limit"), "{err}");
assert!(!path.exists(), "nothing is written for a refused export");
std::fs::remove_dir_all(&dir).unwrap();
// Other formats are unaffected, and so is a small export from the same session.
assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok());
s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap();
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3);
}
#[test]
fn exports_below_the_cap_keep_their_positions() {
for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap();
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}");
}
}
+28
View File
@@ -0,0 +1,28 @@
use gridcraft_engine::Session;
use serde_json::json;
fn freeze(s: &Session) -> Option<(u32, u32)> {
s.doc().unwrap().wb.active().unwrap().freeze
}
#[test]
fn showing_a_custom_view_restores_its_frozen_panes() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
s.execute("view.customViews", json!({"save": "Frozen"})).unwrap();
s.execute("view.unfreezePanes", json!({})).unwrap();
assert_eq!(freeze(&s), None);
s.execute("view.customViews", json!({"show": "Frozen"})).unwrap();
assert_eq!(freeze(&s), Some((1, 1)));
}
#[test]
fn showing_an_unfrozen_custom_view_clears_current_panes() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.customViews", json!({"save": "Plain"})).unwrap();
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
s.execute("view.customViews", json!({"show": "Plain"})).unwrap();
assert_eq!(freeze(&s), None);
}
+135
View File
@@ -0,0 +1,135 @@
//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit.
use gridcraft_engine::Session;
use gridcraft_engine::cmd::draw::{ink_points, ink_width};
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::{Shape, ShapeKind};
use serde_json::json;
fn workbook() -> Session {
let mut session = Session::new();
session.new_workbook();
session
}
fn reopen(session: &mut Session) -> Session {
let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap();
reopened
}
fn shapes(session: &Session) -> &[Shape] {
&session.doc().unwrap().wb.active().unwrap().shapes
}
fn assert_anchor(actual: &Shape, expected: &Shape) {
assert_eq!(actual.anchor.cell, expected.anchor.cell);
assert_eq!(actual.anchor.mode, expected.anchor.mode);
for (name, actual, expected) in [
("dx", actual.anchor.dx, expected.anchor.dx),
("dy", actual.anchor.dy, expected.anchor.dy),
("width", actual.anchor.width, expected.anchor.width),
("height", actual.anchor.height, expected.anchor.height),
] {
assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}");
}
}
fn assert_ink(actual: &Shape, expected: &Shape) {
assert_eq!(actual.kind, ShapeKind::Ink);
let actual_points = ink_points(actual);
let expected_points = ink_points(expected);
assert_eq!(actual_points.len(), expected_points.len());
for (actual, expected) in actual_points.iter().zip(&expected_points) {
for axis in 0..2 {
// DrawingML quantizes coordinates to integer EMUs (9,525 per model unit).
assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}");
}
}
assert_eq!(actual.line, expected.line);
assert_eq!(ink_width(actual), ink_width(expected));
assert_anchor(actual, expected);
}
#[test]
fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() {
let mut session = workbook();
session
.execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3}))
.unwrap();
session
.execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7}))
.unwrap();
let expected = shapes(&session).to_vec();
assert_eq!(expected.len(), 2);
for pass in 0..2 {
session = reopen(&mut session);
assert_eq!(shapes(&session).len(), expected.len());
for (actual, expected) in shapes(&session).iter().zip(&expected) {
assert_ink(actual, expected);
}
if pass == 0 {
session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap();
}
}
assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0));
}
#[test]
fn reopened_ink_can_still_be_converted_to_a_shape() {
for (points, expected_kind, expected_name) in [
(vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"),
(vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"),
] {
let mut session = workbook();
session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap();
let original = shapes(&session)[0].clone();
session = reopen(&mut session);
let id = shapes(&session)[0].id;
let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap();
assert_eq!(converted["kind"], expected_name);
let shape = &shapes(&session)[0];
assert_eq!(shape.kind, expected_kind);
assert_eq!(shape.line, original.line);
assert!(shape.text.is_empty());
assert_anchor(shape, &original);
}
}
#[test]
fn ordinary_shape_text_is_not_mistaken_for_ink_points() {
let mut session = workbook();
session
.execute(
"insert.shape",
json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}),
)
.unwrap();
let original = shapes(&session)[0].clone();
for _ in 0..2 {
session = reopen(&mut session);
assert_eq!(shapes(&session).len(), 1);
let shape = &shapes(&session)[0];
assert_eq!(shape.kind, ShapeKind::Rectangle);
assert_eq!(shape.text, original.text);
assert_eq!(shape.fill, original.fill);
assert_eq!(shape.line, original.line);
assert_anchor(shape, &original);
assert!(session.execute("draw.inkToShape", json!({})).is_err());
}
}
#[test]
fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() {
for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] {
let mut session = workbook();
session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap();
let expected = shapes(&session)[0].clone();
for _ in 0..2 {
session = reopen(&mut session);
assert_ink(&shapes(&session)[0], &expected);
}
}
}
@@ -0,0 +1,47 @@
use gridcraft_engine::{EngineError, Session};
use serde_json::json;
fn new_session() -> Session {
let mut session = Session::new();
session.new_workbook();
session
}
#[test]
fn insert_with_an_out_of_range_count_is_a_normal_error() {
for (id, key, at) in [("home.insertRows", "rows", "3:3"), ("home.insertColumns", "cols", "C:C")] {
let mut session = new_session();
let err = session.execute(id, json!({ key: at, "count": u32::MAX })).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{id}: {err:?}");
}
}
#[test]
fn insert_after_a_formatted_line_with_an_out_of_range_count_is_a_normal_error() {
// A custom size on the preceding line and no cells: the used-range guard has nothing to check.
let mut session = new_session();
session.execute("home.rowHeight", json!({"rows": "2:2", "height": 30})).unwrap();
let err = session.execute("home.insertRows", json!({"rows": "3:3", "count": u32::MAX})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
let mut session = new_session();
session.execute("home.columnWidth", json!({"cols": "B:B", "width": 100})).unwrap();
let err = session.execute("home.insertColumns", json!({"cols": "C:C", "count": u32::MAX})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
}
#[test]
fn delete_with_an_out_of_range_count_does_not_panic() {
for (id, key, at) in [("home.deleteRows", "rows", "3:3"), ("home.deleteColumns", "cols", "C:C")] {
let mut session = new_session();
let res = session.execute(id, json!({ key: at, "count": u32::MAX }));
assert!(!matches!(res, Err(EngineError::Internal(..))), "{id}: {res:?}");
}
}
#[test]
fn insert_and_delete_with_a_small_count_still_work() {
let mut session = new_session();
session.execute("home.insertRows", json!({"rows": "1:1", "count": 3})).unwrap();
session.execute("home.deleteRows", json!({"rows": "1:1", "count": 3})).unwrap();
}
+24
View File
@@ -0,0 +1,24 @@
use gridcraft_core::{CellError, CellRef, Value};
use gridcraft_engine::Session;
use serde_json::json;
#[test]
fn cell_set_offset_extreme_arguments_return_ref() {
let mut session = Session::new();
session.new_workbook();
for formula in [
"=OFFSET(B2,1E300,0)",
"=OFFSET(B2,0,1E300)",
"=OFFSET(B2,0,0,1E300,1)",
"=OFFSET(B2,0,0,1,1E300)",
"=OFFSET(B2,-1E300,0)",
"=OFFSET(B2,0,-1E300)",
"=OFFSET(B2,0,0,-1E300,1)",
"=OFFSET(B2,0,0,1,-1E300)",
"=OFFSET(B2,1E300,0,-1E300,1)",
"=OFFSET(B2,0,1E300,1,-1E300)",
] {
session.execute("cell.set", json!({"cell": "D1", "input": formula})).unwrap();
assert_eq!(session.doc().unwrap().wb.sheet(0).unwrap().value(CellRef::new(0, 3)), Value::Error(CellError::Ref), "{formula}");
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Whole-row and whole-column formatting must re-fit row heights (issue #833).
use serde_json::json;
fn sheet_mut(s: &mut gridcraft_engine::Session) -> &mut gridcraft_engine::model::Sheet {
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().wb).sheet_mut(0).unwrap()
}
#[test]
fn whole_row_font_format_grows_row_height_and_undoes() {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A5", "values": [["plain big bold"]]})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().row_height(4);
s.execute("home.fontSize", json!({"range": "5:5", "size": 20})).unwrap();
let after = s.doc().unwrap().wb.active().unwrap().row_height(4);
assert!(after > before + 10.0, "20pt whole-row format should grow the row: before={before}, after={after}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), before, "undo must restore the height");
}
#[test]
fn whole_column_font_format_grows_only_rows_with_cells() {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A50", "values": [["b"]]})).unwrap();
s.execute("home.fontSize", json!({"range": "A:A", "size": 20})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(sh.row_height(0) > 30.0 && sh.row_height(49) > 30.0, "rows with cells grow");
assert!(sh.rows.len() <= 3, "full-column format must fit only rows that have cells, rows map has {} entries", sh.rows.len());
}
#[test]
fn whole_row_format_does_not_resize_custom_height_rows() {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A5", "values": [["plain big bold"]]})).unwrap();
{
let sh = sheet_mut(&mut s);
let e = sh.rows.entry(4).or_default();
e.custom = true;
e.size = Some(25.0);
}
s.execute("home.fontSize", json!({"range": "5:5", "size": 20})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 25.0, "custom-height rows keep their height");
}
#[test]
fn whole_row_format_keeps_hidden_rows_hidden() {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A5", "values": [["plain big bold"]]})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().row_height(4);
sheet_mut(&mut s).rows.entry(4).or_default().hidden = true;
s.execute("home.fontSize", json!({"range": "5:5", "size": 20})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let info = sh.rows.get(&4).unwrap();
assert!(info.size.unwrap_or(before) > before + 10.0, "hidden row still grows to its font: before={before}, size={:?}", info.size);
assert!(info.hidden, "formatting a hidden row must not unhide it");
}
+48
View File
@@ -0,0 +1,48 @@
use gridcraft_engine::Session;
use serde_json::json;
const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\""];
fn with_name(name: &str, refers_to: &str) -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap();
s
}
#[test]
fn saving_a_view_works_when_stored_list_is_not_an_object() {
for text in NOT_OBJECTS {
let mut s = with_name("_sc_custom_views", text);
let out = s.execute("view.customViews", json!({"save": "v"})).unwrap();
assert!(out.get("v").is_some(), "{text}: {out}");
}
}
#[test]
fn saving_a_view_keeps_the_keys_of_a_stored_object() {
let mut s = with_name("_sc_custom_views", "\"{\"\"a\"\":1}\"");
let out = s.execute("view.customViews", json!({"save": "v"})).unwrap();
assert!(out.get("a").is_some() && out.get("v").is_some(), "{out}");
}
#[test]
fn creating_a_script_works_when_stored_list_is_not_an_object() {
for text in NOT_OBJECTS {
let mut s = with_name("_sc_scripts", text);
s.execute("automate.newScript", json!({"name": "s1", "commands": []})).unwrap();
let list = s.execute("automate.allScripts", json!({})).unwrap();
assert!(list.to_string().contains("s1"), "{text}: {list}");
}
}
#[test]
fn stopping_a_recording_works_when_stored_list_is_not_an_object() {
for text in NOT_OBJECTS {
let mut s = with_name("_sc_scripts", text);
s.execute("automate.recordActions", json!({})).unwrap();
s.execute("automate.recordActions", json!({"stop": true, "name": "s1"})).unwrap();
let list = s.execute("automate.allScripts", json!({})).unwrap();
assert!(list.to_string().contains("s1"), "{text}: {list}");
}
}
@@ -0,0 +1,29 @@
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"]]})).unwrap();
s.execute("insert.table", json!({"range":"A1:B2", "header":true, "name":"T"})).unwrap();
s
}
fn table_range(s: &Session) -> String {
s.doc().unwrap().wb.active().unwrap().tables[0].range.a1()
}
#[test]
fn resize_to_disjoint_range_is_rejected_and_keeps_table() {
let mut s = table();
assert!(s.execute("table.resize", json!({"table":"T", "range":"D1:E2"})).is_err());
assert_eq!(table_range(&s), "A1:B2");
assert_eq!(s.doc().unwrap().wb.active().unwrap().tables[0].columns[1].name, "Qty");
}
#[test]
fn resize_to_overlapping_range_still_works() {
let mut s = table();
s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap();
assert_eq!(table_range(&s), "B1:C2");
}
@@ -0,0 +1,34 @@
use gridcraft_core::{CellRef, MAX_ROWS};
use gridcraft_engine::Session;
use serde_json::json;
fn refused(range: &str) {
let mut s = Session::new();
s.new_workbook();
s.execute("insert.table", json!({"range": range, "header": true})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().tables[0].clone();
assert!(s.execute("table.totalRow", json!({"on": true, "table": &before.name})).is_err(), "{range}");
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.tables[0].range, before.range, "{range}");
assert_eq!(sh.tables[0].range.end.row, MAX_ROWS - 1, "{range}");
assert!(!sh.tables[0].totals_row, "{range}");
assert!(sh.value(CellRef::new(MAX_ROWS, 0)).is_empty(), "{range}");
}
#[test]
fn total_row_is_refused_on_a_table_ending_at_the_last_worksheet_row() {
refused("A1048575:B1048576");
refused("A1048576:B1048576");
}
#[test]
fn total_row_still_grows_an_ordinary_table() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Amount"], ["a", 2], ["b", 3]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
let t = &s.doc().unwrap().wb.active().unwrap().tables[0];
assert!(t.totals_row);
assert_eq!(t.range.end.row, 3);
}
+3 -4
View File
@@ -264,11 +264,10 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr {
}
Expr::Ref(r)
}
// Sheet-qualified defined name `Old!MyName` is stored as one string.
// Sheet-qualified defined name `Old!MyName` is stored as one string, with the bare sheet name as the parser
// stores it (the printer adds the quotes).
Expr::Name(n) => match n.split_at_checked(old.len()) {
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => {
Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new)))
}
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => Expr::Name(format!("{new}{rest}")),
_ => Expr::Name(n),
},
other => other,
+18 -6
View File
@@ -50,8 +50,9 @@ fn significant_spaces(toks: Vec<Token>) -> Vec<Token> {
let mut out: Vec<Token> = Vec::with_capacity(toks.len());
for (i, t) in toks.iter().enumerate() {
if t.tok == Tok::Space {
let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_)));
let next_start = toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_)));
let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_) | Tok::Error(_)));
let next_start =
toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_) | Tok::Error(_)));
if prev_end && next_start {
out.push(t.clone());
}
@@ -88,15 +89,19 @@ fn word_is_cell(w: &str) -> Option<Anchor> {
Some(Anchor { row, col, row_abs, col_abs })
}
/// `_xlfn.XLOOKUP` → `XLOOKUP`.
/// `_xlfn.XLOOKUP` → `XLOOKUP`. `_xludf.` is kept (`_xludf.SUM`): it marks a user-defined
/// function, never the built-in of the same name, and files need it back.
pub fn normalize_function_name(name: &str) -> String {
let u = name.to_ascii_uppercase();
for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS.", "_XLUDF."] {
for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS."] {
if let Some(r) = u.strip_prefix(p) {
return r.to_string();
}
}
u
match u.strip_prefix("_XLUDF.") {
Some(r) => format!("_xludf.{r}"),
None => u,
}
}
impl Parser {
@@ -357,6 +362,10 @@ impl Parser {
if w.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Err(self.err(format!("unexpected `{w}`")));
}
// A function passed by name is saved as `_xleta.SUM`.
if let Some(f) = w.get(..7).filter(|p| p.eq_ignore_ascii_case("_xleta.")).and_then(|_| w.get(7..)) {
return Ok(Expr::Name(f.to_ascii_uppercase()));
}
Ok(Expr::Name(w.to_string()))
}
@@ -417,6 +426,9 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr {
pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
let mut specifiers = Vec::new();
let mut cols: Vec<String> = Vec::new();
// Spaces at the edges of a column name are part of it (`Table1[Qty ]` names `Qty `), so the
// untrimmed body is what a lone column is read from.
let raw = body;
let body = body.trim();
let unescape = |s: &str| {
let mut out = String::new();
@@ -468,7 +480,7 @@ pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
} else if let Some(i) = item(body) {
specifiers.push(i);
} else if !body.is_empty() {
cols.push(column(body)?);
cols.push(column(raw)?);
}
} else {
// [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1]
+40 -7
View File
@@ -91,6 +91,16 @@ fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String {
}
pub fn struct_ref(s: &StructRef) -> String {
write_struct_ref(s, true)
}
/// Structured reference as files spell it: `[@Col]` is only Excel's display shorthand, files
/// hold `[[#This Row],[Col]]` (and `[#This Row]` for `[@]`).
pub fn struct_ref_file(s: &StructRef) -> String {
write_struct_ref(s, false)
}
fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#");
let item = |i: &StructItem| match i {
StructItem::All => "#All",
@@ -99,7 +109,7 @@ pub fn struct_ref(s: &StructRef) -> String {
StructItem::Totals => "#Totals",
StructItem::ThisRow => "#This Row",
};
let this_row = s.specifiers == [StructItem::ThisRow];
let this_row = at_shorthand && s.specifiers == [StructItem::ThisRow];
let mut out = s.table.clone();
let simple_col = |c: &str| !c.chars().any(|ch| matches!(ch, ' ' | '[' | ']' | '#' | '\'' | ',' | ':'));
match (&s.col_start, &s.col_end) {
@@ -109,7 +119,12 @@ pub fn struct_ref(s: &StructRef) -> String {
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
}
(Some(c), None) if s.specifiers.is_empty() => {
let _ = write!(out, "[{}]", esc(c));
// Excel writes a name with spaces at its edges in the double-bracket form.
if c.trim() == c {
let _ = write!(out, "[{}]", esc(c));
} else {
let _ = write!(out, "[[{}]]", esc(c));
}
}
(Some(c), None) if this_row => {
if simple_col(c) {
@@ -178,7 +193,15 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
Some(at) => s.push_str(&reference_r1c1(r, at)),
None => s.push_str(&reference_a1(r)),
},
Expr::Name(n) => s.push_str(n),
Expr::Name(n) => match n.rsplit_once('!') {
// A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back.
Some((sheet, name)) => {
s.push_str(&quote_sheet(sheet));
s.push('!');
s.push_str(name);
}
_ => s.push_str(n),
},
Expr::Struct(st) => s.push_str(&struct_ref(st)),
Expr::Unary(op, x) => match op {
UnOp::Neg => {
@@ -224,10 +247,20 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
}
},
},
Expr::Binary(op, a, b) => {
write_expr(s, a, r1c1);
s.push_str(op.symbol());
write_expr(s, b, r1c1);
Expr::Binary(..) => {
// Walk the left spine iteratively: a flat chain such as `1+1+1+...` parses into a
// tree one level deeper per operator, and recursing on it could exhaust the stack.
let mut rest: Vec<(&BinOp, &Expr)> = Vec::new();
let mut first = e;
while let Expr::Binary(op, a, b) = first {
rest.push((op, b));
first = a;
}
write_expr(s, first, r1c1);
for (op, right) in rest.into_iter().rev() {
s.push_str(op.symbol());
write_expr(s, right, r1c1);
}
}
Expr::Call(name, args) => {
s.push_str(name);
+65
View File
@@ -5,6 +5,29 @@ fn rt(src: &str) -> String {
print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")))
}
#[test]
fn quoted_sheet_qualified_name_keeps_quotes() {
assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total");
assert_eq!(rt("'Data'!Total"), "Data!Total");
assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1");
}
#[test]
fn intersection_with_deleted_operand_reparses() {
// Deleting the columns of an intersection operand prints `A1:B2 #REF!`; that text must parse back.
assert_eq!(rt("A1:B2 #REF!"), "A1:B2 #REF!");
assert_eq!(rt("#REF! A1:B2"), "#REF! A1:B2");
assert_eq!(parse("A1:B2 #REF!"), parse("A1:B2 (#REF!)").map(|e| strip_paren(&e)));
}
fn strip_paren(e: &Expr) -> Expr {
match e {
Expr::Binary(op, a, b) => Expr::Binary(*op, Box::new(strip_paren(a)), Box::new(strip_paren(b))),
Expr::Paren(x) => strip_paren(x),
other => other.clone(),
}
}
#[test]
fn roundtrips() {
for f in [
@@ -48,6 +71,8 @@ fn normalises() {
assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)");
assert_eq!(rt("= 1 + 2"), "1+2");
assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)");
assert_eq!(rt("_xlfn.GROUPBY(A1:A3,B1:B3,_xleta.SUM)"), "GROUPBY(A1:A3,B1:B3,SUM)");
assert_eq!(rt("_XLUDF.concat(\"a\",\"b\")"), "_xludf.CONCAT(\"a\",\"b\")");
assert_eq!(rt("true"), "TRUE");
assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1");
}
@@ -183,6 +208,10 @@ fn rename_sheet_updates_qualified_names() {
assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName");
let e = parse("Data!MyName").unwrap();
assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName");
// Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches.
let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's");
assert_eq!(e, Expr::Name("It's!MyName".into()));
assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName");
}
#[test]
@@ -248,6 +277,20 @@ fn unbracketed_this_row_column() {
}
}
#[test]
fn struct_column_keeps_edge_spaces() {
// The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`.
let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() };
assert_eq!(s.col_start.as_deref(), Some("Qty "));
let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() };
assert_eq!(s.col_start.as_deref(), Some(" Qty"));
// Excel writes such a column in the double-bracket form.
assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]");
for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] {
assert_eq!(rt(f), f);
}
}
#[test]
fn trim_references() {
for f in [
@@ -301,3 +344,25 @@ fn trim_references_adjust() {
let refs: Vec<String> = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect();
assert_eq!(refs, ["Sheet2!A1:B5"]);
}
#[test]
fn prints_long_flat_chain_without_deep_recursion() {
// Small stack: a recursive printer overflows long before 20000 levels.
let handle = std::thread::Builder::new()
.stack_size(256 * 1024)
.spawn(|| {
let mut e = Expr::Number(1.0);
for _ in 0..20000 {
e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0)));
}
let text = print(&e);
let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 });
// Dropping the deep tree recurses; leak it so only the printer is under test.
std::mem::forget(e);
(text, r1c1)
})
.expect("spawn");
let (text, r1c1) = handle.join().expect("printer must not overflow the stack");
assert_eq!(text, vec!["1"; 20001].join("+"));
assert_eq!(r1c1, text);
}
+100 -23
View File
@@ -5,6 +5,10 @@ use std::cmp::Ordering;
use gridcraft_core::parse::parse_number_text;
use gridcraft_core::{CellError, Value, compare_numbers, compare_text};
/// Longest text criterion COUNTIF and its relatives accept, in UTF-16 code units as Excel
/// counts them (😀 is two units); a longer one is `#VALUE!`.
const MAX_CRITERION_UTF16_UNITS: usize = 255;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Op {
Eq,
@@ -22,6 +26,15 @@ enum Operand {
Error(CellError),
/// Text (lower-cased chars kept for wildcard matching).
Text(String),
/// A criterion cut to its first 255 UTF-16 units in the middle of a surrogate pair: the
/// complete characters before the cut, followed (in Excel) by a lone high surrogate.
///
/// No cell text holds a lone surrogate, so `=` matches nothing and `<>` everything, as
/// Excel gives 0 for SUMIFS with a criterion of 128 emoji. For `<`, `<=`, `>` and `>=` the
/// criterion sorts just above the kept text: a cell matches `>`/`>=` when it sorts above
/// that text, and `<`/`<=` otherwise. This is an approximation (Excel wasn't checked) and
/// only matters for comparison criteria over 255 units.
CutText(String),
/// Nothing after the operator (`"="`, `"<>"`) or an empty criterion.
Blank,
}
@@ -48,25 +61,52 @@ impl Criterion {
}
}
fn parse_text(t: &str) -> Criterion {
let (op, rest) = if let Some(r) = t.strip_prefix(">=") {
(Op::Ge, r)
} else if let Some(r) = t.strip_prefix("<=") {
(Op::Le, r)
} else if let Some(r) = t.strip_prefix("<>") {
(Op::Ne, r)
} else if let Some(r) = t.strip_prefix('=') {
(Op::Eq, r)
} else if let Some(r) = t.strip_prefix('>') {
(Op::Gt, r)
} else if let Some(r) = t.strip_prefix('<') {
(Op::Lt, r)
} else {
if t.is_empty() {
return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true };
/// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS
/// functions: like `parse`, but text longer than 255 UTF-16 code units is `#VALUE!`, as in
/// Excel (checked on Excel 2016 and Microsoft 365: `"a"×254 & "😀"` is 255 characters but
/// 256 units, and is `#VALUE!`).
pub(crate) fn parse_arg(v: &Value) -> Result<Criterion, CellError> {
match v {
Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)),
// A UTF-8 string has at least as many bytes as UTF-16 units, so short text skips the count.
Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS && t.encode_utf16().nth(MAX_CRITERION_UTF16_UNITS).is_some() => {
Err(CellError::Value)
}
(Op::Eq, t)
};
other => Ok(Criterion::parse(other)),
}
}
/// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel
/// doesn't reject a longer text criterion there: it matches its first 255 UTF-16 code units
/// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and
/// MINIFS). A cut through a surrogate pair matches nothing; see `Operand::CutText`.
pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion {
match v {
Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)),
Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS => {
let mut units = 0;
for (i, c) in t.char_indices() {
if units + c.len_utf16() > MAX_CRITERION_UTF16_UNITS {
if units == MAX_CRITERION_UTF16_UNITS {
return Criterion::parse_text(&t[..i]);
}
// The cut falls between the two halves of `c`.
let (op, rest) = split_op(&t[..i]);
return Criterion { op, operand: Operand::CutText(rest.to_string()), bare_empty: false };
}
units += c.len_utf16();
}
Criterion::parse_text(t)
}
other => Criterion::parse(other),
}
}
fn parse_text(t: &str) -> Criterion {
if t.is_empty() {
return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true };
}
let (op, rest) = split_op(t);
let operand = if rest.is_empty() {
Operand::Blank
} else if let Some(n) = parse_number_text(rest) {
@@ -98,9 +138,10 @@ impl Criterion {
match self.op {
Op::Eq => blank,
Op::Ne => !blank,
// "<" / ">" with nothing: Excel compares against "" for text cells.
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
// empty string never matches ("<=" and ">=" count it in neither).
_ => match &v {
Value::Text(t) => cmp_op(self.op, compare_text(t, "")),
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
_ => false,
},
}
@@ -140,10 +181,35 @@ impl Criterion {
_ => false,
},
},
Operand::CutText(kept) => match self.op {
Op::Eq => false,
Op::Ne => true,
Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
},
}
}
}
/// Splits a leading comparison operator (`=` when there is none) off a text criterion.
fn split_op(t: &str) -> (Op, &str) {
if let Some(r) = t.strip_prefix(">=") {
(Op::Ge, r)
} else if let Some(r) = t.strip_prefix("<=") {
(Op::Le, r)
} else if let Some(r) = t.strip_prefix("<>") {
(Op::Ne, r)
} else if let Some(r) = t.strip_prefix('=') {
(Op::Eq, r)
} else if let Some(r) = t.strip_prefix('>') {
(Op::Gt, r)
} else if let Some(r) = t.strip_prefix('<') {
(Op::Lt, r)
} else {
(Op::Eq, t)
}
}
fn cmp_op(op: Op, o: Ordering) -> bool {
match op {
Op::Eq => o == Ordering::Equal,
@@ -168,19 +234,25 @@ fn tokenize(pattern: &str) -> Vec<Tok> {
while let Some(c) = it.next() {
match c {
'~' => match it.next() {
Some(n) => out.extend(n.to_lowercase().map(Tok::Char)),
Some(n) => out.extend(std::iter::once(lower_char(n)).map(Tok::Char)),
None => out.push(Tok::Char('~')),
},
'*' => out.push(Tok::Star),
'?' => out.push(Tok::Any),
c => out.extend(c.to_lowercase().map(Tok::Char)),
c => out.extend(std::iter::once(lower_char(c)).map(Tok::Char)),
}
}
out
}
/// Lowercase one char to exactly one char, so positions in the lowered text match the original
/// (U+0130 `İ` folds to `i`, like `fold` in text.rs).
fn lower_char(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn lower_chars(s: &str) -> Vec<char> {
s.chars().flat_map(char::to_lowercase).collect()
s.chars().map(lower_char).collect()
}
/// Whether the pattern contains an unescaped wildcard.
@@ -290,6 +362,9 @@ mod tests {
assert!(m("<>", Value::from("a")));
assert!(!m("<>", Value::Empty));
assert!(m("TRUE", Value::Bool(true)));
// A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0).
assert!(!m("<=", Value::from("")));
assert!(!m(">=", Value::from("")));
assert!(m("<b", Value::from("apple")));
assert!(!m("<b", Value::Number(1.0)));
assert!(m("#N/A", Value::Error(CellError::NA)));
@@ -317,6 +392,8 @@ mod tests {
assert_eq!(wildcard_find("b?d", "abxxd bcd", 0), Some(6));
assert_eq!(wildcard_find("c*", "abcd", 0), Some(2));
assert_eq!(wildcard_find("z", "abcd", 0), None);
assert_eq!(wildcard_find("b", "a\u{130}bc", 0), Some(2));
assert!(wildcard_match("i*", "\u{130}stanbul"));
assert!(has_wildcards("a*"));
assert!(!has_wildcards("abc"));
}
+115 -43
View File
@@ -4,7 +4,9 @@
//! The database and the criteria are arrays whose first row holds column labels. Each criteria
//! row is a set of conditions that must all hold (AND); a record matches when any criteria row
//! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that
//! begin with the text, as in Excel's advanced filter.
//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A
//! formula under a label that names no field is a computed criterion, which the host evaluates
//! for each record ([`Ctx::computed_criterion`]).
use std::borrow::Cow;
@@ -49,7 +51,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
fn cell_criterion(v: &Value) -> Option<Criterion> {
match v {
Value::Empty => None,
Value::Text(t) if t.is_empty() => None,
Value::Text(t) => {
let s: &str = t;
let has_op = s.starts_with(['=', '<', '>']);
@@ -65,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option<Criterion> {
}
/// Rows (1-based within the database, excluding the header) of matching records.
fn matching_records(db: &Array, crit: &Array) -> Vec<usize> {
// Per criteria row: list of (database column or None when the label is unknown, criterion).
let mut rows: Vec<Vec<(Option<usize>, Criterion)>> = Vec::new();
fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec<usize> {
// Per criteria row: its index and a list of (criteria column, database column or None when
// the label is unknown, criterion).
let mut rows: Vec<(usize, Vec<(usize, Option<usize>, Criterion)>)> = Vec::new();
for r in 1..crit.rows {
let mut conds = Vec::new();
for c in 0..crit.cols {
@@ -78,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec<usize> {
Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)),
_ => None,
};
conds.push((col, cr));
conds.push((c, col, cr));
}
rows.push(conds);
rows.push((r, conds));
}
let all_match = rows.is_empty();
(1..db.rows)
.filter(|&r| {
.filter(|&rec| {
all_match
|| rows.iter().any(|conds| {
conds.iter().all(|(col, cr)| match col {
Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)),
None => false,
|| rows.iter().any(|(r, conds)| {
conds.iter().all(|(c, col, cr)| match col {
Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)),
// Under a label that names no field, only a formula (a computed
// criterion) can hold.
None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false),
})
})
})
@@ -102,7 +106,7 @@ struct Selection<'a> {
records: Vec<usize>,
}
fn select(a: &[Arg]) -> R<Selection<'_>> {
fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R<Selection<'a>> {
let dbv = &a.first().ok_or(CellError::Value)?.value;
let critv = &a.get(2).ok_or(CellError::Value)?.value;
if let Value::Error(e) = dbv {
@@ -114,31 +118,31 @@ fn select(a: &[Arg]) -> R<Selection<'_>> {
let db = as_array(dbv);
let crit = as_array(critv);
let field = field_index(&db, a)?;
let records = matching_records(&db, &crit);
let records = matching_records(&db, &crit, ctx);
Ok(Selection { db, field, records })
}
/// Numbers of the field among matching records (field required).
fn field_numbers(a: &[Arg]) -> R<Vec<f64>> {
let s = select(a)?;
fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R<Vec<f64>> {
let s = select(a, c)?;
let f = s.field.ok_or(CellError::Value)?;
Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect())
}
fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(field_numbers(a)?.iter().sum())
fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(field_numbers(a, c)?.iter().sum())
}
fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a)?;
fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a, c)?;
if v.is_empty() {
return Err(CellError::Div0);
}
num_val(v.iter().sum::<f64>() / v.len() as f64)
}
fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = select(a)?;
fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let s = select(a, c)?;
let n = match s.field {
Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(),
None => s.records.len(),
@@ -146,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(n as f64)
}
fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = select(a)?;
fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let s = select(a, c)?;
let n = match s.field {
Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(),
None => s.records.len(),
@@ -155,28 +159,31 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(n as f64)
}
fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = select(a)?;
fn dget(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let s = select(a, c)?;
let f = s.field.ok_or(CellError::Value)?;
match s.records.as_slice() {
[] => Err(CellError::Value),
[r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)),
// Records whose field is blank (or "") don't count, as in Excel.
let mut found =
s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty()));
match (found.next(), found.next()) {
(None, _) => Err(CellError::Value),
(Some(v), None) => Ok(v.clone()),
_ => Err(CellError::Num),
}
}
fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a)?;
fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a, c)?;
num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0))
}
fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a)?;
fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a, c)?;
num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0))
}
fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a)?;
fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a, c)?;
if v.is_empty() {
return num_val(0.0);
}
@@ -194,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R<f64> {
Ok(ss / if sample { n - 1.0 } else { n })
}
fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, true)?)
fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, true)?)
}
fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, false)?)
fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, false)?)
}
fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, true)?.sqrt())
fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, true)?.sqrt())
}
fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, false)?.sqrt())
fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, false)?.sqrt())
}
pub(crate) fn specs() -> Vec<FnSpec> {
@@ -371,4 +378,69 @@ mod tests {
is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref);
close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0);
}
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86).
#[test]
fn empty_string_criterion() {
let db = arr(vec![
vec![tv("Index"), tv("Value")],
vec![tv("a"), tv("=")],
vec![tv("b"), Value::Empty],
vec![tv("c"), tv("")],
vec![tv("d"), tv(" ")],
vec![tv("f"), Value::Error(CellError::Value)],
vec![tv("h"), tv("abc")],
vec![tv("i"), nv(0.0)],
]);
let get = |index: &str| {
let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]);
ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)])
};
// Like any plain text, "" is a "begins with" pattern: it matches every text value.
is_text(get("c"), "c");
is_text(get("h"), "h");
is_err(get("b"), CellError::Value);
is_err(get("i"), CellError::Value);
is_err(get("f"), CellError::Value);
let all = arr(vec![vec![tv("Value")], vec![tv("")]]);
close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0);
// Under a label that names no field, "" is a condition that never holds.
let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]);
let get = |label: Value, value: Value| {
let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]);
ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)])
};
is_text(get(Value::Empty, Value::Empty), "a");
is_text(get(tv(""), Value::Empty), "a");
is_err(get(Value::Empty, tv("")), CellError::Value);
is_err(get(tv(""), tv("")), CellError::Value);
}
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119).
#[test]
fn dget_skips_empty_fields() {
let db = arr(vec![
vec![tv("One"), tv("Two")],
vec![tv("a"), Value::Empty],
vec![tv("b"), tv(" ")],
vec![tv("c"), tv("")],
vec![tv("d"), Value::Error(CellError::NA)],
vec![tv("j"), Value::Empty],
vec![tv("j"), tv("Text")],
vec![tv("j"), Value::Empty],
vec![tv("k"), Value::Empty],
vec![tv("k"), tv("")],
]);
let get = |one: &str| {
let crit = arr(vec![vec![tv("One")], vec![tv(one)]]);
ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)])
};
is_err(get("a"), CellError::Value);
is_text(get("b"), " ");
is_err(get("c"), CellError::Value);
is_err(get("d"), CellError::NA);
is_text(get("j"), "Text");
is_err(get("k"), CellError::Value);
}
}
+37 -11
View File
@@ -1,6 +1,6 @@
//! Date and time functions.
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd};
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd, serial_in_system};
use gridcraft_core::parse::parse_input;
use gridcraft_core::{CellError, DateSystem, Value};
@@ -98,10 +98,10 @@ fn second(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
fn now(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(c.now_serial())
num_val(serial_in_system(c.date_system(), c.now_serial()))
}
fn today(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(c.now_serial().floor())
num_val(serial_in_system(c.date_system(), c.now_serial()).floor())
}
fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
@@ -164,7 +164,9 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
if !(1900..=9999).contains(&y) {
return Err(CellError::Num);
}
let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64);
// Excel keeps serial 0's day 0 and gives February 1900 its real 28 days here, though the
// sheet's calendar has a 29 February 1900 (serial 60).
let dd = (d.day as i64).min(days_in_month(y as i32, mo as u32) as i64);
num_val(ymd_serial(sys, y, mo, dd)? as f64)
}
@@ -177,7 +179,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
if !(1900..=9999).contains(&y) {
return Err(CellError::Num);
}
num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64)
// Like EDATE, February 1900 ends on the 28th (serial 59).
num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64)
}
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
@@ -680,17 +683,35 @@ mod tests {
close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0);
close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0);
is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value);
// EDATE and EOMONTH land on it as the end of February 1900.
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0);
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0);
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0);
// EDATE and EOMONTH read it as 29 February, but end February 1900 on the 28th.
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 59.0);
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 59.0);
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 59.0);
close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0);
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0);
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 59.0);
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 59.0);
close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0));
close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
}
#[test]
fn edate_eomonth_early_1900() {
// Excel 365: serial 0 is 0 January 1900 and February 1900 has 28 days here.
close(ev("EDATE", vec![n(0.0), n(0.0)]), 0.0);
close(ev("EDATE", vec![n(0.0), n(1.0)]), 31.0);
close(ev("EDATE", vec![n(0.0), n(12.0)]), 366.0);
close(ev("EOMONTH", vec![n(0.0), n(1.0)]), 59.0);
close(ev("EDATE", vec![n(31.0), n(1.0)]), 59.0);
close(ev("EOMONTH", vec![n(59.0), n(0.0)]), 59.0);
// Unchanged, and the same as Excel.
close(ev("EOMONTH", vec![n(0.0), n(0.0)]), 31.0);
is_err(ev("EOMONTH", vec![n(0.0), n(-1.0)]), CellError::Num);
close(ev("EDATE", vec![n(59.0), n(1.0)]), 88.0);
close(ev("EDATE", vec![n(60.0), n(1.0)]), 89.0);
close(ev("EDATE", vec![n(61.0), n(-1.0)]), 32.0);
close(ev("EOMONTH", vec![n(31.0), n(0.0)]), 31.0);
}
#[test]
fn datedif_units() {
let s = n(d(2001.0, 6.0, 1.0));
@@ -794,6 +815,11 @@ mod tests {
close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0);
let spec = crate::lookup("YEAR").unwrap();
close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0);
// The clock is 2026-10-07 12:00; in the 1904 system that is serial 44840.5.
close(crate::call(crate::lookup("NOW").unwrap(), &[], &mut ctx), 44840.5);
let today = crate::call(crate::lookup("TODAY").unwrap(), &[], &mut ctx);
close(today.clone(), 44840.0);
close(crate::call(spec, &[crate::Arg::val(today)], &mut ctx), 2026.0);
// No fictitious day here: 1904 is a real leap year and 1900 is before the epoch.
let spec = crate::lookup("DATE").unwrap();
close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0);
+61 -7
View File
@@ -30,6 +30,11 @@ fn rows_of(a: &Array) -> Vec<Vec<Value>> {
(0..a.rows).map(|r| (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect()).collect()
}
/// `#NUM!` when an `h` x `w` output would exceed the function cell budget.
fn check_output_size(h: usize, w: usize) -> R<()> {
if h.saturating_mul(w) > MAX_CELLS { Err(CellError::Num) } else { Ok(()) }
}
/// Builds an array from rows (all rows padded to the widest with `pad`). Empty → `#CALC!`.
fn from_rows(rows: Vec<Vec<Value>>, pad: &Value) -> R<Value> {
let h = rows.len();
@@ -37,9 +42,7 @@ fn from_rows(rows: Vec<Vec<Value>>, pad: &Value) -> R<Value> {
if h == 0 || w == 0 {
return Err(CellError::Calc);
}
if h.saturating_mul(w) > MAX_CELLS {
return Err(CellError::Num);
}
check_output_size(h, w)?;
let mut data = Vec::with_capacity(h * w);
for mut r in rows {
r.resize(w, pad.clone());
@@ -321,16 +324,20 @@ fn vstack(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn hstack(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let mut arrays = Vec::new();
let mut h = 0usize;
let mut w = 0usize;
let mut total = 0usize;
for i in 0..args.len() {
let a = arr_arg(args, i)?;
h = h.max(a.rows);
w = w.saturating_add(a.cols);
total = total.saturating_add(a.data.len());
if total > MAX_CELLS {
return Err(CellError::Num);
}
arrays.push(a);
}
// The padded rectangle can be far larger than the inputs; refuse it before building any rows.
check_output_size(h, w)?;
let mut rows = vec![Vec::new(); h];
for a in &arrays {
for (r, row) in rows.iter_mut().enumerate() {
@@ -785,6 +792,12 @@ fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
from_rows(out, &Value::Empty)
}
/// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and
/// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key.
fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) {
(header_rows.saturating_add(body_rows), col_keys.saturating_mul(value_cols).saturating_add(lead))
}
fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let rf = arr_arg(args, 0)?;
let cf = arr_arg(args, 1)?;
@@ -801,9 +814,6 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let ckeys: Vec<Vec<Value>> = p.rows.iter().map(|&r| key_row(&cf, r)).collect();
let rk = distinct_keys(&rkeys, if row_sort.unsigned_abs() as usize <= rf.cols { row_sort } else { 0 });
let ck = distinct_keys(&ckeys, if col_sort.unsigned_abs() as usize <= cf.cols { col_sort } else { 0 });
if rk.len().saturating_add(2).saturating_mul((ck.len() + 1).saturating_mul(vals.cols).saturating_add(rf.cols)) > MAX_CELLS {
return Err(CellError::Num);
}
let mut cell: HashMap<(String, String), Vec<usize>> = HashMap::new();
for ((r, c), &i) in rkeys.iter().zip(ckeys.iter()).zip(p.rows.iter()) {
cell.entry((row_key(r), row_key(c))).or_default().push(i);
@@ -832,6 +842,14 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
cols.insert(0, None);
}
let lead = rf.cols;
let value_row = vals.cols > 1 || p.show_headers;
let header_rows = cf.cols.saturating_add(usize::from(value_row));
let body_rows = rk.len().saturating_add(usize::from(row_total != 0));
// Reject before building any header or body row: every column-field header row is as wide as the whole grid.
let (height, width) = pivot_dims(header_rows, body_rows, cols.len(), vals.cols, lead);
if height.saturating_mul(width) > MAX_CELLS {
return Err(CellError::Num);
}
let mut out: Vec<Vec<Value>> = Vec::new();
// Column header rows: one per column field, plus value names when several value columns.
for level in 0..cf.cols {
@@ -847,7 +865,7 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
out.push(line);
}
if vals.cols > 1 || p.show_headers {
if value_row {
let mut line: Vec<Value> = if p.show_headers { p.field_headers.iter().take(lead).cloned().collect() } else { vec![Value::Empty; lead] };
line.resize(lead, Value::Empty);
for _ in &cols {
@@ -1132,6 +1150,17 @@ mod tests {
is_err(ev("TOROW", vec![av(arr(vec![vec![Value::Empty]])), n(1.0)]), CellError::Calc);
}
#[test]
fn output_size_checked_before_padding() {
assert_eq!(super::check_output_size(1_000_001, 10), Err(CellError::Num));
assert_eq!(super::check_output_size(usize::MAX, 2), Err(CellError::Num));
assert_eq!(super::check_output_size(1_000_000, 10), Ok(()));
// A 1M-row column beside a 9-wide row: ~1M input cells, but the padded result would be 10,000,010.
let tall = Value::from(gridcraft_core::Array::column(vec![nv(1.0); 1_000_001]));
let wide = row(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
is_err(ev("HSTACK", vec![av(tall), av(wide)]), CellError::Num);
}
#[test]
fn wrapping() {
let r = ev("WRAPROWS", vec![av(row(&[1.0, 2.0, 3.0, 4.0, 5.0])), n(2.0)]);
@@ -1225,4 +1254,29 @@ mod tests {
]
);
}
#[test]
fn pivotby_size_check_counts_column_header_rows() {
// One row, n column fields and n value columns: n header rows + value-name row + body row + total row,
// each 1 + 2n wide. The size checked up front matches the grid PIVOTBY builds.
let dims = |k: u32, totals: bool| {
let fields: Vec<f64> = (0..k).map(f64::from).collect();
let mut args = vec![av(row(&[1.0])), av(row(&fields)), av(row(&fields)), t("SUM")];
if !totals {
args.extend([empty(), n(0.0), empty(), n(0.0)]);
}
let r = rows_of(&ev("PIVOTBY", args));
(r.len(), r.first().map_or(0, Vec::len))
};
assert_eq!(dims(2, true), super::pivot_dims(3, 2, 2, 2, 1));
assert_eq!(dims(10, true), (13, 21));
assert_eq!(dims(10, true), super::pivot_dims(11, 2, 2, 10, 1));
assert_eq!(dims(10, false), (12, 11));
assert_eq!(dims(10, false), super::pivot_dims(11, 1, 1, 10, 1));
// n = 2300: the 2300 header rows alone are 10.58M cells, so the check must count them before any is built.
let (h, w) = super::pivot_dims(2301, 2, 2, 2300, 1);
assert!(2300 * w > crate::util::MAX_CELLS && h * w > crate::util::MAX_CELLS);
let wide: Vec<f64> = (0..2300).map(f64::from).collect();
is_err(ev("PIVOTBY", vec![av(row(&[1.0])), av(row(&wide)), av(row(&wide)), t("SUM")]), CellError::Num);
}
}
+4 -1
View File
@@ -321,7 +321,8 @@ fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
neg += x / (1.0 + fr).powi(i as i32);
}
}
if pos == 0.0 || neg == 0.0 {
// No positive flow is not an error in Excel: the formula below gives -1.
if neg == 0.0 {
return Err(CellError::Div0);
}
let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0;
@@ -1813,6 +1814,8 @@ mod tests {
close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6);
close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7);
is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0);
// Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing.
close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12);
close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089);
close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584);
is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num);
+9 -2
View File
@@ -15,7 +15,7 @@
use std::collections::HashMap;
use std::sync::OnceLock;
use gridcraft_core::{CellError, DateSystem, Value};
use gridcraft_core::{Array, CellError, DateSystem, Value};
/// Builds a [`FnSpec`] whose implementation returns `Result<Value, CellError>`.
///
@@ -105,6 +105,12 @@ pub trait Ctx {
fn now_serial(&self) -> f64;
/// Uniform in [0, 1).
fn random(&mut self) -> f64;
/// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds
/// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the
/// criteria didn't come from a reference.
fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option<bool> {
None
}
}
pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value;
@@ -197,7 +203,8 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
})
}
/// Looks a function up by name (case-insensitive; `_xlfn.`, `_xlws.` and `_xludf.`-free names).
/// Looks a function up by name (case-insensitive; `_xlfn.`- and `_xlws.`-free names). A
/// `_xludf.` name is user-defined and never found.
pub fn lookup(name: &str) -> Option<&'static FnSpec> {
let mut n = name.trim().to_ascii_uppercase();
loop {
+139 -8
View File
@@ -75,7 +75,15 @@ fn round_dir(x: f64, digits: i32, up: bool) -> f64 {
}
let s = clean(scaled);
let r = if up { s.ceil() } else { s.floor() };
let out = if neg { r * f } else { clean(r / f) };
// Toward zero, r == 0 with an infinite scale would be 0 * inf = NaN (#NUM!); the result is simply
// zero. Away from zero the true result (10^309 or more) is not a number, so ROUNDUP stays #NUM!.
let out = if !neg {
clean(r / f)
} else if r == 0.0 && !up {
0.0
} else {
r * f
};
if x < 0.0 { -out } else { out }
}
@@ -634,16 +642,18 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)];
/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; higher forms
/// allow subtracting progressively smaller numerals (max ratio 10, 20, 50, 100, 1000).
/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher
/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating:
/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2
/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID.
fn roman_tokens(form: u32) -> Vec<(String, u32)> {
let max_ratio = [10, 20, 50, 100, 1000].get(form as usize).copied().unwrap_or(10);
let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0));
let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect();
for (i, (sc, sv)) in NUMERALS.iter().enumerate() {
for (lc, lv) in NUMERALS.iter().skip(i + 1) {
let ratio = lv / sv;
let pow10 = matches!(sc, 'I' | 'X' | 'C');
let ok = if form == 0 { pow10 && ratio <= 10 } else { ratio <= max_ratio };
let ok = ratio <= if pow10 { pow10_max } else { five_max };
let value = lv - sv;
// Skip pairs that equal a single numeral (VX = V) – they are never shorter.
if ok && !NUMERALS.iter().any(|(_, v)| *v == value) {
@@ -950,6 +960,9 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R<Ve
if rest.is_empty() || rest.len() % 2 != 0 {
return Err(CellError::Value);
}
// A text criterion over 255 characters is `#VALUE!` only when there is a single criteria
// pair. With two or more pairs Excel matches its first 255 characters instead.
let single_pair = rest.len() == 2;
let mut mask = vec![true; rows.saturating_mul(cols)];
for pair in rest.chunks(2) {
let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) };
@@ -957,7 +970,8 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R<Ve
if r.rows != rows || r.cols != cols {
return Err(CellError::Value);
}
let c = Criterion::parse(&crit.value.scalar());
let scalar = crit.value.scalar();
let c = if single_pair { Criterion::parse_arg(&scalar)? } else { Criterion::parse_arg_truncated(&scalar) };
for (m, v) in mask.iter_mut().zip(r.iter()) {
if *m && !c.matches(v) {
*m = false;
@@ -1017,7 +1031,7 @@ fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let range = as_array(&crate::util::arg(a, 0)?.value);
let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar());
let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?;
num_val(range.iter().filter(|v| c.matches(v)).count() as f64)
}
@@ -1026,7 +1040,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
/// the evaluator does that resizing (it has the references), so cells line up one to one here.
fn if_numbers(a: &[Arg]) -> R<Vec<f64>> {
let range = as_array(&crate::util::arg(a, 0)?.value);
let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar());
let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?;
let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None };
let mut out = Vec::new();
for r in 0..range.rows {
@@ -1373,6 +1387,13 @@ mod tests {
close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0);
close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719);
close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0);
for digits in [-308.0, -309.0, -400.0] {
for x in [1234.0, -1234.0, 0.5] {
close(ev2("ROUNDDOWN", x, digits), 0.0);
close(ev2("TRUNC", x, digits), 0.0);
}
}
assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num)));
close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3);
close(ev2("TRUNC", 8.987, 2.0), 8.98);
close(ev1("TRUNC", -8.9), -8.0);
@@ -1553,6 +1574,43 @@ mod tests {
is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num);
}
/// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733):
/// form 2 subtracts I from C and X from M, form 3 also V from M.
#[test]
fn roman_forms_two_and_three_match_excel() {
const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"];
const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"];
let mut cases = Vec::new();
for thousands in 0..4usize {
let m = "M".repeat(thousands);
for (h, hundreds) in HUNDREDS.iter().enumerate() {
if h != 4 && h != 9 {
cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC")));
}
}
for (o, ones) in ONES.iter().enumerate() {
cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}")));
}
for (o, ones) in ONES.iter().enumerate().take(5) {
cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}")));
}
}
assert_eq!(cases.len(), 92);
for (number, form, want) in &cases {
is_text(ev2("ROMAN", *number as f64, *form), want);
}
// Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID.
is_text(ev2("ROMAN", 495.0, 2.0), "XDV");
is_text(ev2("ROMAN", 495.0, 3.0), "VD");
is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX");
is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX");
is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV");
is_text(ev2("ROMAN", 999.0, 4.0), "IM");
is_text(ev2("ROMAN", 995.0, 4.0), "VM");
is_text(ev2("ROMAN", 99.0, 1.0), "VCIV");
is_text(ev2("ROMAN", 99.0, 4.0), "IC");
}
#[test]
fn matrices() {
let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]);
@@ -1643,4 +1701,77 @@ mod tests {
is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value);
is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA);
}
#[test]
fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() {
let ok = "x".repeat(255);
let long = "x".repeat(256);
let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]);
let qty = col(&[1.0, 2.0]);
close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0);
is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value);
close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0);
is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value);
is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value);
// With two or more pairs Excel matches the first 255 characters: the 255-character cell
// (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365.
let over = format!("{ok}y");
close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0);
close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0);
close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0);
close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0);
close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0);
}
#[test]
fn criteria_over_255_utf16_units_follow_excel() {
// The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these
// strings, B is the row number, and each row uses its own string as the criterion.
let e = "😀";
let strings =
[e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)];
let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect());
let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
// COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`.
let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None];
for (s, want) in strings.iter().zip(expected) {
let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]);
let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]);
let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]);
let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]);
match want {
Some([a, b, c, d]) => {
close(countif, a);
close(sumif, b);
close(countifs, c);
close(sumifs, d);
}
// Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which
// matches no cell (for emoji it splits a surrogate pair).
None => {
is_err(countif, CellError::Value);
is_err(sumif, CellError::Value);
is_err(countifs, CellError::Value);
close(sumifs, 0.0);
}
}
}
}
#[test]
fn criteria_cut_inside_a_surrogate_pair() {
let e = "😀";
let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]);
let rows = col(&[1.0, 2.0, 3.0, 4.0]);
let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]);
// "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji.
close(sumifs(format!("={}", e.repeat(128))), 1.0);
// With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion
// equals no cell text: "=" matches nothing and "<>" everything, blanks included.
close(sumifs(format!("=a{}", e.repeat(128))), 0.0);
close(sumifs(format!("<>{}", e.repeat(128))), 10.0);
// Comparisons treat it as sorting just above the 126 kept emoji.
close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0);
close(sumifs(format!("<={}", e.repeat(128))), 3.0);
}
}
+46 -16
View File
@@ -79,10 +79,12 @@ fn mode_sngl(v: &[f64]) -> R<f64> {
}
/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers
/// count, like [`numbers`]. Scalar arguments are stricter than in SUM: Excel answers `#VALUE!`
/// for direct text (even numeric text such as `"1"`) and for a single-cell reference to text or
/// a blank cell (observed: `MODE(B8,"1",2,3)` and `MODE(B9,2,D7)` with D7 blank are `#VALUE!`).
/// Arguments are processed left to right, so the first error wins.
/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical
/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so
/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference
/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to
/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an
/// older Excel that answered `#VALUE!` for typed text; they are not the reference here.
fn mode_numbers(args: &[Arg]) -> R<Vec<f64>> {
let mut out = Vec::new();
for a in args {
@@ -98,9 +100,8 @@ fn mode_numbers(args: &[Arg]) -> R<Vec<f64>> {
}
Value::Number(n) => out.push(*n),
Value::Error(e) => return Err(*e),
Value::Text(_) | Value::Empty => return Err(CellError::Value),
Value::Bool(_) if a.from_ref => {}
v => out.push(v.to_number()?),
Value::Text(_) | Value::Bool(_) => {}
Value::Empty => return Err(CellError::Value),
}
}
Ok(out)
@@ -461,7 +462,7 @@ fn prob(a: &[Arg], _: C) -> R<Value> {
let (xs, ps) = arg_pairs(a, 0, 1)?;
let lo = num(a, 2)?;
let hi = if has(a, 3) { num(a, 3)? } else { lo };
if ps.iter().any(|&p| !(0.0..=1.0).contains(&p)) {
if ps.iter().any(|&p| p <= 0.0 || p > 1.0) {
return Err(CellError::Num);
}
let total: f64 = ps.iter().sum();
@@ -2279,9 +2280,11 @@ mod tests {
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]);
}
/// MODE argument semantics observed in Excel (FormulaEvalTestData, row "Mode"): multi-cell
/// ranges skip text and blanks, but direct text and single-cell references to text or blank
/// cells are `#VALUE!`; the first error from the left wins.
/// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"):
/// text is skipped, typed or referenced, but a single-cell reference to a blank cell is
/// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an
/// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and
/// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`.
#[test]
fn mode_scalar_arguments() {
let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]);
@@ -2290,9 +2293,9 @@ mod tests {
(vec![rf(data)], Ok(1.0)),
(vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)),
// MODE(C9,E16,"1",B8): E16 holds the text "1.1".
(vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::Value)),
(vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::Value)),
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Value)),
(vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)),
(vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)),
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)),
// MODE(B9,2,D7) with D7 blank.
(vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)),
(vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)),
@@ -2305,7 +2308,31 @@ mod tests {
}
}
}
is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), n(1.0)]), CellError::Value);
is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value);
}
/// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736).
#[test]
fn mode_skips_typed_text_and_logicals() {
let cases: Vec<(Vec<crate::Arg>, Result<f64, CellError>)> = vec![
(vec![n(1.0), n(1.0), t("x")], Ok(1.0)),
(vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)),
(vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)),
(vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)),
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)),
(vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)),
(vec![n(1.0), n(1.0), b(true)], Ok(1.0)),
];
for name in ["MODE", "MODE.SNGL"] {
for (args, want) in &cases {
match want {
Ok(x) => close(ev(name, args.clone()), *x),
Err(err) => is_err(ev(name, args.clone()), *err),
}
}
}
assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]);
is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA);
}
#[test]
@@ -2472,7 +2499,10 @@ mod tests {
let p = c(&[0.2, 0.3, 0.1, 0.4]);
close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1);
close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8);
is_err(ev("PROB", vec![x, c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num);
is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num);
// Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits.
is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num);
is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num);
}
#[test]
+30 -7
View File
@@ -1,11 +1,12 @@
//! Text functions.
use gridcraft_core::parse::parse_number_text;
use gridcraft_core::parse::parse_number_text_in;
use gridcraft_core::{CellError, Value, number_to_text};
use crate::criteria::wildcard_find;
use crate::util::{
A, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, text_val,
A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text,
text_val,
};
use crate::{Arg, Ctx, FnSpec, VAR};
@@ -214,7 +215,7 @@ fn find_impl(a: &[Arg], wild: bool) -> R<Value> {
let hay = text(a, 1)?;
let start = opt_num(a, 2, 1.0)?;
let hc = chars(&hay);
if (start.is_nan() || start < 1.0) || start > hc.len() as f64 + 1.0 {
if (start.is_nan() || start < 1.0) || start > hc.len().max(1) as f64 {
return Err(CellError::Value);
}
let start = start as usize - 1;
@@ -294,17 +295,17 @@ fn unicode(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
/// VALUE: a blank cell reads as 0, but empty or blank text is not a number (observed:
/// `VALUE("")` is `#VALUE!`).
fn value(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn value(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
match scalar(arg(a, 0)?) {
Value::Number(n) => num_val(n),
Value::Empty => num_val(0.0),
Value::Error(e) => Err(e),
Value::Text(t) => parse_number_text(&t).ok_or(CellError::Value).and_then(num_val),
Value::Text(t) => parse_number_text_in(&t, c.date_system()).ok_or(CellError::Value).and_then(num_val),
_ => Err(CellError::Value),
}
}
fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let s = text(a, 0)?;
let dec = if a.len() > 1 { text(a, 1)?.chars().next().ok_or(CellError::Value)? } else { '.' };
let grp = if a.len() > 2 { text(a, 2)?.chars().next().ok_or(CellError::Value)? } else { ',' };
@@ -337,7 +338,7 @@ fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
out.push(c);
}
}
let mut n = parse_number_text(&out).ok_or(CellError::Value)?;
let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?;
for _ in 0..pct {
n /= 100.0;
}
@@ -610,6 +611,9 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
let ncols = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
let nrows = rows.len();
if nrows.saturating_mul(ncols) > MAX_CELLS {
return Err(CellError::Num);
}
let mut data = Vec::with_capacity(nrows * ncols);
for r in rows {
let n = r.len();
@@ -841,6 +845,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
fixed
),
f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar),
f!("USDOLLAR", 1, 2, Compatibility, S, "USDOLLAR(number, [decimals])", "Formats a number as currency text, like DOLLAR.", dollar),
f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext),
f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn),
f!(
@@ -1032,6 +1037,15 @@ mod tests {
close(ev("SEARCH", vec![t("~*"), t("a*b")]), 2.0);
is_err(ev("SEARCH", vec![t("z"), t("abc")]), CellError::Value);
is_err(ev("SEARCH", vec![t("a"), t("abc"), n(5.0)]), CellError::Value);
close(ev("SEARCH", vec![t("b"), t("\u{130}b")]), 2.0);
close(ev("SEARCH", vec![t("B"), t("\u{130}b")]), 2.0);
close(ev("SEARCH", vec![t("b?"), t("a\u{130}bc")]), 3.0);
close(ev("SEARCH", vec![t("i"), t("a\u{130}bc")]), 2.0);
is_err(ev("SEARCH", vec![t("b"), t("a\u{130}bc"), n(4.0)]), CellError::Value);
for f in ["FIND", "SEARCH"] {
close(ev(f, vec![t(""), t("abc"), n(3.0)]), 3.0);
is_err(ev(f, vec![t(""), t("abc"), n(4.0)]), CellError::Value);
}
assert_eq!(ev("EXACT", vec![t("a"), t("A")]), Value::Bool(false));
assert_eq!(ev("EXACT", vec![t("a"), t("a")]), Value::Bool(true));
}
@@ -1091,6 +1105,8 @@ mod tests {
is_text(ev("DOLLAR", vec![n(1234.567)]), "$1,234.57");
is_text(ev("DOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
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(-1234.567), n(1.0)]), "($1,234.6)");
is_text(ev("T", vec![t("x")]), "x");
is_text(ev("T", vec![n(1.0)]), "");
}
@@ -1133,6 +1149,13 @@ mod tests {
assert_eq!(rows_of(&v)[1], vec![tv("c"), tv("-")]);
}
#[test]
fn split_rejects_oversized_padded_grid() {
// One row of 8000 columns followed by 8000 one-cell rows: the text is under the cell-text limit, the padded grid is not.
let text = format!("{}{}", "a,".repeat(8000), "b;".repeat(8000));
is_err(ev("TEXTSPLIT", vec![t(&text), t(","), t(";")]), CellError::Num);
}
#[test]
fn to_text() {
is_text(ev("VALUETOTEXT", vec![t("a\"b")]), "a\"b");
+29
View File
@@ -202,6 +202,9 @@ fn basic_tools() {
assert!(f.as_array().unwrap().iter().any(|x| x["name"] == "VLOOKUP"));
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "SUM(1,2,3)"}))["result"], json!(6.0));
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "5"}));
// #697: a number for `input` is entered, not rejected or cleared.
ok(&mut s, "set_cell", json!({"cell": "B1", "input": 42.5}));
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B1"}))["value"], json!(42.5));
ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "A2", "input": "=A1*2"}}));
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0));
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=A1+A2", "cell": "C1"}))["result"], json!(15.0));
@@ -517,3 +520,29 @@ fn text_to_columns_destination_follows_its_sheet() {
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "D0"}));
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "Sheet1!D1"}));
}
#[test]
fn invalid_borders_are_invalid_params() {
let mut s = server();
let bad = |s: &mut Server, id: u64, borders: Value| {
let v = rpc(s, id, "tools/call", json!({"name": "format_range", "arguments": {"range": "A1", "borders": borders}}));
assert_eq!(err_code(&v), -32602, "{v}");
};
bad(&mut s, 1, json!(123));
bad(&mut s, 2, json!("bogus"));
bad(&mut s, 3, json!({"preset": "bogus"}));
bad(&mut s, 4, json!({"style": "thick"}));
ok(&mut s, "format_range", json!({"range": "A1", "borders": "all"}));
ok(&mut s, "format_range", json!({"range": "B1", "borders": {"preset": "all", "style": "thick", "color": "#123456"}}));
}
#[test]
fn invalid_sort_keys_are_invalid_params() {
let mut s = server();
ok(&mut s, "write_range", json!({"range": "A1", "values": [["b"], ["c"], ["a"]]}));
for (id, keys) in [(1, json!([123])), (2, json!([{"order": "desc"}])), (3, json!([{"column": "A", "order": "sideways"}]))] {
let v = rpc(&mut s, id, "tools/call", json!({"name": "sort_range", "arguments": {"range": "A1:A3", "keys": keys}}));
assert_eq!(err_code(&v), -32602, "{v}");
}
ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]}));
}
+39 -3
View File
@@ -180,7 +180,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Enter one cell exactly as if typed: a number, text, or a formula starting with = (\"=AVERAGE(B2:B9)\"). \
Dependent cells recalculate. Returns the command result; use get_cell to read the computed value.",
obj(
json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": string("Typed input: number, text or =formula")}),
json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": {"type": ["string", "number", "boolean"], "description": "Typed input: number, text or =formula"}}),
&["cell", "input"],
),
false,
@@ -391,8 +391,41 @@ fn type_matches(t: &str, v: &Value) -> bool {
}
}
/// Validates `args` against the tool's schema: unknown tool, missing required properties and
/// top-level type mismatches are JSON-RPC "invalid params" errors.
/// Checks a value against a (sub)schema: `type`, `enum`, `oneOf`, array `items`, and for objects the required and known properties.
fn schema_matches(schema: &Value, v: &Value) -> bool {
if let Some(t) = schema["type"].as_str()
&& !type_matches(t, v)
{
return false;
}
if let Some(allowed) = schema["enum"].as_array()
&& !allowed.contains(v)
{
return false;
}
if let Some(alts) = schema["oneOf"].as_array()
&& !alts.iter().any(|alt| schema_matches(alt, v))
{
return false;
}
if let Some(xs) = v.as_array()
&& !xs.iter().all(|x| schema_matches(&schema["items"], x))
{
return false;
}
if let Some(o) = v.as_object() {
if schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str).any(|r| o.get(r).is_none_or(Value::is_null)) {
return false;
}
if let Some(props) = schema["properties"].as_object() {
return o.iter().all(|(k, x)| props.get(k).is_some_and(|ps| x.is_null() || schema_matches(ps, x)));
}
}
true
}
/// Validates `args` against the tool's schema: unknown tool, missing required properties,
/// top-level type mismatches and values matching none of a `oneOf` are JSON-RPC "invalid params" errors.
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
let defs = tool_definitions();
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
@@ -421,6 +454,9 @@ pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
{
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
}
if (p["oneOf"].is_array() || p["items"]["oneOf"].is_array()) && !schema_matches(p, v) {
return Err(format!("{name}: argument `{k}` does not match the schema: {}", p["description"]));
}
}
Ok(())
}
+1 -1
View File
@@ -43,7 +43,7 @@ mod tests {
dates: vec![],
},
);
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) });
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false });
let json = serde_json::to_string(&s).expect("serialize");
let back: Sheet = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.comments, s.comments);
+37 -1
View File
@@ -48,6 +48,23 @@ pub struct TableColumn {
pub totals_label: Option<String>,
/// Calculated column formula.
pub formula: Option<String>,
/// `uniqueName`: the name XML maps know the column by.
#[serde(default)]
pub unique_name: Option<String>,
/// Binding to an element of an XML map. It moves with the column and goes when the column is
/// deleted; inserted columns are unmapped.
#[serde(default)]
pub xml: Option<XmlColumnPr>,
}
/// A table column's binding to an XML map (`<xmlColumnPr>`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct XmlColumnPr {
pub map_id: u32,
pub xpath: String,
/// `xmlDataType` as written in the file (`string`, `boolean`, `integer`…).
pub data_type: String,
pub denormalized: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -70,6 +87,12 @@ pub struct Table {
/// SUBTOTAL distinguishes.
#[serde(default)]
pub filter: Vec<(u32, FilterCriterion)>,
/// `tableType` when it isn't a plain worksheet table: `xml` for a table bound to an XML map.
#[serde(default)]
pub table_type: Option<String>,
/// The connection (`xl/connections.xml`) an XML table is bound through.
#[serde(default)]
pub connection_id: Option<u32>,
}
impl Table {
@@ -304,9 +327,14 @@ pub struct Comment {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Hyperlink {
/// URL, `mailto:`, or an in-workbook location like `Sheet2!A1`.
/// URL, `mailto:`, a file path (`Book.xlsx`, `Book.xlsx#Sheet1!A2`), or an in-workbook location
/// like `Sheet2!A1`.
pub target: String,
pub tooltip: Option<String>,
/// The target is outside this workbook (Excel's "Existing File or Web Page"): a relationship in
/// the file, not a `location`. A bare file name can't be told from a defined name by its text.
#[serde(default)]
pub external: bool,
}
// ---------------------------------------------------------------- drawing objects
@@ -975,6 +1003,12 @@ pub struct PivotTable {
pub last_range: Option<RangeRef>,
/// Calculated fields, usable in the Values area by name.
pub calculated_fields: Vec<PivotCalcField>,
/// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather
/// than Columns (Excel's "values on rows").
pub values_on_rows: bool,
/// Position of Σ Values among the row fields (`None` = after the last). Only used with
/// `values_on_rows`: in Columns Σ Values always comes after the last column field.
pub values_position: Option<usize>,
}
/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in
@@ -1007,6 +1041,8 @@ impl Default for PivotTable {
show_headers: true,
last_range: None,
calculated_fields: vec![],
values_on_rows: false,
values_position: None,
}
}
}
+2 -2
View File
@@ -18,8 +18,8 @@ pub mod workbook;
pub use cell::{Cell, CellPicture, Formula};
pub use features::*;
pub use password::PasswordHash;
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml};
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility};
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
pub use store::CellStore;
pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
+26 -3
View File
@@ -7,6 +7,8 @@
use std::sync::Arc;
use crate::sheet::Visibility;
/// A relationship from an owning part (the package root, the workbook or a sheet, or a kept part)
/// to a kept part or to an external target.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -33,15 +35,29 @@ pub struct PreservedPart {
}
/// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its
/// relationship ids rewritten.
/// relationship ids rewritten, or that Excel needs back unchanged (the workbook's `fileVersion`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedXml {
/// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`).
/// Which slot of the owner it belongs in (`externalReferences`, `fileVersion`, `ext`,
/// `oleObjects`, `controls`).
pub slot: String,
/// The element, namespace declarations it needs included.
pub xml: String,
}
/// A chart sheet (a sheet that holds one chart and no cells) kept as it was in the source file. Its
/// part, drawing, chart, style and colour parts are in [`Preserved::parts`]; the workbook
/// relationship to it is in [`Preserved::rels`].
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedSheet {
pub name: String,
pub visibility: Visibility,
/// The id of the workbook relationship to the chart sheet part, in the source file.
pub rel_id: String,
/// How many worksheets come before it: its position among the model's sheets.
pub before: usize,
}
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
/// or one sheet).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -57,11 +73,18 @@ pub struct Preserved {
/// The sheet's legacy VML drawing when kept elements need its shapes (form controls, OLE
/// objects). Its own relationships point into `parts`.
pub vml: Option<PreservedPart>,
/// Chart sheets, in file order (workbook owner only).
pub sheets: Vec<PreservedSheet>,
}
impl Preserved {
pub fn is_empty(&self) -> bool {
self.package_rels.is_empty() && self.rels.is_empty() && self.parts.is_empty() && self.elements.is_empty() && self.vml.is_none()
self.package_rels.is_empty()
&& self.rels.is_empty()
&& self.parts.is_empty()
&& self.elements.is_empty()
&& self.vml.is_none()
&& self.sheets.is_empty()
}
/// Total bytes held.
pub fn byte_len(&self) -> usize {
+6
View File
@@ -117,6 +117,12 @@ pub struct Sheet {
pub const DEFAULT_COL_WIDTH: f32 = 64.0;
pub const DEFAULT_ROW_HEIGHT: f32 = 20.0;
/// Column width (points) → characters of the default font's digit width (7 px), without the
/// cell padding: the value the Column Width dialog shows.
pub fn points_to_chars(px: f64) -> f64 {
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
}
impl Sheet {
pub fn new(name: impl Into<String>) -> Sheet {
Sheet {
+6
View File
@@ -377,6 +377,12 @@ impl Default for StyleTable {
}
impl StyleTable {
/// A table whose default (Normal) style is `normal`, such as a file's own Normal font.
pub fn with_default(normal: Style) -> Self {
let mut index = HashMap::new();
index.insert(normal.clone(), StyleId::DEFAULT);
StyleTable { styles: vec![normal], index }
}
pub fn get(&self, id: StyleId) -> &Style {
self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE)
}
+5
View File
@@ -116,6 +116,10 @@ pub struct Workbook {
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
#[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>,
/// The folder the workbook was read from, which relative paths to linked workbooks in
/// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON.
#[serde(skip)]
pub source_dir: Option<std::path::PathBuf>,
}
impl Default for Workbook {
@@ -143,6 +147,7 @@ impl Workbook {
vba_project: None,
csv_delimiter: None,
preserved: None,
source_dir: None,
}
}
pub fn sheet(&self, i: usize) -> Option<&Sheet> {
+105 -4
View File
@@ -98,13 +98,38 @@ impl Default for NumberFormat {
}
}
/// Longest number format code, in characters (not bytes), that is parsed: Excel's own limit.
/// A longer code is invalid: [`NumberFormat::parse`] returns a General format (the cell shows
/// its value unformatted; the stored code is kept as is) and `TEXT` returns `#VALUE!`. The cap
/// also bounds every allocation that scales with the number of placeholders in a code.
pub const MAX_FORMAT_CODE_CHARS: usize = 255;
/// True when `code` is longer than [`MAX_FORMAT_CODE_CHARS`] characters.
fn too_long(code: &str) -> bool {
// A code of more than 255 bytes can still be short in characters; count only when needed.
code.len() > MAX_FORMAT_CODE_CHARS && code.chars().count() > MAX_FORMAT_CODE_CHARS
}
impl NumberFormat {
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text.
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text, and
/// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters degrades to General.
pub fn parse(code: &str) -> NumberFormat {
Self::parse_inner(code, false)
}
/// `parse` without the length cap, so tests can reach renderer paths a capped code cannot.
#[cfg(test)]
fn parse_uncapped(code: &str) -> NumberFormat {
Self::parse_inner(code, true)
}
fn parse_inner(code: &str, uncapped: bool) -> NumberFormat {
#[cfg(test)]
parse_cache::count_parse();
let trimmed = code.trim();
// `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other
// value displays as General.
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" {
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" || (!uncapped && too_long(code)) {
return NumberFormat {
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }),
};
@@ -261,7 +286,8 @@ impl NumberFormat {
let Some(mut out) = out else {
return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true);
};
if minus && sec.kind != SecKind::Text {
// Excel drops the minus sign when the first section shows a negative number as all zeros.
if minus && sec.kind != SecKind::Text && !out.zero {
out.prepend("-");
}
(Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false)
@@ -294,6 +320,78 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted
}
}
/// A small per-thread cache of parsed format codes for callers that format many values with the
/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only
/// (the date system and values are applied when formatting), so the code is the whole key.
mod parse_cache {
use std::cell::RefCell;
use std::collections::HashMap;
use super::NumberFormat;
/// Most codes kept per thread. A full cache is emptied, so memory stays bounded.
pub(super) const CAPACITY: usize = 64;
/// Longer codes are parsed every time and never stored.
pub(super) const MAX_CODE_LEN: usize = 256;
thread_local! {
static CACHE: RefCell<HashMap<String, NumberFormat>> = RefCell::new(HashMap::new());
}
pub(super) fn get(code: &str) -> NumberFormat {
if code.len() > MAX_CODE_LEN {
return NumberFormat::parse(code);
}
// Not usable (thread shutting down, re-entered): parse without the cache.
let cached = CACHE
.try_with(|c| {
let mut c = c.try_borrow_mut().ok()?;
if let Some(f) = c.get(code) {
return Some(f.clone());
}
let f = NumberFormat::parse(code);
if c.len() >= CAPACITY {
c.clear();
}
c.insert(code.to_string(), f.clone());
Some(f)
})
.ok()
.flatten();
cached.unwrap_or_else(|| NumberFormat::parse(code))
}
#[cfg(test)]
thread_local! {
static PARSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(super) fn count_parse() {
let _ = PARSES.try_with(|p| p.set(p.get() + 1));
}
/// Parses done on this thread so far (tests only).
#[cfg(test)]
pub(super) fn parses() -> usize {
PARSES.try_with(std::cell::Cell::get).unwrap_or(0)
}
/// Empties this thread's cache (tests only).
#[cfg(test)]
pub(super) fn clear() {
let _ = CACHE.try_with(|c| c.borrow_mut().clear());
}
}
impl NumberFormat {
/// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so
/// formatting many values with one code parses it once. Same result as `parse`.
pub fn parse_cached(code: &str) -> NumberFormat {
parse_cache::get(code)
}
}
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
/// section, or are returned as is.
@@ -301,7 +399,10 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
if code.is_empty() {
return Ok(String::new());
}
let fmt = NumberFormat::parse(code);
if too_long(code) {
return Err(CellError::Value);
}
let fmt = NumberFormat::parse_cached(code);
let n = match v.scalar() {
Value::Error(e) => return Err(e),
Value::Empty => 0.0,
+45 -14
View File
@@ -125,12 +125,20 @@ pub(crate) struct Section {
}
/// Splits a code into sections at unquoted, unescaped, unbracketed semicolons.
///
/// A `[` opens a bracket only when a `]` follows it; the bracket then runs to the next `]` (quotes
/// inside it are not special), exactly as `tokenize` reads it. An unclosed `[` is a literal in both
/// passes, so a later `;` still separates sections. Linear: each `[` either jumps over its bracket
/// or, with no `]` left in the code, is a literal at once.
fn split_sections(code: &str) -> Vec<String> {
let cs: Vec<char> = code.chars().collect();
let last_close = cs.iter().rposition(|&c| c == ']');
let mut out = vec![String::new()];
let mut quoted = false;
let mut bracket = false;
let mut escape = false;
for c in code.chars() {
let mut i = 0usize;
while let Some(&c) = cs.get(i) {
i += 1;
let cur = match out.last_mut() {
Some(c) => c,
None => break,
@@ -145,19 +153,22 @@ fn split_sections(code: &str) -> Vec<String> {
quoted = !quoted;
cur.push(c);
}
'\\' | '_' | '*' if !quoted && !bracket => {
'\\' | '_' | '*' if !quoted => {
escape = true;
cur.push(c);
}
'[' if !quoted => {
bracket = true;
'[' if !quoted && last_close.is_some_and(|l| l >= i) => {
// `i` is already past the `[`; the closing bracket is at or after it.
cur.push(c);
while let Some(&d) = cs.get(i) {
i += 1;
cur.push(d);
if d == ']' {
break;
}
}
}
']' if !quoted => {
bracket = false;
cur.push(c);
}
';' if !quoted && !bracket => {
';' if !quoted => {
if out.len() >= MAX_SECTIONS {
break;
}
@@ -246,6 +257,8 @@ fn tokenize(src: &str) -> Raw {
let cs: Vec<char> = src.chars().collect();
let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false };
let toks = &mut raw.toks;
// With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear.
let last_close = cs.iter().rposition(|&c| c == ']');
let mut i = 0usize;
while let Some(&c) = cs.get(i) {
match c {
@@ -290,7 +303,7 @@ fn tokenize(src: &str) -> Raw {
continue;
}
'[' => {
let close = cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']'));
let close = if last_close.is_some_and(|l| l > i) { cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) } else { None };
if let Some(p) = close {
let content: String = cs.get(i + 1..i + 1 + p).unwrap_or(&[]).iter().collect();
bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency), &content);
@@ -513,6 +526,20 @@ fn analyze(raw: Raw) -> Section {
let mut seen_exp = false;
let mut seen_digit = false;
let mut fraction_slash = false;
// Precomputed so each comma and slash is O(1) (a long run of them stays linear): whether the
// first non-comma token after a position is a digit, and where the last exponent marker is.
let mut digit_after_commas = vec![false; toks.len()];
let mut after = false;
for (j, t) in toks.iter().enumerate().rev() {
if let Some(slot) = digit_after_commas.get_mut(j) {
*slot = after;
}
if !matches!(t, Tok::Comma) {
after = matches!(t, Tok::Digit(_));
}
}
let last_exp = toks.iter().rposition(|t| matches!(t, Tok::Exp { .. }));
let scientific = toks.iter().skip_while(|t| !matches!(t, Tok::Digit(_))).any(|t| matches!(t, Tok::Exp { .. }));
for i in 0..toks.len() {
let Some(t) = toks.get(i).cloned() else { break };
let replace = match t {
@@ -542,11 +569,14 @@ fn analyze(raw: Raw) -> Section {
}
}
Tok::Comma => {
let next = toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Comma)));
let next_digit = matches!(next, Some(Tok::Digit(_)));
let next_digit = digit_after_commas.get(i).copied().unwrap_or(false);
if seen_digit && next_digit && !seen_point && !seen_exp {
sec.thousands = true;
Some(Tok::Lit(String::new()))
} else if seen_digit && scientific {
// Observed: a scientific section never scales; any other comma after the
// first digit shows nothing (`#,e+#`, `#e+#,`, `#e+000,00`).
Some(Tok::Lit(String::new()))
} else if seen_digit && !next_digit {
sec.scale += 1;
Some(Tok::Lit(String::new()))
@@ -558,7 +588,8 @@ fn analyze(raw: Raw) -> Section {
// Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`).
let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_)))
|| matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_)));
let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. }));
// An exponent kept before this bar (`seen_exp`) or any marker still ahead of it.
let has_exp = seen_exp || last_exp.is_some_and(|l| l > i);
if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp {
fraction_slash = true;
sec.fraction = true;
+31 -16
View File
@@ -1,7 +1,7 @@
//! Rendering a value with a parsed section.
use gridcraft_core::DateSystem;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial};
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial};
use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit};
use crate::parse::{DatePart, Section, Tok};
@@ -11,6 +11,8 @@ use crate::parse::{DatePart, Section, Tok};
pub(crate) struct Out {
pub text: String,
pub fill: Option<(char, usize)>,
/// True when every digit shown is zero (a number section that rounded to zero).
pub zero: bool,
}
impl Out {
@@ -58,7 +60,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
/// Renders a date/time. `None` when the serial is outside the supported range.
pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out> {
if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) {
if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) {
return None;
}
let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3);
@@ -231,7 +233,9 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range<usize>) -> Vec<usize> {
/// Renders a non-negative number through a number section.
pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
let mut x = v.abs();
if sec.percent > 0 {
// Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as
// `1%e+5`).
if sec.percent > 0 && !sec.exp {
x *= 100f64.powi(sec.percent.min(20));
}
if sec.scale > 0 {
@@ -247,6 +251,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
let mut pieces: Vec<String> = vec![String::new(); n];
let mut before: Vec<String> = vec![String::new(); n + 1];
let mut lits: Vec<Option<String>> = Vec::new();
let mut zero = false;
if sec.fraction {
lits = vec![None; n];
render_fraction(sec, x, &mut pieces, &mut lits);
@@ -264,7 +269,9 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
let exp_idx = digit_indices(toks, e_at + 1..n);
let step = int_idx.len().max(1) as i32;
let (mut e, int_s, frac_s) = if x == 0.0 {
(0i32, String::new(), "0".repeat(frac_idx.len()))
// Observed: zero shows `0` in every integer placeholder, `#` and `?` too
// (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`).
(0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len()))
} else {
let (digits, xe) = decimal_digits(x);
let mut e = xe - xe.rem_euclid(step);
@@ -283,26 +290,30 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
if int_s.is_empty() && frac_s.chars().all(|c| c == '0') {
e = 0;
}
let mut head = String::from(if upper { "E" } else { "e" });
if e < 0 {
head.push('-');
let head = if upper { "E" } else { "e" };
let sign = if e < 0 {
"-"
} else if plus {
head.push('+');
}
let width = exp_idx.iter().filter(|&&i| matches!(toks.get(i), Some(Tok::Digit('0')))).count().min(20);
let digits = format!("{:0w$}", e.unsigned_abs(), w = width);
"+"
} else {
""
};
let digits = e.unsigned_abs().to_string();
// The sign sits right before the exponent digits, after any literal that follows the
// `E` (observed: `|0|e+|0|` shows `|1|e|+5|`); `?` pads the digits with spaces.
match exp_idx.first() {
Some(&first) => {
if let Some(p) = pieces.get_mut(e_at) {
*p = head;
*p = head.to_string();
}
if let Some(p) = pieces.get_mut(first) {
*p = digits;
if let Some(b) = before.get_mut(first) {
*b = sign.to_string();
}
fill_int(toks, &exp_idx, &digits, false, false, &mut pieces);
}
None => {
if let Some(p) = pieces.get_mut(e_at) {
*p = head + &digits;
*p = format!("{head}{sign}{digits}");
}
}
}
@@ -310,6 +321,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
} else {
fixed(x, frac_idx.len())
};
zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0');
fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces);
if int_idx.is_empty()
&& !int_s.is_empty()
@@ -338,6 +350,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
_ => out.push(pieces.get(i).map(String::as_str).unwrap_or("")),
}
}
out.zero = zero;
out
}
@@ -496,7 +509,9 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
// Denominator digits are zero padded to the `0` placeholders, then left aligned; `?`
// pads with spaces after them.
let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count();
let padded = format!("{den_s:0>zeros$}");
// No `{:0>zeros$}`: a format width above u16::MAX panics.
let mut padded = "0".repeat(zeros.saturating_sub(den_s.chars().count()));
padded.push_str(&den_s);
let dc: Vec<char> = padded.chars().collect();
let count = den_idx.len();
for (k, &ti) in den_idx.iter().enumerate() {
+283 -1
View File
@@ -24,7 +24,7 @@ fn numbers() {
assert_eq!(f("0", -2.5), "-3");
assert_eq!(f("0.00", 2.675), "2.68");
assert_eq!(f("0.00", 1.005), "1.01");
assert_eq!(f("0.0", -0.04), "-0.0");
assert_eq!(f("0.0", -0.04), "0.0");
assert_eq!(f("00000", 123.0), "00123");
assert_eq!(f("000-00-0000", 123456789.0), "123-45-6789");
assert_eq!(f("#.##", 0.5), ".5");
@@ -105,6 +105,29 @@ fn scientific() {
assert_eq!(f("0.00E+00", -12345.0), "-1.23E+04");
}
/// A negative number that rounds to zero in the first section shows no minus sign; a negative
/// section of its own keeps its text (observed Excel results).
#[test]
fn negative_rounding_to_zero_drops_the_sign() {
let cases: &[(&str, f64, &str)] = &[
("0.00", -0.0033, "0.00"),
("0.00", -2.89e-9, "0.00"),
("0.00", -0.005, "-0.01"),
("0", -0.4, "0"),
("0", -0.5, "-1"),
("0.0", -0.004, "0.0"),
("#,##0.00", -0.004, "0.00"),
("0.00%", -2.89e-9, "0.00%"),
("0.00%", -0.0033, "-0.33%"),
("0.00;(0.00)", -0.0033, "(0.00)"),
("0.00;-0.00;\"zero\"", -0.0033, "-0.00"),
("0.00E+00", -2.89e-9, "-2.89E-09"),
];
for &(code, n, want) in cases {
assert_eq!(f(code, n), want, "{code} with {n}");
}
}
#[test]
fn fractions() {
assert_eq!(f("# ?/?", 1.5), "1 1/2");
@@ -247,6 +270,11 @@ fn dates_and_times() {
assert_eq!(f("m/d/yyyy", 60.0), "2/29/1900");
assert_eq!(f("m/d/yyyy", 0.0), "1/0/1900");
assert_eq!(format_value(&Value::Number(0.0), &NumberFormat::parse("m/d/yyyy"), DateSystem::D1904).text, "1/1/1904");
let fmt = NumberFormat::parse("m/d/yyyy");
assert_eq!(format_value(&Value::Number(2957003.5), &fmt, DateSystem::D1904).text, "12/31/9999");
assert_eq!(format_value(&Value::Number(2957004.0), &fmt, DateSystem::D1904).text, "########");
assert_eq!(format_value(&Value::Number(2958465.0), &fmt, DateSystem::D1900).text, "12/31/9999");
assert_eq!(format_value(&Value::Number(2958466.0), &fmt, DateSystem::D1900).text, "########");
assert_eq!(f("m/d/yyyy", -1.0), "########");
assert_eq!(f("m/d/yyyy", 3e6), "########");
assert_eq!(f("[$-409]m/d/yyyy", 46302.0), "10/7/2026");
@@ -339,7 +367,11 @@ fn text_fn() {
assert_eq!(text_function(&Value::Empty, "0.00", S), Ok("0.00".into()));
assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into()));
assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value));
assert_eq!(text_function(&Value::Number(2957003.0), "m/d/yyyy", DateSystem::D1904), Ok("12/31/9999".into()));
assert_eq!(text_function(&Value::Number(2957004.0), "m/d/yyyy", DateSystem::D1904), Err(CellError::Value));
assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into()));
assert_eq!(text_function(&Value::Number(-0.0033), "0", S), Ok("0".into()));
assert_eq!(text_function(&Value::Number(-0.4), "0", S), Ok("0".into()));
// Booleans go through the text section as TRUE/FALSE (observed Excel results).
let cases: &[(bool, &str, &str)] = &[
(true, ";;;@", "TRUE"),
@@ -582,3 +614,253 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() {
let (d, e) = decimal_digits(999999999999999.6);
assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15));
}
// ---- parse cache ----
/// `TEXT()` as it was before the cache: parse the code, then format.
fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
if code.is_empty() {
return Ok(String::new());
}
let fmt = NumberFormat::parse(code);
let n = match v.scalar() {
Value::Error(e) => return Err(e),
Value::Empty => 0.0,
Value::Number(n) => n,
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
Some(n) => n,
None => return Ok(fmt.format_text(&t).text),
},
Value::Array(_) => return Err(CellError::Value),
};
if !n.is_finite() {
return Err(CellError::Num);
}
let (f, overflow) = fmt.format_number(n, sys);
if overflow { Err(CellError::Value) } else { Ok(f.text) }
}
const CACHE_CODES: &[&str] = &[
"0",
"0.00",
"#,##0.00",
"#,##0.00;[Red](#,##0.00)",
"$#,##0.00",
"0%",
"0.0%",
"0.00E+00",
"##0.0E+0",
"General",
"general",
"checkbox",
"@",
"0;@",
"0;-0;;@",
"00000",
"# ?/?",
"# ??/16",
"?/?",
"m/d/yyyy",
"yyyy-mm-dd hh:mm:ss",
"dddd, mmmm d, yyyy",
"h:mm AM/PM",
"[h]:mm:ss",
"mm:ss.000",
"[Red]0;[Blue]-0",
"[>100]0.0;[<5]0.000;0",
"[<=9999999]###-####;(###) ###-####",
"[$-409]d mmm yy",
"\"abc\"0",
"\\x0",
"*-0",
"_(* #,##0_)",
"0.0.0",
"[",
"\"abc",
"0;0;0;0;0",
"#,##0.00;[Red",
"[$-]0",
" 0.00 ",
"[Color99]0",
"0.00_);(0.00)",
"#,,\"M\"",
"yyyy",
"",
];
fn cache_values() -> Vec<Value> {
vec![
Value::Number(0.0),
Value::Number(1234.5678),
Value::Number(-42.5),
Value::Number(0.5),
Value::Number(45000.75),
Value::Number(1e15),
Value::Number(1e-7),
Value::Number(-0.0001),
Value::Number(f64::INFINITY),
Value::from("abc"),
Value::from("12.5"),
Value::Bool(true),
Value::Empty,
Value::Error(CellError::NA),
]
}
#[test]
fn parse_cached_gives_the_same_results_cold_and_warm() {
assert!(CACHE_CODES.len() >= 40);
parse_cache::clear();
let values = cache_values();
for sys in [DateSystem::D1900, DateSystem::D1904] {
// Cold on the first system, warm on the second.
for code in CACHE_CODES {
let direct = NumberFormat::parse(code);
let cached = NumberFormat::parse_cached(code);
assert_eq!(cached.code(), direct.code(), "{code:?}");
assert_eq!(
(cached.kind(), cached.is_date(), cached.is_text(), cached.is_general()),
(direct.kind(), direct.is_date(), direct.is_text(), direct.is_general())
);
for v in &values {
let want = text_uncached(v, code, sys);
assert_eq!(text_function(v, code, sys), want, "TEXT({v:?}, {code:?})");
// Again, now certainly warm.
assert_eq!(text_function(v, code, sys), want, "warm TEXT({v:?}, {code:?})");
assert_eq!(format_value(v, &cached, sys), format_value(v, &direct, sys), "{v:?} {code:?}");
}
}
}
}
#[test]
fn parse_cached_parses_each_code_once() {
parse_cache::clear();
let before = parse_cache::parses();
for i in 0..10_000 {
assert!(text_function(&Value::Number(f64::from(i) + 0.5), "0.00", S).is_ok());
}
assert_eq!(parse_cache::parses() - before, 1);
let before = parse_cache::parses();
for i in 0..10_000 {
let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" };
assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok());
}
// "0.00" is still cached from above: only the second code is new.
assert_eq!(parse_cache::parses() - before, 1);
parse_cache::clear();
let before = parse_cache::parses();
for i in 0..10_000 {
let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" };
assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok());
}
assert_eq!(parse_cache::parses() - before, 2);
}
#[test]
fn parse_cache_is_bounded() {
parse_cache::clear();
// More distinct codes than the capacity: all still format correctly...
let n = parse_cache::CAPACITY + 10;
for i in 0..n {
let c = format!("0.00\"{i}\"");
assert_eq!(text_function(&Value::Number(1.0), &c, S), Ok(format!("1.00{i}")));
}
// ...the first ones were evicted (parsed again), the newest is still cached.
let before = parse_cache::parses();
let newest = format!("0.00\"{}\"", n - 1);
assert!(text_function(&Value::Number(1.0), &newest, S).is_ok());
assert_eq!(parse_cache::parses() - before, 0);
assert!(text_function(&Value::Number(1.0), "0.00\"0\"", S).is_ok());
assert_eq!(parse_cache::parses() - before, 1);
// A code too long (in bytes) for the cache is parsed on every call and not stored. Two-byte
// characters keep it under the 255-character cap that TEXT() refuses past.
let long = format!("0\"{}\"", "é".repeat(parse_cache::MAX_CODE_LEN / 2 + 8));
let before = parse_cache::parses();
for _ in 0..3 {
assert!(text_function(&Value::Number(1.0), &long, S).is_ok());
}
assert_eq!(parse_cache::parses() - before, 3);
}
#[test]
fn fraction_denominator_with_65536_zeros_does_not_panic() {
// `{:0>n$}` panics for a width above u16::MAX; the cap keeps this out of reach of `parse`,
// so the renderer is driven with the uncapped parser.
let code = format!("# ?/{}", "0".repeat(65_536));
let fmt = NumberFormat::parse_uncapped(&code);
let text = format_value(&Value::Number(0.5), &fmt, S).text;
assert!(text.starts_with(" 1/"), "{text}");
// And a denominator with fewer digits than zeros is still zero padded.
assert_eq!(f("# ?/000", 0.5), " 1/002");
}
#[test]
fn format_codes_are_capped_at_255_characters() {
let ok = format!("0{}", "0".repeat(254));
let long = format!("0{}", "0".repeat(255));
assert_eq!(ok.chars().count(), MAX_FORMAT_CODE_CHARS);
assert!(text_function(&Value::Number(1.0), &ok, S).is_ok());
assert_eq!(text_function(&Value::Number(1.0), &long, S), Err(CellError::Value));
// Capped codes display as General.
assert_eq!(f(&long, 0.5), "0.5");
// Characters are counted, not bytes: 255 two-byte characters are fine, 256 are not.
let multi_ok = "é".repeat(255);
assert!(multi_ok.len() > 255);
assert!(text_function(&Value::Number(1.0), &multi_ok, S).is_ok());
assert_eq!(text_function(&Value::Number(1.0), &"é".repeat(256), S), Err(CellError::Value));
// 65536 zeros through the public API: no panic, TEXT is #VALUE!.
let huge = format!("# ?/{}", "0".repeat(65_536));
assert_eq!(text_function(&Value::Number(0.5), &huge, S), Err(CellError::Value));
assert_eq!(f(&huge, 0.5), "0.5");
}
/// Parses and renders `code` on a worker thread; panics if that takes more than 20 s, so a quadratic
/// parser fails the test instead of hanging CI. Not a benchmark: at this size an O(n^2) pass takes
/// minutes and an O(n) pass milliseconds, so the margin is decades wide.
fn finishes_in_time(code: String) -> String {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
// Uncapped: `parse` turns a code past 255 characters into General before the parser sees it.
let _ = tx.send(format_value(&Value::Number(1234.5), &NumberFormat::parse_uncapped(&code), S).text);
});
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("format code did not parse and render in time")
}
#[test]
fn hostile_codes_parse_in_linear_time() {
let n = 1_000_000;
// Unclosed brackets, a run of commas after a digit, a run of fraction bars.
assert!(finishes_in_time("[".repeat(n)).starts_with('['));
finishes_in_time(format!("0{}", ",".repeat(n)));
finishes_in_time(format!("0{}", "/".repeat(n)));
finishes_in_time(format!("0{}0", ",".repeat(n)));
// Unclosed brackets mixed with semicolons: each is a literal and a section separator stays one.
finishes_in_time("[;".repeat(n / 2));
}
#[test]
fn unclosed_bracket_is_a_literal_in_both_passes() {
// Sections still split after an unclosed `[`.
assert_eq!(f("[0;0", 5.0), "[5");
assert_eq!(f("0;[0", -5.0), "[5");
assert_eq!(f("0;-0;[", 0.0), "[");
assert_eq!(parse::parse_sections("0;[Red-0").len(), 2);
assert_eq!(parse::parse_sections("[0;0").len(), 2);
}
#[test]
fn closed_brackets_still_hold_their_semicolons() {
assert_eq!(f("[Red]0;[Blue]-0", -5.0), "-5");
assert_eq!(f("[>=100]0.0;0", 150.0), "150.0");
assert_eq!(f("[$-409]0.00", 2.5), "2.50");
assert_eq!(f("[$€-2] #,##0", 1234.0), "€ 1,234");
assert_eq!(parse::parse_sections("[Red]0;[Blue]0").len(), 2);
assert_eq!(parse::parse_sections("[unknown;x]0;0").len(), 2);
assert_eq!(f("0\\[0", 5.0), "0[5");
assert_eq!(f("\"[\"0;0", 5.0), "[5");
}
@@ -0,0 +1,272 @@
//! `TEXT(value, format)` against the results Excel cached in Apache POI's
//! `test-data/spreadsheet/NumberFormatApproxTests.xlsx` (sheet `Tests`: B = format, C = value,
//! A = `=TEXT(C, B)`). The workbook is not committed; its rows are copied here as data.
use gridcraft_core::{DateSystem, Value};
use gridcraft_numfmt::text_function;
/// (format, value, Excel's result).
// The last row's 3.14159 is the workbook's value, not an approximation of pi.
#[allow(clippy::approx_constant)]
const CASES: &[(&str, f64, &str)] = &[
(r"|#,e-#|", 123456.789, r"|1e5|"),
(r"|#e-#,|", 123456.789, r"|1e5|"),
(r"|#%e-#|", 123456.789, r"|1%e5|"),
(r"|#e-#%|", 123456.789, r"|1e5%|"),
(r"|#|e-|#|", 123456.789, r"|1|e|5|"),
(r"|#.#|e-|#|", 123456.789, r"|1.2|e|5|"),
(r"|####.####|e-|#|", 123456.789, r"|12.3457|e|4|"),
(r"|#######.####|e-|#|", 123456.789, r"|123456.789|e|0|"),
(r"|#,######.####|e-|#|", 123456.789, r"|123,456.789|e|0|"),
(r"|0|e-|0|", 123456.789, r"|1|e|5|"),
(r"|0.0|e-|0|", 123456.789, r"|1.2|e|5|"),
(r"|0000.0000|e-|0|", 123456.789, r"|0012.3457|e|4|"),
(r"|0000000.0000|e-|0|", 123456.789, r"|0123456.7890|e|0|"),
(r"|0,000000.0000|e-|0|", 123456.789, r"|0,123,456.7890|e|0|"),
(r"|?|e-|?|", 123456.789, r"|1|e|5|"),
(r"|?.?|e-|?|", 123456.789, r"|1.2|e|5|"),
(r"|????.????|e-|?|", 123456.789, r"| 12.3457|e|4|"),
(r"|???????.????|e-|?|", 123456.789, r"| 123456.789 |e|0|"),
(r"|?,??????.????|e-|?|", 123456.789, r"| 123,456.789 |e|0|"),
(r"|#,e+#|", 123456.789, r"|1e+5|"),
(r"|#e+#,|", 123456.789, r"|1e+5|"),
(r"|#%e+#|", 123456.789, r"|1%e+5|"),
(r"|#e+#%|", 123456.789, r"|1e+5%|"),
(r"|#|e+|#|", 123456.789, r"|1|e|+5|"),
(r"|#.#|e+|#|", 123456.789, r"|1.2|e|+5|"),
(r"|####.####|e+|#|", 123456.789, r"|12.3457|e|+4|"),
(r"|#######.####|e+|#|", 123456.789, r"|123456.789|e|+0|"),
(r"|#,######.####|e+|#|", 123456.789, r"|123,456.789|e|+0|"),
(r"|0|e+|0|", 123456.789, r"|1|e|+5|"),
(r"|0.0|e+|0|", 123456.789, r"|1.2|e|+5|"),
(r"|0000.0000|e+|0|", 123456.789, r"|0012.3457|e|+4|"),
(r"|0000000.0000|e+|0|", 123456.789, r"|0123456.7890|e|+0|"),
(r"|0,000000.0000|e+|0|", 123456.789, r"|0,123,456.7890|e|+0|"),
(r"|?|e+|?|", 123456.789, r"|1|e|+5|"),
(r"|?.?|e+|?|", 123456.789, r"|1.2|e|+5|"),
(r"|????.????|e+|?|", 123456.789, r"| 12.3457|e|+4|"),
(r"|???????.????|e+|?|", 123456.789, r"| 123456.789 |e|+0|"),
(r"|?,??????.????|e+|?|", 123456.789, r"| 123,456.789 |e|+0|"),
(r"|#.#|E-|#|", 123456.789, r"|1.2|E|5|"),
(r"|#.#|E+|#|", 123456.789, r"|1.2|E|+5|"),
(r"|#,e-#|", 1.23456789e-05, r"|1e-5|"),
(r"|#e-#,|", 1.23456789e-05, r"|1e-5|"),
(r"|#%e-#|", 1.23456789e-05, r"|1%e-5|"),
(r"|#e-#%|", 1.23456789e-05, r"|1e-5%|"),
(r"|#|e-|#|", 1.23456789e-05, r"|1|e|-5|"),
(r"|#.#|e-|#|", 1.23456789e-05, r"|1.2|e|-5|"),
(r"|####.####|e-|#|", 1.23456789e-05, r"|1234.5679|e|-8|"),
(r"|#######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"),
(r"|#,######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"),
(r"|0|e-|0|", 1.23456789e-05, r"|1|e|-5|"),
(r"|0.0|e-|0|", 1.23456789e-05, r"|1.2|e|-5|"),
(r"|0000.0000|e-|0|", 1.23456789e-05, r"|1234.5679|e|-8|"),
(r"|0000000.0000|e-|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"),
(r"|0,000000.0000|e-|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"),
(r"|?|e-|?|", 1.23456789e-05, r"|1|e|-5|"),
(r"|?.?|e-|?|", 1.23456789e-05, r"|1.2|e|-5|"),
(r"|????.????|e-|?|", 1.23456789e-05, r"|1234.5679|e|-8|"),
(r"|???????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
(r"|?,??????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
(r"|#,e+#|", 1.23456789e-05, r"|1e-5|"),
(r"|#e+#,|", 1.23456789e-05, r"|1e-5|"),
(r"|#%e+#|", 1.23456789e-05, r"|1%e-5|"),
(r"|#e+#%|", 1.23456789e-05, r"|1e-5%|"),
(r"|#|e+|#|", 1.23456789e-05, r"|1|e|-5|"),
(r"|#.#|e+|#|", 1.23456789e-05, r"|1.2|e|-5|"),
(r"|#.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"),
(r"|##.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"),
(r"|###.####|e+|#|", 0.000123456789, r"|123.4568|e|-6|"),
(r"|####.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"),
(r"|#####.####|e+|#|", 0.000123456789, r"|12.3457|e|-5|"),
(r"|#######.####|e+|#|", 0.000123456789, r"|1234.5679|e|-7|"),
(r"|#.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"),
(r"|##.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"),
(r"|###.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"),
(r"|####.####|e+|#|", 1.23456789e-05, r"|1234.5679|e|-8|"),
(r"|#####.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"),
(r"|#######.####|e+|#|", 1.23456789e-05, r"|123.4568|e|-7|"),
(r"|#.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"),
(r"|##.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"),
(r"|###.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"),
(r"|####.####|e+|#|", 1.23456789e-06, r"|123.4568|e|-8|"),
(r"|#####.####|e+|#|", 1.23456789e-06, r"|12345.6789|e|-10|"),
(r"|#######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"),
(r"|#,######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"),
(r"|0|e+|0|", 1.23456789e-05, r"|1|e|-5|"),
(r"|0.0|e+|0|", 1.23456789e-05, r"|1.2|e|-5|"),
(r"|0000.0000|e+|0|", 1.23456789e-05, r"|1234.5679|e|-8|"),
(r"|0000000.0000|e+|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"),
(r"|0,000000.0000|e+|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"),
(r"|?|e+|?|", 1.23456789e-05, r"|1|e|-5|"),
(r"|?.?|e+|?|", 1.23456789e-05, r"|1.2|e|-5|"),
(r"|????.????|e+|?|", 1.23456789e-05, r"|1234.5679|e|-8|"),
(r"|???????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
(r"|?,??????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
(r"|#.#|E-|#|", 1.23456789e-05, r"|1.2|E|-5|"),
(r"|#.#|E+|#|", 1.23456789e-05, r"|1.2|E|-5|"),
(r"|#,e-#|", 0.0, r"|0e0|"),
(r"|#e-#,|", 0.0, r"|0e0|"),
(r"|#%e-#|", 0.0, r"|0%e0|"),
(r"|#e-#%|", 0.0, r"|0e0%|"),
(r"|#|e-|#|", 0.0, r"|0|e|0|"),
(r"|#.#|e-|#|", 0.0, r"|0.|e|0|"),
(r"|####.####|e-|#|", 0.0, r"|0000.|e|0|"),
(r"|#######.####|e-|#|", 0.0, r"|0000000.|e|0|"),
(r"|#,######.####|e-|#|", 0.0, r"|0,000,000.|e|0|"),
(r"|0|e-|0|", 0.0, r"|0|e|0|"),
(r"|0.0|e-|0|", 0.0, r"|0.0|e|0|"),
(r"|0000.0000|e-|0|", 0.0, r"|0000.0000|e|0|"),
(r"|0000000.0000|e-|0|", 0.0, r"|0000000.0000|e|0|"),
(r"|0,000000.0000|e-|0|", 0.0, r"|0,000,000.0000|e|0|"),
(r"|?|e-|?|", 0.0, r"|0|e|0|"),
(r"|?.?|e-|?|", 0.0, r"|0. |e|0|"),
(r"|????.????|e-|?|", 0.0, r"|0000. |e|0|"),
(r"|???????.????|e-|?|", 0.0, r"|0000000. |e|0|"),
(r"|?,??????.????|e-|?|", 0.0, r"|0,000,000. |e|0|"),
(r"|#,e+#|", 0.0, r"|0e+0|"),
(r"|#e+#,|", 0.0, r"|0e+0|"),
(r"|#%e+#|", 0.0, r"|0%e+0|"),
(r"|#e+#%|", 0.0, r"|0e+0%|"),
(r"|#|e+|#|", 0.0, r"|0|e|+0|"),
(r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"),
(r"|####.####|e+|#|", 0.0, r"|0000.|e|+0|"),
(r"|#######.####|e+|#|", 0.0, r"|0000000.|e|+0|"),
(r"|#,######.####|e+|#|", 0.0, r"|0,000,000.|e|+0|"),
(r"|0|e+|0|", 0.0, r"|0|e|+0|"),
(r"|0.0|e+|0|", 0.0, r"|0.0|e|+0|"),
(r"|0000.0000|e+|0|", 0.0, r"|0000.0000|e|+0|"),
(r"|0000000.0000|e+|0|", 0.0, r"|0000000.0000|e|+0|"),
(r"|0,000000.0000|e+|0|", 0.0, r"|0,000,000.0000|e|+0|"),
(r"|?|e+|?|", 0.0, r"|0|e|+0|"),
(r"|?.?|e+|?|", 0.0, r"|0. |e|+0|"),
(r"|????.????|e+|?|", 0.0, r"|0000. |e|+0|"),
(r"|???????.????|e+|?|", 0.0, r"|0000000. |e|+0|"),
(r"|?,??????.????|e+|?|", 0.0, r"|0,000,000. |e|+0|"),
(r"|#.#|E-|#|", 0.0, r"|0.|E|0|"),
(r"|#.#|E+|#|", 0.0, r"|0.|E|+0|"),
(r"|#,e-#|", -123456.789, r"-|1e5|"),
(r"|#e-#,|", -123456.789, r"-|1e5|"),
(r"|#%e-#|", -123456.789, r"-|1%e5|"),
(r"|#e-#%|", -123456.789, r"-|1e5%|"),
(r"|#|e-|#|", -123456.789, r"-|1|e|5|"),
(r"|#.#|e-|#|", -123456.789, r"-|1.2|e|5|"),
(r"|####.####|e-|#|", -123456.789, r"-|12.3457|e|4|"),
(r"|#######.####|e-|#|", -123456.789, r"-|123456.789|e|0|"),
(r"|#,######.####|e-|#|", -123456.789, r"-|123,456.789|e|0|"),
(r"|0|e-|0|", -123456.789, r"-|1|e|5|"),
(r"|0.0|e-|0|", -123456.789, r"-|1.2|e|5|"),
(r"|0000.0000|e-|0|", -123456.789, r"-|0012.3457|e|4|"),
(r"|0000000.0000|e-|0|", -123456.789, r"-|0123456.7890|e|0|"),
(r"|0,000000.0000|e-|0|", -123456.789, r"-|0,123,456.7890|e|0|"),
(r"|?|e-|?|", -123456.789, r"-|1|e|5|"),
(r"|?.?|e-|?|", -123456.789, r"-|1.2|e|5|"),
(r"|????.????|e-|?|", -123456.789, r"-| 12.3457|e|4|"),
(r"|???????.????|e-|?|", -123456.789, r"-| 123456.789 |e|0|"),
(r"|?,??????.????|e-|?|", -123456.789, r"-| 123,456.789 |e|0|"),
(r"|#,e+#|", -123456.789, r"-|1e+5|"),
(r"|#e+#,|", -123456.789, r"-|1e+5|"),
(r"|#%e+#|", -123456.789, r"-|1%e+5|"),
(r"|#e+#%|", -123456.789, r"-|1e+5%|"),
(r"|#|e+|#|", -123456.789, r"-|1|e|+5|"),
(r"|#.#|e+|#|", -123456.789, r"-|1.2|e|+5|"),
(r"|####.####|e+|#|", -123456.789, r"-|12.3457|e|+4|"),
(r"|#######.####|e+|#|", -123456.789, r"-|123456.789|e|+0|"),
(r"|#,######.####|e+|#|", -123456.789, r"-|123,456.789|e|+0|"),
(r"|0|e+|0|", -123456.789, r"-|1|e|+5|"),
(r"|0.0|e+|0|", -123456.789, r"-|1.2|e|+5|"),
(r"|0000.0000|e+|0|", -123456.789, r"-|0012.3457|e|+4|"),
(r"|0000000.0000|e+|0|", -123456.789, r"-|0123456.7890|e|+0|"),
(r"|0,000000.0000|e+|0|", -123456.789, r"-|0,123,456.7890|e|+0|"),
(r"|?|e+|?|", -123456.789, r"-|1|e|+5|"),
(r"|?.?|e+|?|", -123456.789, r"-|1.2|e|+5|"),
(r"|????.????|e+|?|", -123456.789, r"-| 12.3457|e|+4|"),
(r"|???????.????|e+|?|", -123456.789, r"-| 123456.789 |e|+0|"),
(r"|?,??????.????|e+|?|", -123456.789, r"-| 123,456.789 |e|+0|"),
(r"|#.#|E-|#|", -123456.789, r"-|1.2|E|5|"),
(r"|#.#|E+|#|", -123456.789, r"-|1.2|E|+5|"),
(r"|#,e-#|", -1.23456789e-05, r"-|1e-5|"),
(r"|#e-#,|", -1.23456789e-05, r"-|1e-5|"),
(r"|#%e-#|", -1.23456789e-05, r"-|1%e-5|"),
(r"|#e-#%|", -1.23456789e-05, r"-|1e-5%|"),
(r"|#|e-|#|", -1.23456789e-05, r"-|1|e|-5|"),
(r"|#.#|e-|#|", -1.23456789e-05, r"-|1.2|e|-5|"),
(r"|####.####|e-|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
(r"|#######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
(r"|#,######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
(r"|0|e-|0|", -1.23456789e-05, r"-|1|e|-5|"),
(r"|0.0|e-|0|", -1.23456789e-05, r"-|1.2|e|-5|"),
(r"|0000.0000|e-|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
(r"|0000000.0000|e-|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"),
(r"|0,000000.0000|e-|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"),
(r"|?|e-|?|", -1.23456789e-05, r"-|1|e|-5|"),
(r"|?.?|e-|?|", -1.23456789e-05, r"-|1.2|e|-5|"),
(r"|????.????|e-|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
(r"|???????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
(r"|?,??????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
(r"|#,e+#|", -1.23456789e-05, r"-|1e-5|"),
(r"|#e+#,|", -1.23456789e-05, r"-|1e-5|"),
(r"|#%e+#|", -1.23456789e-05, r"-|1%e-5|"),
(r"|#e+#%|", -1.23456789e-05, r"-|1e-5%|"),
(r"|#|e+|#|", -1.23456789e-05, r"-|1|e|-5|"),
(r"|#.#|e+|#|", -1.23456789e-05, r"-|1.2|e|-5|"),
(r"|####.####|e+|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
(r"|#######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
(r"|#,######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
(r"|0|e+|0|", -1.23456789e-05, r"-|1|e|-5|"),
(r"|0.0|e+|0|", -1.23456789e-05, r"-|1.2|e|-5|"),
(r"|0000.0000|e+|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
(r"|0000000.0000|e+|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"),
(r"|0,000000.0000|e+|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"),
(r"|?|e+|?|", -1.23456789e-05, r"-|1|e|-5|"),
(r"|?.?|e+|?|", -1.23456789e-05, r"-|1.2|e|-5|"),
(r"|????.????|e+|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
(r"|???????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
(r"|?,??????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
(r"|#.#|E-|#|", -1.23456789e-05, r"-|1.2|E|-5|"),
(r"|#.#|E+|#|", -1.23456789e-05, r"-|1.2|E|-5|"),
(r"|#.#|e+|#|", 1.2345678e+142, r"|1.2|e|+142|"),
(r"|#.#|e+|#####|", 1.2345678e+142, r"|1.2|e|+142|"),
(r"|#.#|e+|0|", 1.2345678e+142, r"|1.2|e|+142|"),
(r"|#.#|e+|000|", 1.2345678e+142, r"|1.2|e|+142|"),
(r"|#.#|e+|0000|", 1.2345678e+142, r"|1.2|e|+0142|"),
(r"|#.#|e+|00000|", 1.2345678e+142, r"|1.2|e|+00142|"),
(r"|#.#|e+|?|", 1.2345678e+142, r"|1.2|e|+142|"),
(r"|#.#|e+|???|", 1.2345678e+142, r"|1.2|e|+142|"),
(r"|#.#|e+|????|", 1.2345678e+142, r"|1.2|e|+ 142|"),
(r"|#.#|e+|?????|", 1.2345678e+142, r"|1.2|e|+ 142|"),
(r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"),
(r"|#.#|e+|#####|", 0.0, r"|0.|e|+0|"),
(r"|#.#|e+|0|", 0.0, r"|0.|e|+0|"),
(r"|#.#|e+|000|", 0.0, r"|0.|e|+000|"),
(r"|#.#|e+|0000|", 0.0, r"|0.|e|+0000|"),
(r"|#.#|e+|00000|", 0.0, r"|0.|e|+00000|"),
(r"|#.#|e+|?|", 0.0, r"|0.|e|+0|"),
(r"|#.#|e+|???|", 0.0, r"|0.|e|+ 0|"),
(r"|#.#|e+|????|", 0.0, r"|0.|e|+ 0|"),
(r"|#.#|e+|?????|", 0.0, r"|0.|e|+ 0|"),
(r"|#.#|e+|#|", 1.2345678e-142, r"|1.2|e|-142|"),
(r"|#.#|e+|#####|", 1.2345678e-142, r"|1.2|e|-142|"),
(r"|#.#|e+|0|", 1.2345678e-142, r"|1.2|e|-142|"),
(r"|#.#|e+|000|", 1.2345678e-142, r"|1.2|e|-142|"),
(r"|#.#|e+|0000|", 1.2345678e-142, r"|1.2|e|-0142|"),
(r"|#.#|e+|00000|", 1.2345678e-142, r"|1.2|e|-00142|"),
(r"|#.#|e+|000,00|", 1.2345678e-142, r"|1.2|e|-00142|"),
(r"|#.#|e+|?|", 1.2345678e-142, r"|1.2|e|-142|"),
(r"|#.#|e+|???|", 1.2345678e-142, r"|1.2|e|-142|"),
(r"|#.#|e+|????|", 1.2345678e-142, r"|1.2|e|- 142|"),
(r"|#.#|e+|?????|", 1.2345678e-142, r"|1.2|e|- 142|"),
(r"#,##0.00e+00", 3.14159, r"3.14e+00"),
];
#[test]
fn poi_number_format_approx_tests() {
let wrong: Vec<String> = CASES
.iter()
.filter_map(|&(code, v, want)| {
let got = text_function(&Value::Number(v), code, DateSystem::D1900);
(got.as_deref() != Ok(want)).then(|| format!("TEXT({v}, {code:?}) = {got:?}, Excel {want:?}"))
})
.collect();
assert!(wrong.is_empty(), "{} of {} cases differ:\n{}", wrong.len(), CASES.len(), wrong.join("\n"));
}
+71 -17
View File
@@ -145,7 +145,8 @@ impl Font {
/// Width of `text` in points when set in `font` at `size` points.
pub fn text_width(font: Font, size: f32, text: &str) -> f32 {
let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum();
// u64: a u32 sum overflows for ~8 million characters.
let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum();
units as f32 * size / 1000.0
}
@@ -221,6 +222,32 @@ fn n(v: f32) -> String {
}
}
/// True when every value is finite (NaN and infinities are rejected).
fn finite(v: &[f32]) -> bool {
v.iter().all(|x| x.is_finite())
}
/// A positive, finite extent (a width, height or radius).
fn extent(v: f32) -> bool {
v > 0.0 && v.is_finite()
}
/// A stroke width: negative, NaN and infinite become 0 (the thinnest line).
fn stroke_width(w: f32) -> f32 {
if w.is_finite() { w.max(0.0) } else { 0.0 }
}
/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification
/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is
/// replaced by an empty array, i.e. a solid line.
fn dash_values(dash: &[f32]) -> String {
let d: Vec<f32> = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect();
if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) {
return String::new();
}
d.iter().map(|v| n(*v)).collect::<Vec<_>>().join(" ")
}
fn rgb_op(c: Rgb, stroke: bool) -> String {
let f = |v: u8| n(v as f32 / 255.0);
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
@@ -288,7 +315,7 @@ impl Page {
}
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
if w <= 0.0 || h <= 0.0 {
if !(extent(w) && extent(h) && finite(&[x, y])) {
return;
}
let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
@@ -296,7 +323,10 @@ impl Page {
}
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h));
if !finite(&[x, y, w, h]) {
return;
}
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h));
self.op(&s);
}
@@ -306,19 +336,28 @@ impl Page {
/// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) {
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
let s =
format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1)));
if !finite(&[x0, y0, x1, y1]) {
return;
}
let s = format!(
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
rgb_op(color, true),
n(stroke_width(width)),
dash_values(dash),
n(x0),
n(self.y(y0)),
n(x1),
n(self.y(y1))
);
self.op(&s);
}
/// An open polyline.
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
if pts.len() < 2 {
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) {
return;
}
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" "));
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash));
self.path(&mut s, pts, false);
s.push_str(" S");
self.op(&s);
@@ -335,7 +374,7 @@ impl Page {
/// A closed polygon, filled and/or stroked.
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) {
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) {
return;
}
let mut s = String::new();
@@ -344,7 +383,7 @@ impl Page {
s.push('\n');
}
if let Some((c, w)) = stroke {
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w));
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w)));
}
self.path(&mut s, pts, true);
s.push_str(match (fill.is_some(), stroke.is_some()) {
@@ -357,7 +396,7 @@ impl Page {
/// A circle approximated by four cubic Béziers.
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
if r <= 0.0 || (fill.is_none() && stroke.is_none()) {
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) {
return;
}
const K: f32 = 0.552_284_8;
@@ -369,7 +408,7 @@ impl Page {
s.push('\n');
}
if let Some((c, w)) = stroke {
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w));
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w)));
}
let _ = write!(
s,
@@ -417,7 +456,7 @@ impl Page {
/// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate
/// system) about its baseline origin.
pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
if text.is_empty() || !(size.is_finite() && size > 0.0) {
if text.is_empty() || !extent(size) || !finite(&[x, y]) {
return;
}
let fi = font.index();
@@ -446,7 +485,7 @@ impl Page {
/// `(x, y, w, h)`. Malformed or oversized buffers are ignored.
pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) {
let px = pw as u64 * ph as u64;
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 {
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {
return;
}
let px = px as usize;
@@ -516,8 +555,23 @@ fn run_length(data: &[u8]) -> Vec<u8> {
out
}
fn pdf_text_string(s: &str) -> String {
pdf_string(s)
/// Longest Info string written, in characters; longer values are cut on a character boundary.
const MAX_INFO_CHARS: usize = 1024;
/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII,
/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2).
fn pdf_text_string(text: &str) -> String {
let text: String = text.chars().take(MAX_INFO_CHARS).collect();
if text.chars().all(|c| matches!(c, ' '..='~')) {
return pdf_string(&text);
}
let mut s = String::with_capacity(text.len() * 4 + 6);
s.push_str("<FEFF");
for unit in text.encode_utf16() {
let _ = write!(s, "{unit:04X}");
}
s.push('>');
s
}
impl PdfDoc {
+120
View File
@@ -157,3 +157,123 @@ fn hostile_inputs_never_panic() {
let pdf = d.finish();
check_xref(&pdf);
}
fn page_content(d: &PdfDoc) -> &str {
&d.pages[0].content
}
#[test]
fn text_width_does_not_overflow() {
// 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds.
let long = "a".repeat(8_000_000);
let w = text_width(Font::Helvetica, 10.0, &long);
assert!(w.is_finite() && w > 0.0);
assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000)));
}
#[test]
fn all_zero_dash_is_drawn_solid() {
let mut d = PdfDoc::new();
let p = d.add_page(100.0, 100.0);
p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]);
p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]);
p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]);
let c = page_content(&d);
assert_eq!(c.matches("[] 0 d").count(), 2);
assert!(!c.contains("[0 0]"));
assert!(c.contains("[3 2] 0 d"));
}
#[test]
fn negative_and_nan_stroke_widths_become_zero() {
let mut d = PdfDoc::new();
let p = d.add_page(100.0, 100.0);
p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]);
p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]);
p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0)));
p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY)));
let c = page_content(&d);
assert_eq!(c.matches("\n0 w ").count(), 4);
assert!(!c.contains("-1 w") && !c.contains("-3 w"));
}
#[test]
fn non_finite_image_size_stores_nothing() {
let mut d = PdfDoc::new();
let p = d.add_page(200.0, 200.0);
let px = [255u8; 16];
for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] {
p.image_rgba(0.0, 0.0, w, h, &px, 2, 2);
}
p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2);
assert!(p.images.is_empty());
assert!(p.content.is_empty());
}
#[test]
fn every_draw_method_survives_hostile_numbers() {
let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30];
let px = [200u8; 16];
let mut d = PdfDoc::new();
let p = d.add_page(300.0, 300.0);
for &a in &bad {
for &b in &bad {
p.clip_rect(a, b, a, b);
p.fill_rect(a, b, b, a, [1, 2, 3]);
p.rect_stroke(a, b, a, b, a, [1, 2, 3]);
p.stroke_line(a, b, b, a, a, [1, 2, 3]);
p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]);
p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]);
p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a)));
p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a)));
p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x");
p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b);
p.image_rgba(a, b, b, a, &px, 2, 2);
}
}
p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]);
let c = page_content(&d);
assert!(!c.contains("NaN") && !c.contains("inf"));
let pdf = d.finish();
check_xref(&pdf);
}
/// Decodes the `<FEFF…>` hex string following `key` in the file bytes.
fn utf16_entry(pdf: &[u8], key: &str) -> String {
let at = find(pdf, key.as_bytes()).unwrap() + key.len();
let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or("");
let hex = rest.trim_start().strip_prefix("<FEFF").unwrap();
let hex = &hex[..hex.find('>').unwrap()];
let units: Vec<u16> = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect();
String::from_utf16(&units).unwrap()
}
#[test]
fn info_strings_with_non_ascii_are_utf16be() {
let mut doc = PdfDoc::new();
doc.add_page(200.0, 200.0);
doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into();
doc.info.author = "Müller & Søn".into();
let pdf = doc.finish();
assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀");
assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn");
}
#[test]
fn ascii_info_strings_stay_literal_and_escaped() {
let mut doc = PdfDoc::new();
doc.add_page(200.0, 200.0);
doc.info.title = r"Q1 (draft) a\b".into();
let pdf = doc.finish();
assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some());
assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some());
}
#[test]
fn info_strings_are_capped() {
let mut doc = PdfDoc::new();
doc.add_page(200.0, 200.0);
doc.info.title = "日".repeat(100_000);
let pdf = doc.finish();
assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS);
}
+228 -14
View File
@@ -4,8 +4,13 @@
//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export,
//! Options); Save and Close act at once. Every action runs an existing command through
//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is
//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched
//! until the user picks one.
//! UI-only and never saved. Recent workbooks are listed by path, with when each was last changed:
//! that is asked of the file system on a thread of its own, never while drawing, and only the
//! files' metadata is read. No workbook is opened until the user picks one.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value as Json, json};
@@ -109,6 +114,8 @@ pub struct Backstage {
preview: Option<((u64, u64, bool, bool), Json)>,
/// Save As: the chosen file type (an extension from [`SAVE_TYPES`]).
pub save_format: &'static str,
/// Home and Open: when the recent workbooks were last changed.
recent: RecentFiles,
}
/// Save As file types: (extension, label). The first is the workbook itself; the others are
@@ -118,7 +125,16 @@ pub const SAVE_TYPES: [(&str, &str); 4] =
impl Backstage {
pub fn new(page: Page) -> Backstage {
Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None, save_format: "xlsx" }
Backstage {
page,
props: Default::default(),
print_all: false,
fit_to_page: false,
preview_page: 0,
preview: None,
save_format: "xlsx",
recent: RecentFiles::default(),
}
}
}
@@ -187,9 +203,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text));
ui.add_space(18.0);
match bs.page {
Page::Home => home(app, ui, &mut out),
Page::Home => home(app, ui, &mut bs, &mut out),
Page::New => new_page(app, ui, &mut out),
Page::Open => open_page(app, ui, &mut out),
Page::Open => open_page(app, ui, &mut bs, &mut out),
Page::Info => info(app, ui, &mut bs),
Page::SaveAs => save_as(app, ui, &mut bs, &mut out),
Page::Print => print(app, ui, &mut bs, &mut out),
@@ -325,7 +341,7 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
});
}
/// File name and folder of a recent path, split as text (nothing on disk is read).
/// File name and folder of a recent path, split as text.
fn split_path(path: &str) -> (String, String) {
let p = std::path::Path::new(path);
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
@@ -333,8 +349,12 @@ fn split_path(path: &str) -> (String, String) {
(name, folder)
}
/// Recent workbooks, newest first. Opening one is the user's explicit choice.
fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) {
/// Width of the column that tells when a recent workbook was last changed.
const MODIFIED_W: f32 = 150.0;
/// Recent workbooks, newest first, each with when it was last changed. Opening one is the user's
/// explicit choice.
fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome, limit: usize) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
if app.ui.recent.is_empty() {
@@ -342,6 +362,21 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
return;
}
let width = ui.available_width().min(CONTENT_W);
// What the file system has said so far; the rest arrives in later frames (see `RecentFiles`).
let known = bs.recent.known(ui.ctx(), &app.ui.recent);
// The web build has no files to ask about, and a narrow window leaves the names their room.
let modified_w = if cfg!(target_arch = "wasm32") || width < 420.0 { 0.0 } else { MODIFIED_W };
if modified_w > 0.0 {
let (head, _) = ui.allocate_exact_size(vec2(width, 20.0), Sense::hover());
ui.painter().text(
pos2(head.right() - modified_w, head.center().y),
Align2::LEFT_CENTER,
lang.tr("Modified"),
theme::ui_font(11.5),
t.text_dim,
);
}
let now = crate::now_ms() / 1000.0;
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
let (name, folder) = split_path(&path);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
@@ -351,8 +386,19 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
p.rect_filled(rect, 4.0, t.hover);
}
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text);
p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
// A long name or folder stops short of the column instead of running under it.
let room = if modified_w > 0.0 { rect.right() - modified_w - 14.0 } else { rect.right() };
let text = p.with_clip_rect(Rect::from_min_max(rect.min, pos2(room, rect.bottom())).intersect(p.clip_rect()));
text.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
text.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
if modified_w > 0.0 {
let (words, color) = match known.get(&path) {
Some(Modified::At(seconds)) => (age(lang, now - seconds), t.text_dim),
Some(Modified::Unknown) => (lang.tr("Not available").to_string(), t.text_disabled),
None => (String::new(), t.text_dim),
};
p.text(pos2(rect.right() - modified_w, rect.center().y), Align2::LEFT_CENTER, words, theme::ui_font(12.0), color);
}
let resp = resp.on_hover_text(&path);
if resp.clicked() {
app.open_path(&path);
@@ -363,6 +409,98 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
}
}
/// What the file system said about a recent workbook.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Modified {
/// When it was last changed, in seconds since 1970.
At(f64),
/// It can't be reached: moved, deleted, or on a drive that is away.
Unknown,
}
/// One thread at a time asks about recent workbooks, so a drive that never answers holds up one
/// thread, not one for every time the view is opened.
static ASKING: AtomicBool = AtomicBool::new(false);
/// When the listed workbooks were last changed, as far as the file system has answered.
///
/// Asking can take long (a network drive that is asleep, a share that is away), so it is never
/// done while drawing: a thread asks, and the list shows each answer as it comes back. Only the
/// files' metadata is read. A view opened anew asks anew.
#[derive(Clone, Debug, Default)]
pub struct RecentFiles {
/// The list the answers are for.
asked: Vec<String>,
found: Arc<Mutex<HashMap<String, Modified>>>,
}
impl RecentFiles {
/// What is known this frame about the files in `recent`; starts asking when the list is new.
fn known(&mut self, ctx: &egui::Context, recent: &[String]) -> HashMap<String, Modified> {
if self.asked != recent && !ASKING.swap(true, Ordering::SeqCst) {
self.asked = recent.to_vec();
if !ask(ctx.clone(), recent.to_vec(), self.found.clone()) {
ASKING.store(false, Ordering::SeqCst);
}
}
self.found.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
}
/// Starts the thread that asks. Returns whether it started.
#[cfg(not(target_arch = "wasm32"))]
fn ask(ctx: egui::Context, paths: Vec<String>, found: Arc<Mutex<HashMap<String, Modified>>>) -> bool {
/// Lets the next list ask, however the thread ends.
struct Done;
impl Drop for Done {
fn drop(&mut self) {
ASKING.store(false, Ordering::SeqCst);
}
}
let asking = std::thread::Builder::new().name("gridcraft-recent-files".into()).spawn(move || {
let _done = Done;
for path in paths {
let changed = std::fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok());
let answer = changed.map_or(Modified::Unknown, |since| Modified::At(since.as_secs_f64()));
found.lock().unwrap_or_else(PoisonError::into_inner).insert(path, answer);
ctx.request_repaint();
}
});
asking.is_ok()
}
/// The web build has no file system to ask.
#[cfg(target_arch = "wasm32")]
fn ask(_ctx: egui::Context, _paths: Vec<String>, _found: Arc<Mutex<HashMap<String, Modified>>>) -> bool {
false
}
/// How long ago, in words. The clock's time zone isn't known here, so the list tells ages rather
/// than dates, which could be a day out. From two days on the counts are never one, so each
/// language needs one form of "days", "weeks", "months" and "years".
fn age(lang: Language, seconds: f64) -> String {
const DAY: f64 = 86_400.0;
let s = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 };
let counted = |pattern: &str, n: f64| lang.tr(pattern).replace("{n}", &(n.floor() as u64).to_string());
if s < 60.0 {
lang.tr("Just now").to_string()
} else if s < 3600.0 {
counted("{n} min ago", s / 60.0)
} else if s < DAY {
counted("{n} h ago", s / 3600.0)
} else if s < 2.0 * DAY {
lang.tr("Yesterday").to_string()
} else if s < 14.0 * DAY {
counted("{n} days ago", s / DAY)
} else if s < 61.0 * DAY {
counted("{n} weeks ago", s / (7.0 * DAY))
} else if s < 731.0 * DAY {
counted("{n} months ago", s / (30.44 * DAY))
} else {
counted("{n} years ago", s / (365.25 * DAY))
}
}
/// A large clickable row (icon, title, one-line description).
fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
@@ -395,13 +533,13 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
// ---------------------------------------------------------------- pages
fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
fn home(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
let lang = app.ui.language;
section(ui, lang, "New");
new_tiles(app, ui, out);
ui.add_space(22.0);
section(ui, lang, "Recent");
recent_list(app, ui, out, 8);
recent_list(app, ui, bs, out, 8);
if app.ui.recent.len() > 8 {
ui.add_space(6.0);
if ui.link(lang.tr("More workbooks")).clicked() {
@@ -414,7 +552,7 @@ fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
new_tiles(app, ui, out);
}
fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
fn open_page(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
let lang = app.ui.language;
if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() {
let before = app.session.active().map(|d| d.uid);
@@ -426,7 +564,7 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
}
ui.add_space(22.0);
section(ui, lang, "Recent");
recent_list(app, ui, out, usize::MAX);
recent_list(app, ui, bs, out, usize::MAX);
}
/// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an
@@ -824,6 +962,12 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Editing");
let mut autocomplete = app.ui.autocomplete;
if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() {
app.run_or_alert("options.autoComplete", json!({"on": autocomplete}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Save");
let mut autosave = app.grid.autosave();
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
@@ -856,6 +1000,76 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
mod tests {
use super::*;
#[test]
fn ages_are_told_in_words() {
const DAY: f64 = 86_400.0;
for (seconds, words) in [
(0.0, "Just now"),
(59.9, "Just now"),
(60.0, "1 min ago"),
(150.0, "2 min ago"),
(3600.0, "1 h ago"),
(23.9 * 3600.0, "23 h ago"),
(DAY, "Yesterday"),
(1.99 * DAY, "Yesterday"),
// From here on a count is never one.
(2.0 * DAY, "2 days ago"),
(13.9 * DAY, "13 days ago"),
(14.0 * DAY, "2 weeks ago"),
(60.9 * DAY, "8 weeks ago"),
(61.0 * DAY, "2 months ago"),
(730.9 * DAY, "24 months ago"),
(731.0 * DAY, "2 years ago"),
(4000.0 * DAY, "10 years ago"),
// A clock that is behind the file's, or a number that isn't one.
(-5.0, "Just now"),
(f64::NAN, "Just now"),
(f64::NEG_INFINITY, "Just now"),
(f64::INFINITY, "Just now"),
] {
assert_eq!(age(Language::En, seconds), words, "{seconds}");
}
assert!(age(Language::En, f64::MAX).ends_with(" years ago"));
// Other languages put the count where their own words want it.
assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前");
assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen");
assert_eq!(age(Language::Fr, 90.0), "il y a 1 min");
for lang in Language::ALL {
for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] {
let words = age(lang, seconds);
assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}");
}
}
}
#[test]
fn the_recent_list_learns_when_its_files_were_last_changed() {
let dir = std::env::temp_dir().join(format!("gridcraft-recent-files-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let there = dir.join("There.xlsx").to_string_lossy().into_owned();
let gone = dir.join("Gone.xlsx").to_string_lossy().into_owned();
std::fs::write(&there, b"a file, if not a workbook").unwrap();
let ctx = egui::Context::default();
let recent = vec![there.clone(), gone.clone()];
let mut files = RecentFiles::default();
// Nothing is read while drawing: the answers arrive from a thread, in later frames.
let mut known = HashMap::new();
for _ in 0..2_000 {
known = files.known(&ctx, &recent);
if known.len() == recent.len() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(matches!(known.get(&there), Some(Modified::At(seconds)) if *seconds > 1.0e9), "{known:?}");
assert_eq!(known.get(&gone), Some(&Modified::Unknown));
// The same list is not asked about twice; an empty one is never asked about.
assert_eq!(files.known(&ctx, &recent), known);
assert!(RecentFiles::default().known(&ctx, &[]).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn page_ids_round_trip_and_unknown_ids_are_refused() {
for page in Page::ALL {
+2 -1
View File
@@ -181,6 +181,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
@@ -242,7 +243,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
} else if field == "search" {
d.search = s("value").unwrap_or("").to_string();
} else {
d.values.insert(field.to_string(), p.get("value").cloned().unwrap_or(Json::Null));
d.set_value(field, p.get("value").cloned().unwrap_or(Json::Null));
}
ok(json!(d.values))
}
+380 -101
View File
@@ -47,6 +47,17 @@ impl Dialog {
&self.name
}
/// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom` stores.
pub(crate) fn set_value(&mut self, key: &str, value: Json) {
if self.name == "zoom"
&& key == "preset"
&& let Some(p) = value.as_str().and_then(|s| s.parse::<f64>().ok())
{
self.values.insert("percent".into(), json!(p));
}
self.values.insert(key.into(), value);
}
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
@@ -338,6 +349,20 @@ impl Dialog {
json!({"text": existing}),
)
}
// Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which
// refuses anything but http, https and mailto.
"openLink" => {
let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string();
Dialog::form(
"openLink",
"Open Hyperlink",
"link.open",
vec![Note(format!(
"This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"
))],
json!({"url": url}),
)
}
"insertLink" => Dialog::form(
"insertLink",
"Insert Hyperlink",
@@ -370,20 +395,26 @@ impl Dialog {
],
json!({"header": "", "footer": "&CPage &P of &N"}),
),
"pageSetup" => Dialog::form(
"pageSetup",
"Page Setup",
"pageLayout.margins",
vec![
Number { key: "left", label: "Left margin (in):" },
Number { key: "right", label: "Right margin (in):" },
Number { key: "top", label: "Top margin (in):" },
Number { key: "bottom", label: "Bottom margin (in):" },
Number { key: "header", label: "Header (in):" },
Number { key: "footer", label: "Footer (in):" },
],
json!({"left": 0.7, "right": 0.7, "top": 0.75, "bottom": 0.75, "header": 0.3, "footer": 0.3}),
),
"pageSetup" => {
// Opens on the active sheet's saved margins, so OK without edits rewrites them unchanged.
let m = app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or([0.7, 0.7, 0.75, 0.75, 0.3, 0.3]);
// Round off f32 noise so the form shows 0.1, not 0.10000000149.
let [left, right, top, bottom, header, footer] = m.map(|v| (f64::from(v) * 1e4).round() / 1e4);
Dialog::form(
"pageSetup",
"Page Setup",
"pageLayout.margins",
vec![
Number { key: "left", label: "Left margin (in):" },
Number { key: "right", label: "Right margin (in):" },
Number { key: "top", label: "Top margin (in):" },
Number { key: "bottom", label: "Bottom margin (in):" },
Number { key: "header", label: "Header (in):" },
Number { key: "footer", label: "Footer (in):" },
],
json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer}),
)
}
"textToColumns" => Dialog::form(
"textToColumns",
"Convert Text to Columns",
@@ -415,6 +446,7 @@ impl Dialog {
Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" },
Text { key: "values", label: "Values range (blank: second column of the selection):" },
Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" },
Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" },
Check { key: "confidenceInterval", label: "Confidence interval" },
Text { key: "confidence", label: "Confidence level (%):" },
Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" },
@@ -534,48 +566,8 @@ impl Dialog {
"dataValidation",
"Data Validation",
"data.validation",
vec![
Choice {
key: "type",
label: "Allow",
options: vec![
("any", "Any value"),
("whole", "Whole number"),
("decimal", "Decimal"),
("list", "List"),
("date", "Date"),
("time", "Time"),
("textLength", "Text length"),
("custom", "Custom"),
],
},
Choice {
key: "operator",
label: "Data",
options: vec![
("between", "between"),
("notBetween", "not between"),
("equal", "equal to"),
("notEqual", "not equal to"),
("greater", "greater than"),
("less", "less than"),
("greaterOrEqual", "greater than or equal to"),
("lessOrEqual", "less than or equal to"),
],
},
Text { key: "formula1", label: "Minimum / Source / Formula:" },
Text { key: "formula2", label: "Maximum:" },
Check { key: "ignoreBlank", label: "Ignore blank" },
Check { key: "dropdown", label: "In-cell dropdown" },
Text { key: "inputMessage", label: "Input message:" },
Choice {
key: "errorStyle",
label: "Error style",
options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")],
},
Text { key: "errorMessage", label: "Error message:" },
],
json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}),
data_validation_fields("any", "between"),
validation_defaults(app),
),
"cfQuick" => {
let ty = p("type").as_str().unwrap_or("cellIs").to_string();
@@ -807,15 +799,167 @@ impl Dialog {
}
}
/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without
/// edits keeps it; a plain list rule when the cell has none.
fn validation_defaults(app: &SheetApp) -> Json {
let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"});
let Some(doc) = app.session.active() else { return v };
let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else {
return v;
};
// The enums serialize as their variant names; the command takes them in camelCase.
let camel = |x: Json| {
let name = x.as_str().unwrap_or_default();
let mut chars = name.chars();
chars.next().map(|c| c.to_lowercase().chain(chars).collect::<String>()).unwrap_or_default()
};
v["type"] = json!(camel(json!(dv.kind)));
v["operator"] = json!(camel(json!(dv.op)));
v["errorStyle"] = json!(camel(json!(dv.error_style)));
v["formula1"] = json!(dv.f1);
if let Some(f2) = &dv.f2 {
v["formula2"] = json!(f2);
}
v["ignoreBlank"] = json!(dv.allow_blank);
v["dropdown"] = json!(dv.in_cell_dropdown);
v["inputTitle"] = json!(dv.input_title);
v["inputMessage"] = json!(dv.input_message);
v["errorTitle"] = json!(dv.error_title);
v["errorMessage"] = json!(dv.error_message);
v
}
fn abs(r: &gridcraft_engine::core::RangeRef) -> String {
let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1);
if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) }
}
/// The criterion controls in Excel's Settings tab depend on both Allow and Data.
/// `formula1` is the sole source for a list or custom rule; `formula2` is a
/// second bound only for Between and Not between.
fn data_validation_fields(kind: &str, operator: &str) -> Vec<Field> {
use Field::*;
let mut fields = vec![Choice {
key: "type",
label: "Allow",
options: vec![
("any", "Any value"),
("whole", "Whole number"),
("decimal", "Decimal"),
("list", "List"),
("date", "Date"),
("time", "Time"),
("textLength", "Text length"),
("custom", "Custom"),
],
}];
let two_bounds = matches!(operator, "between" | "notBetween");
match kind {
"list" => {
fields.push(Text { key: "formula1", label: "Source:" });
fields.push(Check { key: "ignoreBlank", label: "Ignore blank" });
fields.push(Check { key: "dropdown", label: "In-cell dropdown" });
}
"custom" => {
fields.push(Text { key: "formula1", label: "Formula:" });
fields.push(Check { key: "ignoreBlank", label: "Ignore blank" });
}
"whole" | "decimal" | "date" | "time" | "textLength" => {
fields.push(Choice {
key: "operator",
label: "Data",
options: vec![
("between", "between"),
("notBetween", "not between"),
("equal", "equal to"),
("notEqual", "not equal to"),
("greater", "greater than"),
("less", "less than"),
("greaterOrEqual", "greater than or equal to"),
("lessOrEqual", "less than or equal to"),
],
});
let lower = matches!(operator, "greater" | "greaterOrEqual");
let (first, second) = match kind {
"date" => (
if two_bounds || lower {
"Start date:"
} else if matches!(operator, "less" | "lessOrEqual") {
"End date:"
} else {
"Date:"
},
"End date:",
),
"time" => (
if two_bounds || lower {
"Start time:"
} else if matches!(operator, "less" | "lessOrEqual") {
"End time:"
} else {
"Time:"
},
"End time:",
),
_ => (
if two_bounds || lower {
"Minimum:"
} else if matches!(operator, "less" | "lessOrEqual") {
"Maximum:"
} else {
"Value:"
},
"Maximum:",
),
};
fields.push(Text { key: "formula1", label: first });
if two_bounds {
fields.push(Text { key: "formula2", label: second });
}
fields.push(Check { key: "ignoreBlank", label: "Ignore blank" });
}
_ => {}
}
fields.push(Text { key: "inputMessage", label: "Input message:" });
fields.push(Choice {
key: "errorStyle",
label: "Error style",
options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")],
});
fields.push(Text { key: "errorMessage", label: "Error message:" });
fields
}
fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
d.fields = data_validation_fields(kind, operator);
form_fields(ui, d);
ok_cancel(ui, confirm, open);
}
/// Builds the command params from form values for dialogs that need translation.
fn build_params(d: &Dialog) -> Json {
let mut v = Json::Object(d.values.clone());
match d.name.as_str() {
"dataValidation" => {
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
if let Some(m) = v.as_object_mut() {
if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") {
m.remove("formula2");
}
if matches!(kind, "any" | "list" | "custom") {
m.remove("operator");
}
if kind == "any" {
m.remove("formula1");
}
if kind != "list" {
m.remove("dropdown");
}
}
}
"textToColumns" => {
let mut delims = Vec::new();
for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] {
@@ -891,7 +1035,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"solver" => 460.0,
_ => 380.0,
};
let window = egui::Window::new(d.title.clone())
let window = egui::Window::new(app.ui.language.tr(&d.title))
.id(egui::Id::new("dialog").with(&d.name))
.collapsible(false)
.resizable(false)
@@ -903,6 +1047,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
ui.set_min_width(width - 20.0);
match d.name.as_str() {
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
"dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open),
"insertFunction" => insert_function(app, ui, &mut d, &mut confirm),
"find" => find(app, ui, &mut d),
"goTo" => go_to(app, ui, &mut d, &mut confirm),
@@ -1223,11 +1368,15 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
let lang = crate::i18n::current(ui.ctx());
let ok = ui.add(
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
);
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
*confirm = true;
}
if ui.button(" Cancel ").clicked() {
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
*open = false;
}
});
@@ -1286,9 +1435,20 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
ui.vertical(|ui| {
ui.label(egui::RichText::new(label).strong());
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
for (val, text) in options {
if ui.radio(cur == val, text).clicked() {
d.values.insert(key.into(), json!(val));
if d.name == "dataValidation" {
let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| *text).unwrap_or("");
egui::ComboBox::from_id_salt(("dataValidation", key)).selected_text(selected).width(300.0).show_ui(ui, |ui| {
for (val, text) in options {
if ui.selectable_label(cur == val, text).clicked() {
d.set_value(key, json!(val));
}
}
});
} else {
for (val, text) in options {
if ui.radio(cur == val, text).clicked() {
d.set_value(key, json!(val));
}
}
}
});
@@ -1362,9 +1522,20 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
}
fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
// `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn
// labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a
// different word in some languages.
let lang = app.ui.language;
ui.horizontal(|ui| {
for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] {
if ui.selectable_label(d.tab == tab, tab).clicked() {
for (tab, key) in [
("Number", "Number"),
("Alignment", "Alignment"),
("Font", "Font"),
("Border", "Border"),
("Fill", "Format Cells|Fill"),
("Protection", "Protection"),
] {
if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() {
d.tab = tab.to_string();
}
}
@@ -1376,20 +1547,20 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
"Number" => {
let mut code = st.num_fmt.as_str().to_string();
ui.columns(2, |cols| {
cols[0].label(egui::RichText::new("Category:").strong());
cols[0].label(egui::RichText::new(lang.tr("Category:")).strong());
for name in
["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]
{
let c = gridcraft_engine::cmd::format::format_code_for(name).to_string();
if cols[0].selectable_label(code == c, name).clicked() {
if cols[0].selectable_label(code == c, lang.tr(name)).clicked() {
code = c;
}
}
cols[1].label(egui::RichText::new("Sample").strong());
cols[1].label(egui::RichText::new(lang.tr("Sample")).strong());
let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default();
cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0)));
cols[1].add_space(8.0);
cols[1].label("Type (custom format code):");
cols[1].label(lang.tr("Type (custom format code):"));
cols[1].text_edit_singleline(&mut code);
for c in [
"0",
@@ -1418,7 +1589,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
}
"Alignment" => {
use gridcraft_engine::model::{HAlign, VAlign};
ui.label(egui::RichText::new("Horizontal").strong());
ui.label(egui::RichText::new(lang.tr("Horizontal")).strong());
ui.horizontal_wrapped(|ui| {
for (h, n) in [
(HAlign::General, "General"),
@@ -1430,10 +1601,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
(HAlign::CenterAcross, "Center Across Selection"),
(HAlign::Distributed, "Distributed"),
] {
ui.radio_value(&mut st.align.h, h, n);
ui.radio_value(&mut st.align.h, h, lang.tr(n));
}
});
ui.label(egui::RichText::new("Vertical").strong());
ui.label(egui::RichText::new(lang.tr("Vertical")).strong());
ui.horizontal_wrapped(|ui| {
for (v, n) in [
(VAlign::Top, "Top"),
@@ -1442,29 +1613,29 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
(VAlign::Justify, "Justify"),
(VAlign::Distributed, "Distributed"),
] {
ui.radio_value(&mut st.align.v, v, n);
ui.radio_value(&mut st.align.v, v, lang.tr(n));
}
});
let mut indent = st.align.indent as f32;
ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text("Indent"));
ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent")));
st.align.indent = indent as u8;
let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 };
ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text("Orientation (degrees)"));
ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)")));
st.align.rotation = rot as i16;
ui.checkbox(&mut st.align.wrap, "Wrap text");
ui.checkbox(&mut st.align.shrink, "Shrink to fit");
ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text"));
ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit"));
}
"Font" => {
egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| {
egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| {
for f in crate::fonts::families() {
ui.selectable_value(&mut st.font.name, f.clone(), f.clone());
}
});
ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size"));
ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size")));
ui.horizontal(|ui| {
ui.checkbox(&mut st.font.bold, "Bold");
ui.checkbox(&mut st.font.italic, "Italic");
ui.checkbox(&mut st.font.strike, "Strikethrough");
ui.checkbox(&mut st.font.bold, lang.tr("Bold"));
ui.checkbox(&mut st.font.italic, lang.tr("Italic"));
ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough"));
});
use gridcraft_engine::model::{Underline, VertAlign};
ui.horizontal(|ui| {
@@ -1475,17 +1646,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
(Underline::SingleAccounting, "Single Accounting"),
(Underline::DoubleAccounting, "Double Accounting"),
] {
ui.radio_value(&mut st.font.underline, u, n);
ui.radio_value(&mut st.font.underline, u, lang.tr(n));
}
});
ui.horizontal(|ui| {
ui.radio_value(&mut st.font.vert, VertAlign::Baseline, "Normal");
ui.radio_value(&mut st.font.vert, VertAlign::Superscript, "Superscript");
ui.radio_value(&mut st.font.vert, VertAlign::Subscript, "Subscript");
ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal"));
ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript"));
ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript"));
});
ui.label("Color:");
ui.label(lang.tr("Color:"));
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
if let Some(c) = crate::widgets::color_palette(ui, &colors, "Automatic") {
if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) {
st.font.color =
if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() };
}
@@ -1494,10 +1665,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
use gridcraft_engine::model::{BorderLine, BorderStyle};
let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto };
ui.horizontal(|ui| {
if ui.button("None").clicked() {
if ui.button(lang.tr("None")).clicked() {
st.borders = Default::default();
}
if ui.button("Outline").clicked() {
if ui.button(lang.tr("Outline")).clicked() {
st.borders.top = line;
st.borders.bottom = line;
st.borders.left = line;
@@ -1512,6 +1683,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
("Diagonal ↘", &mut st.borders.diag_down),
("Diagonal ↗", &mut st.borders.diag_up),
] {
let n = lang.tr(n);
ui.horizontal(|ui| {
ui.label(format!("{n}:"));
for (s, label) in [
@@ -1523,6 +1695,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
(BorderStyle::Dotted, "dotted"),
(BorderStyle::Double, "double"),
] {
let label = lang.tr(label);
let response = ui.selectable_label(slot.style == s, label);
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
@@ -1537,8 +1710,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
}
"Fill" => {
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
ui.label("Background Color:");
if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") {
ui.label(lang.tr("Background Color:"));
if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) {
st.fill = if c == "none" {
Default::default()
} else {
@@ -1547,11 +1720,13 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
}
}
"Protection" => {
ui.checkbox(&mut st.protection.locked, "Locked");
ui.checkbox(&mut st.protection.hidden, "Hidden");
ui.checkbox(&mut st.protection.locked, lang.tr("Locked"));
ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden"));
ui.label(
egui::RichText::new("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet).")
.small(),
egui::RichText::new(
lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."),
)
.small(),
);
}
_ => {}
@@ -2352,9 +2527,13 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
let method = text(d, "method");
ui.horizontal(|ui| {
ui.label("Select a Solving Method:");
let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" };
let label = |m: &str| match m {
"simplex" => "Simplex LP",
"evolutionary" => "Evolutionary",
_ => "GRG Nonlinear",
};
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
for m in ["grg", "simplex"] {
for m in ["grg", "simplex", "evolutionary"] {
if ui.selectable_label(method == m, label(m)).clicked() {
d.values.insert("method".into(), json!(m));
}
@@ -2362,10 +2541,10 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
});
});
ui.label(
egui::RichText::new(if method == "simplex" {
"Simplex LP: for linear models; integer and binary constraints by branch and bound."
} else {
"GRG Nonlinear: a local optimum of a smooth model, from the current values."
egui::RichText::new(match method.as_str() {
"simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.",
"evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.",
_ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.",
})
.small(),
);
@@ -2397,6 +2576,18 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &
d.values.insert("restore".into(), json!(true));
}
}
// Reports describe the solution whether it is kept or not.
let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty());
if solved {
ui.add_space(4.0);
ui.label("Reports:");
for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] {
let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false);
if ui.checkbox(&mut on, label).changed() {
d.values.insert(key.into(), json!(on));
}
}
}
let mut confirm = false;
ok_cancel(ui, &mut confirm, open);
if confirm {
@@ -2404,6 +2595,16 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &
if changed && restore {
app.run_or_alert("edit.undo", json!({}));
}
let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")]
.into_iter()
.filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true))
.map(|(_, kind)| kind)
.collect();
if solved && !reports.is_empty() {
let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null);
let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")});
app.run_or_alert("data.solverReport", params);
}
*open = false;
}
}
@@ -2460,3 +2661,81 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo
}
});
}
#[cfg(test)]
mod data_validation_tests {
use super::*;
#[test]
fn list_uses_one_source_and_does_not_submit_a_second_list() {
let fields = data_validation_fields("list", "between");
assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" })));
assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. })));
let dialog = Dialog::form(
"dataValidation",
"Data Validation",
"data.validation",
fields,
json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}),
);
let params = build_params(&dialog);
assert_eq!(params["formula1"], "ABC,DEF");
assert!(params.get("formula2").is_none());
assert!(params.get("operator").is_none());
}
#[test]
fn only_between_operators_show_and_submit_two_bounds() {
let two = data_validation_fields("whole", "between");
assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" })));
let one = data_validation_fields("date", "lessOrEqual");
assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" })));
assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. })));
let dialog = Dialog::form(
"dataValidation",
"Data Validation",
"data.validation",
one,
json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}),
);
let params = build_params(&dialog);
assert_eq!(params["operator"], "lessOrEqual");
assert_eq!(params["formula1"], "2026-10-12");
assert!(params.get("formula2").is_none());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_validation_opens_on_the_rule_of_the_active_cell() {
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.run("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "5"})).unwrap();
let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap();
assert_eq!(d.values["type"], "whole");
assert_eq!(d.values["operator"], "greater");
assert_eq!(d.values["formula1"], "5");
// OK without edits keeps the rule: 6 is accepted, 4 is rejected.
app.run(d.command.unwrap(), build_params(&d)).unwrap();
let doc = app.session.active().unwrap();
let at = gridcraft_engine::core::CellRef::new(0, 0);
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "6").is_none());
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some());
}
#[test]
fn data_validation_without_a_rule_opens_on_a_blank_list() {
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap();
assert_eq!(d.values["type"], "list");
assert!(d.values.get("formula1").is_none());
}
}
+10 -2
View File
@@ -251,9 +251,10 @@ pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, ty
match sheet.value(CellRef::new(row, at.col)) {
gridcraft_engine::core::Value::Empty => false,
gridcraft_engine::core::Value::Text(t) => {
if t.to_lowercase().starts_with(&lower) && t.len() > typed.len() {
let folded = t.to_lowercase();
if folded.starts_with(&lower) && folded.len() > lower.len() {
match &found {
Some(f) if !f.eq_ignore_ascii_case(&t) => ambiguous = true,
Some(f) if f.to_lowercase() != folded => ambiguous = true,
None => found = Some(t.to_string()),
_ => {}
}
@@ -281,6 +282,13 @@ pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, ty
if ambiguous { None } else { found }
}
/// The part of `full` after the `typed` prefix, counted in characters: case folding can change
/// a character's byte length ("ß" is two bytes, "ẞ" three).
pub fn completion_rest<'a>(full: &'a str, typed: &str) -> Option<&'a str> {
let start = full.char_indices().nth(typed.chars().count())?.0;
full.get(start..)
}
#[cfg(test)]
mod tests {
use super::*;
+9 -3
View File
@@ -173,6 +173,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
let enter = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::SHIFT, Key::Enter);
let tab = i.consume_key(egui::Modifiers::NONE, Key::Tab) || i.consume_key(egui::Modifiers::SHIFT, Key::Tab);
let esc = i.consume_key(egui::Modifiers::NONE, Key::Escape);
// Backspace takes back the AutoComplete proposal (Excel's selected rest), not a typed character.
if ed.completion.is_some() && i.consume_key(egui::Modifiers::NONE, Key::Backspace) {
ed.completion = None;
}
(enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, m.shift)
});
if alt_enter {
@@ -198,6 +202,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
if esc {
if !ed.autocomplete.is_empty() {
ed.autocomplete.clear();
} else if ed.completion.is_some() {
ed.completion = None;
} else {
cancel = true;
}
@@ -305,7 +311,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
}
if resp.changed() {
ed.completion = None;
if !ed.is_formula()
if app.ui.autocomplete
&& !ed.is_formula()
&& ed.caret == ed.text.chars().count()
&& let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet))
{
@@ -329,8 +336,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
}
// Column AutoComplete: the rest of the suggested entry, shown selected after the caret.
if let Some(full) = &ed.completion
&& full.len() > ed.text.len()
&& let Some(rest) = full.get(ed.text.len()..)
&& let Some(rest) = crate::editor::completion_rest(full, &ed.text)
{
let pos = resp.rect.left_top() + egui::vec2(output.galley.rect.width(), 0.0);
let g = ui.painter().layout_no_wrap(
+39 -5
View File
@@ -9,6 +9,14 @@ pub(crate) fn canonical(
sheet: usize,
cell: gridcraft_engine::core::CellRef,
) -> Result<String, ParseError> {
// The editor hid the `_xludf.` prefixes of this cell's formula; confirming it unchanged keeps
// the user-defined calls instead of binding them to the built-ins of the same name.
if let Some(stored) = wb.sheet(sheet).and_then(|sh| sh.cell(cell)).filter(|c| c.formula.is_some()).map(|c| c.input_text())
&& has_udf_prefix(&stored)
&& text == display(&stored, locale, wb, sheet)
{
return Ok(stored);
}
if locale == FormulaLocale::En || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") {
return Ok(text.to_string());
}
@@ -40,16 +48,42 @@ pub(crate) fn canonical(
}
pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String {
// English (and every language without a formula table) shows the stored text untouched,
// and skips a parse per frame.
// English (and every language without a formula table) shows the stored text, `_xludf.`
// prefixes aside, and skips a parse per frame.
if locale == FormulaLocale::En {
return text.to_string();
return hide_udf_prefix(text, locale);
}
let Some(body) = text.strip_prefix('=') else { return text.to_string() };
formula::parse(body)
let shown = formula::parse(body)
.and_then(|expr| formula::print_input(&expr, locale, |name| wb.name(name, sheet).is_some() || wb.table(name).is_some()))
.map(|body| format!("={body}"))
.unwrap_or_else(|_| text.to_string())
.unwrap_or_else(|_| text.to_string());
hide_udf_prefix(&shown, locale)
}
const UDF_PREFIX: &str = "_xludf.";
fn has_udf_prefix(text: &str) -> bool {
text.to_ascii_lowercase().contains(UDF_PREFIX)
}
/// Excel shows a user-defined call such as `_xludf.EOMONTH()` as `=EOMONTH()`. Only function
/// names lose the prefix: text literals and names keep it.
fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String {
let Some(body) = text.strip_prefix('=').filter(|b| has_udf_prefix(b)) else { return text.to_string() };
let Ok(tokens) = formula::lexer::tokenize_locale(body, locale) else { return text.to_string() };
let mut out = String::from("=");
let mut from = 0;
for t in tokens {
if matches!(t.tok, formula::lexer::Tok::Func(_))
&& body.get(t.start..t.start + UDF_PREFIX.len()).is_some_and(|p| p.eq_ignore_ascii_case(UDF_PREFIX))
{
out.push_str(body.get(from..t.start).unwrap_or(""));
from = t.start + UDF_PREFIX.len();
}
}
out.push_str(body.get(from..).unwrap_or(""));
out
}
// Descriptions/signatures for the supported Spanish function table; untranslated functions
+443 -53
View File
@@ -20,6 +20,42 @@ pub const COL_HEADER_H: f32 = 21.0;
/// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall).
pub const DISPLAY_SCALE: f32 = 0.8;
/// The zoom percentages a Ctrl+wheel notch steps between: four per doubling (about 20% apart),
/// so 25, 50, 100, 200 and 400 are on it. Excel steps 15 points a notch instead; the status bar's
/// Zoom In / Zoom Out buttons keep their 10% steps.
pub const WHEEL_ZOOM_LADDER: [u16; 22] = [10, 12, 15, 18, 21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400];
/// Seconds without Ctrl+wheel input after which part of a notch turned so far is forgotten.
const WHEEL_ZOOM_IDLE: f64 = 0.75;
/// The zoom one Ctrl+wheel notch goes to from `zoom`: the next step on [`WHEEL_ZOOM_LADDER`] in
/// that direction, skipping a step less than 5% away (121% out goes to 100%, not 120%), but never
/// 100% or the 10% / 400% limits (99% or 101% towards 100% goes to 100%).
pub fn wheel_zoom_step(zoom: u16, zoom_in: bool) -> u16 {
let zoom = zoom.clamp(10, 400);
let z = u32::from(zoom);
let far = |l: u32| if zoom_in { l * 100 >= z * 105 } else { l * 105 <= z * 100 };
let mut ahead = WHEEL_ZOOM_LADDER.iter().copied().filter(|&l| if zoom_in { l > zoom } else { l < zoom }).collect::<Vec<_>>();
if !zoom_in {
ahead.reverse();
}
let limit = ahead.last().copied().unwrap_or(zoom);
ahead.into_iter().find(|&l| l == 100 || far(u32::from(l))).unwrap_or(limit)
}
/// One frame of the zoom gliding from `from` to `to` percent: the share `t` (0–1) of the way,
/// measured as a ratio so zooming in and out feel alike, and at least one percent, so it arrives.
pub fn zoom_glide_step(from: u16, to: u16, t: f32) -> u16 {
let (f, g) = (f32::from(from.max(1)), f32::from(to.max(1)));
let next = (f * (g / f).powf(t.clamp(0.0, 1.0))).round();
let next = if next.is_finite() { next.clamp(f32::from(from.min(to)), f32::from(from.max(to))) as u16 } else { to };
match (next == from, from.cmp(&to)) {
(true, std::cmp::Ordering::Less) => from + 1,
(true, std::cmp::Ordering::Greater) => from - 1,
_ => next,
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Drag {
#[default]
@@ -68,11 +104,15 @@ pub enum Drag {
pub struct GridState {
pub drag: Drag,
pub ensure_visible: bool,
/// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell
/// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves).
pub reveal_moving_end: bool,
pub last_title: Option<String>,
pub drag_select: bool,
pub rect: Option<Rect>,
pub cells_rect: Option<Rect>,
pub filter_menu: Option<(u32, Pos2)>,
/// Column and row of the clicked header cell, and where to open the menu.
pub filter_menu: Option<(u32, u32, Pos2)>,
pub context_menu: Option<Pos2>,
pub marching_phase: f32,
/// The selection outline on its way to a new selection.
@@ -84,7 +124,6 @@ pub struct GridState {
pub last_border: String,
pub last_fill: String,
pub last_font_color: String,
pub system_clipboard: Option<String>,
pub trace: Option<serde_json::Value>,
pub renaming_tab: Option<usize>,
pub rename_text: String,
@@ -100,10 +139,42 @@ pub struct GridState {
pub paper: Option<crate::paper_view::PaperCache>,
/// Header or footer being edited in place (Page Layout view).
pub hf_edit: Option<crate::paper_view::HfEdit>,
/// A page margin being dragged on a ruler (Page Layout view); committed on release.
pub ruler_drag: Option<crate::paper_view::RulerDrag>,
/// Insert › Header & Footer: start editing the first page's header once Page Layout view is up.
pub hf_start: bool,
/// The grid geometry of the last frame (hit-testing for agents and tests).
pub geo: Option<Geo>,
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
pub hscroll: Option<Rect>,
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
pub wheel_zoom: Option<WheelZoom>,
}
/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and
/// fine-grained wheels send parts of one, so the turning is counted in notches, and each whole
/// notch steps [`WHEEL_ZOOM_LADDER`]. With animations on the zoom then glides to that step.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WheelZoom {
pub place: (u64, usize),
/// Turned so far towards the next step, in notches (+ in, − out).
pub notches: f32,
/// When `notches` last changed.
pub time: f64,
/// The step the zoom is gliding to, and the percentage set last on the way.
pub glide: Option<(u16, u16)>,
}
impl WheelZoom {
/// The state for `place` showing `zoom` percent: fresh for another document or sheet, and
/// without its glide if the zoom was changed some other way since the glide set it.
fn at(app: &SheetApp, place: (u64, usize), zoom: u16, now: f64) -> WheelZoom {
let mut w = app.grid.wheel_zoom.filter(|w| w.place == place).unwrap_or(WheelZoom { place, notches: 0.0, time: now, glide: None });
if w.glide.is_some_and(|(_, shown)| shown != zoom) {
w.glide = None;
}
w
}
}
impl GridState {
@@ -230,6 +301,13 @@ impl Geo {
None => (sh.row_top(row) - sh.row_top(self.fr)) as f32,
}
}
/// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area.
pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 {
match &self.paper {
Some(pg) => pg.col_x(col) as f32,
None => (sh.col_left(col) - sh.col_left(self.fc)) as f32,
}
}
/// Screen x for a logical x (and back): identity unless the sheet is right-to-left.
pub fn mirror_x(&self, x: f32) -> f32 {
if self.rtl { self.rect.left() + self.rect.right() - x } else { x }
@@ -368,6 +446,15 @@ fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 {
}
}
/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds.
fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 {
let mut end = start;
while end < limit && end + 1 < MAX_COLS && continues(end + 1) {
end += 1;
}
end
}
fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option<CfLook>) {
let mut style = wb.styles.get(sh.style_id(c)).clone();
if let Some(t) = sh.table_at(c) {
@@ -488,11 +575,17 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
// Page Layout view: pages on a desk, rulers above and left of the headings.
let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout;
let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 };
let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max);
// Right-to-left sheets carry the vertical ruler on the right, beside the row headings.
let grid_rect = if rtl {
Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom()))
} else {
Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max)
};
let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo {
if paper {
let probe = Geo::new(sh, grid_rect, scroll);
if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) {
let g = crate::paper_view::with_dragged_margin(app, &wb, si, g);
return Geo::paged(sh, grid_rect, scroll, g);
}
}
@@ -502,8 +595,34 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time));
if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) {
if zoom_delta != 1.0 {
let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round();
let _ = app.session.run("view.zoom", json!({"percent": z}));
// egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points.
let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed);
let turned = zoom_delta.ln() / notch;
let mut zooming = WheelZoom::at(app, place, sh.zoom, now);
if now - zooming.time > WHEEL_ZOOM_IDLE {
zooming.notches = 0.0;
}
if turned.is_finite() {
zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0);
}
zooming.time = now;
// A notch steps once 60% of it has turned, and the rest of it brings `notches` back to
// about 0. (Past half, so what's left after a step can't step back.) Steps count from
// where a glide is going, not from where it has got to.
let from = zooming.glide.map_or(sh.zoom, |(to, _)| to);
let mut z = from;
while zooming.notches >= 0.6 {
z = wheel_zoom_step(z, true);
zooming.notches -= 1.0;
}
while zooming.notches <= -0.6 {
z = wheel_zoom_step(z, false);
zooming.notches += 1.0;
}
if z != from {
zooming.glide = Some((z, sh.zoom));
}
app.grid.wheel_zoom = Some(zooming);
} else if scroll_delta != egui::Vec2::ZERO {
let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE };
let mut d = scroll_delta;
@@ -518,6 +637,22 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
view.scroll -= d / z;
}
}
// The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes
// there at once without animations (as for the selection outline below: View › Animations
// off, egui's off in the headless harness, or a screenshot on its way).
let mut zooming = WheelZoom::at(app, place, sh.zoom, now);
if let Some((to, shown)) = zooming.glide {
let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight();
let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1));
let z = if glides { zoom_glide_step(shown, to, share) } else { to };
let _ = app.session.run("view.zoom", json!({"percent": z}));
zooming.glide = (z != to).then_some((to, z));
ui.ctx().request_repaint();
}
// Store a glide's progress, or that it stopped.
if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) {
app.grid.wheel_zoom = Some(zooming);
}
view.scroll = view.scroll.max(egui::Vec2::ZERO);
let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32);
if paper {
@@ -533,10 +668,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
view.scroll.x = view.scroll.x.min(max_x);
view.scroll.y = view.scroll.y.min(max_y);
let mut geo = make_geo(app, view.scroll);
// Keep the active cell visible after keyboard moves.
// Keep the active cell (or the moving end of an extension) visible after keyboard moves.
if app.grid.ensure_visible {
app.grid.ensure_visible = false;
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(sel.active);
let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active };
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell);
if paper {
crate::paper_view::ensure_covers(app, &wb, si, target);
geo = make_geo(app, view.scroll);
@@ -730,6 +866,63 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
}
}
/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab
/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the
/// far end to the grid, which runs later in the same frame.
pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) {
app.grid.hscroll = Some(track);
if track.height() <= 0.0 || track.width() <= 0.0 {
return;
}
let Some(geo) = app.grid.geo.clone() else { return };
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
let Some(sh) = wb.active() else { return };
let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0);
let scroll = app.view().scroll.x;
let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1);
let content = geo.scroll_left_of(sh, last);
let extent = (content - visible).max(scroll).max(0.0);
let id = ui.id().with("hscroll");
let resp = ui.interact(track, id, Sense::click_and_drag());
let drag_id = id.with("extent");
if resp.drag_started() {
ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));
}
let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width());
let travel = track.width() - thumb_w;
let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };
// A right-to-left sheet scrolls towards the left, so its thumb starts at the right end.
let dir = if geo.rtl { -1.0 } else { 1.0 };
let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset };
let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0)));
ui.painter().rect_filled(track, 0.0, t.tab_bar);
ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });
let mut next = scroll;
if resp.dragged() && travel > 0.0 {
next += dir * resp.drag_delta().x / travel * extent;
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
if p.x < thumb.left() {
next -= dir * visible;
} else if p.x > thumb.right() {
next += dir * visible;
}
}
if resp.hovered() {
let d = ui.input(|i| i.smooth_scroll_delta);
next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z;
}
next = next.max(0.0);
if next != scroll {
if let Some(v) = app.view_mut() {
v.scroll.x = next;
}
ui.ctx().request_repaint();
}
}
pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
let pad_v = 1.5 * z;
let y_top = rect.top() + pad_v;
@@ -1035,17 +1228,24 @@ pub(crate) fn paint_quadrant(
galley = p.layout_no_wrap(s, font.clone(), color);
w = galley.size().x;
}
// Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it.
let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() {
center_across_end(c.col, c1 + 20, |col| {
let n = CellRef::new(c.row, col);
empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross
})
} else {
c.col
};
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
let mut clip = *rect;
if filter_space == 0.0
&& !numeric
&& !matches!(v, Value::Number(_))
&& st.align.rotation.abs() != 90
&& w > rect.width() - 2.0 * pad
&& in_merge(c).is_none()
{
let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad;
if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() {
let mut right = rect.right();
let mut left = rect.left();
if across_end > c.col {
right = geo.x(sh, across_end + 1);
}
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
let mut cc = c.col + 1;
while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 {
@@ -1075,7 +1275,8 @@ pub(crate) fn paint_quadrant(
}
}
let x = match halign {
HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - w / 2.0,
HAlign::CenterAcross => clip.center().x - w / 2.0,
HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0,
HAlign::Right => rect.right() - pad - w - indent,
_ => text_left + pad + indent,
};
@@ -1373,6 +1574,26 @@ fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool {
r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0
}
/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it).
///
/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in
/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay
/// near the viewport do not. Sides wholly outside `clip` are left out.
pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec<Rect> {
if !rect.is_finite() || !clip.is_finite() {
return Vec::new();
}
let h = width / 2.0;
let clip = clip.expand(width * 2.0);
let sides = [
Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)),
Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)),
Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)),
Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)),
];
sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect()
}
/// `outline` is where the current area's border is drawn this frame (see `selection_glide`).
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_selection(
@@ -1415,7 +1636,9 @@ pub(crate) fn paint_selection(
}
}
if cur_here {
p.rect_stroke(outline.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle);
for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) {
p.rect_filled(side, 0.0, t.sel_border);
}
}
if active_here && sel.ranges.len() > 1 {
p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside);
@@ -1743,6 +1966,29 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
}
// Double-click the fill handle: a double-click is also a click, so run it before the handle press returns.
if resp.double_clicked()
&& let Some(p) = pos
&& on_handle(p)
{
// Fill down as far as the neighbouring column goes.
let src = sel.current();
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
let mut last = src.end.row;
while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000
{
last += 1;
}
if last > src.end.row {
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
}
app.grid.drag = Drag::None;
app.grid.drag_select = false;
resp.request_focus();
return;
}
// Press: decide the drag mode.
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
// Where the press began (a drag is only recognised after the pointer has moved).
@@ -1770,6 +2016,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
return;
}
let c = geo.col_at(sh, p.x);
if point_header(app, wb, sh, RangeRef::cols(c, c)) {
return;
}
if resp.drag_started()
&& !mods.any()
&& sel.ranges.len() == 1
@@ -1790,6 +2039,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
return;
}
let r = geo.row_at(sh, p.y);
if point_header(app, wb, sh, RangeRef::rows(r, r)) {
return;
}
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0)));
app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r });
@@ -1808,7 +2060,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
// Filter dropdown buttons.
if is_filter_button(sh, geo, p) {
app.grid.filter_menu = Some((c.col, geo.mirror_pos(p)));
app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p)));
return;
}
// Point mode while editing a formula: clicking inserts a reference.
@@ -2046,25 +2298,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
app.grid.drag_select = false;
}
// Double-click the fill handle.
if resp.double_clicked()
&& let Some(p) = pos
&& on_handle(p)
{
// Fill down as far as the neighbouring column goes.
let src = sel.current();
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
let mut last = src.end.row;
while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000
{
last += 1;
}
if last > src.end.row {
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
}
}
// Right-click: context menu.
if resp.secondary_clicked()
&& let Some(p) = pos
@@ -2097,6 +2330,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
keyboard(app, &ctx, resp, geo, sh);
}
/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting.
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool {
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
let r = range.a1();
let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r };
ed.insert_ref(&r);
ed.request_focus = true;
true
}
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
let cell = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
@@ -2281,15 +2524,18 @@ fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
true
}
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
pub fn follow_link(app: &mut SheetApp, target: &str) {
if target.contains("://") || target.starts_with("mailto:") {
if let Some(f) = &app.services.open_url {
f(target);
use gridcraft_engine::links::{LinkKind, classify};
match classify(target) {
LinkKind::Internal(r) => {
let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')}));
app.grid.ensure_visible = true;
}
} else {
let t = target.trim_start_matches('#');
let _ = app.session.run("edit.goTo", json!({"reference": t}));
app.grid.ensure_visible = true;
LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})),
LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)),
}
}
@@ -2371,6 +2617,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
let mv = |app: &mut SheetApp, dr: i64, dc: i64| {
let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd}));
app.grid.ensure_visible = true;
app.grid.reveal_moving_end = shift;
};
match key {
Key::ArrowUp => mv(app, -1, 0),
@@ -2398,6 +2645,12 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()}));
app.grid.ensure_visible = true;
}
// Excel: Enter while a copied range is marked pastes it and ends copy mode.
Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => {
app.run_or_alert("edit.paste", json!({}));
app.session.clipboard = None;
app.grid.ensure_visible = true;
}
Key::Enter => {
if m.alt || m.ctrl {
app.begin_edit(None, false);
@@ -2480,20 +2733,20 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool {
response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere())
}
/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column.
fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option<RangeRef> {
sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range)
}
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
let Some((col, at)) = app.grid.filter_menu else { return };
let Some((col, row, at)) = app.grid.filter_menu else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
let si = wb.active_sheet;
let Some(sh) = wb.sheet(si) else { return };
let _ = geo;
// Tables get an autofilter on demand.
let table_range = sh
.tables
.iter()
.find(|t| t.range.start.row <= at.y as u32 || true)
.filter(|t| col >= t.range.start.col && col <= t.range.end.col)
.map(|t| t.range);
let table_range = filter_table_range(sh, col, row);
if sh.autofilter.is_none()
&& let Some(r) = table_range
{
@@ -2547,7 +2800,9 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
c.1 = all_new;
}
}
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
// Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only
// worked over the values themselves (#432).
egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| {
for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) {
ui.checkbox(on, v.as_str());
}
@@ -2651,6 +2906,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
"edit.copy" | "edit.cut" => {
app.copy_to_clipboard(ui.ctx(), id);
}
"edit.paste" => app.paste_from_clipboard(),
other => app.run_or_alert(other, json!({})),
}
}
@@ -2674,7 +2930,17 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
let from = ed.point_cell.unwrap_or(ed.cell);
let to = from.offset_clamped(dr, dc);
let text = if extend {
let anchor = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(|t| CellRef::parse(t.split(':').next().unwrap_or(""))).unwrap_or(from);
// The anchor is the corner opposite the moving end: the written range is normalized, so
// after extending up or left its first cell is the moving end, not the anchor.
let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse);
let anchor = pointed
.map(|r| {
CellRef::new(
if from.row == r.start.row { r.end.row } else { r.start.row },
if from.col == r.start.col { r.end.col } else { r.start.col },
)
})
.unwrap_or(from);
RangeRef::new(anchor, to).a1()
} else {
to.a1()
@@ -2715,8 +2981,9 @@ pub(crate) fn paint_overlays(
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside);
crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60));
}
// Input message of the active cell's validation.
// Input message of the active cell's validation, hidden while its list is open: it would cover the items.
if app.editor.is_none()
&& app.grid.list_picker != Some(sel.active)
&& let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active)))
&& dv.show_input
&& !(dv.input_title.is_empty() && dv.input_message.is_empty())
@@ -2893,6 +3160,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
"edit.copy" | "edit.cut" => {
app.copy_to_clipboard(ui.ctx(), id);
}
"edit.paste" => app.paste_from_clipboard(),
other => app.run_or_alert(other, json!({})),
}
}
@@ -3053,6 +3321,104 @@ mod selection_fill_tests {
mod tests {
use super::*;
#[test]
fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() {
for (from, zoom_in, to) in [
(100, true, 120),
(120, false, 100),
(100, false, 85),
// Between steps, the next one each way (Excel: 112% goes to 115% or 100%).
(112, true, 120),
(112, false, 100),
// A step less than 5% away is skipped…
(121, false, 100),
(86, false, 70),
(139, true, 170),
// …but never 100% or a limit.
(99, true, 100),
(101, false, 100),
(96, true, 100),
(385, true, 400),
(11, false, 10),
(400, true, 400),
(10, false, 10),
// Out-of-range zooms (from a file) come into range first.
(5, true, 12),
(1000, false, 340),
(0, false, 10),
(u16::MAX, true, 400),
] {
assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" });
}
// Every step on the ladder is 15–25% from the next, and a notch in then out returns.
// (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.)
for w in WHEEL_ZOOM_LADDER.windows(2) {
let ratio = f32::from(w[1]) / f32::from(w[0]);
assert!((1.15..=1.25).contains(&ratio), "{w:?}");
assert_eq!(wheel_zoom_step(w[0], true), w[1]);
assert_eq!(wheel_zoom_step(w[1], false), w[0]);
}
}
#[test]
fn zoom_glide_moves_by_ratio_and_always_arrives() {
// A third of the way per frame, as at 60 frames a second.
for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] {
let (mut z, mut frames) = (from, 0);
while z != to {
let next = zoom_glide_step(z, to, 0.32);
assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}");
z = next;
frames += 1;
assert!(frames < 40, "{from} → {to} arrives");
}
assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})");
}
assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio");
assert_eq!(zoom_glide_step(120, 100, 0.32), 113);
// No progress asked or nonsense: still a percent closer, or straight there.
assert_eq!(zoom_glide_step(100, 120, 0.0), 101);
assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100);
assert_eq!(zoom_glide_step(100, 120, 5.0), 120);
assert_eq!(zoom_glide_step(120, 120, 0.5), 120);
assert_eq!(zoom_glide_step(0, 10, 0.5), 3);
}
#[test]
fn center_across_run_stops_at_first_non_continuing_column() {
assert_eq!(center_across_end(0, 20, |c| c <= 2), 2);
assert_eq!(center_across_end(0, 20, |_| false), 0);
assert_eq!(center_across_end(0, 1, |_| true), 1);
}
#[test]
fn filter_menu_uses_the_table_whose_header_was_clicked() {
use gridcraft_engine::model::Table;
let table = |id: u32, r0: u32, r1: u32| Table {
id,
name: format!("T{id}"),
range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)),
header_row: true,
totals_row: false,
columns: vec![],
style: String::new(),
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
filter: vec![],
table_type: None,
connection_id: None,
};
let mut sh = Sheet::new("S");
sh.tables.push(table(1, 0, 2));
sh.tables.push(table(2, 4, 7));
assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range));
assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range));
assert_eq!(filter_table_range(&sh, 1, 4), None);
}
#[test]
fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() {
let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0));
@@ -3238,3 +3604,27 @@ mod pane_tests {
assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle)));
}
}
#[cfg(test)]
mod outline_band_tests {
use super::*;
#[test]
fn whole_column_outline_keeps_left_and_right_sides_near_viewport() {
let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0));
let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0));
let sides = outline_bands(col, 2.0, clip);
// Left, right and top (in view); the bottom is 20 million points away.
assert_eq!(sides.len(), 3);
assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON));
assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0));
assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0));
}
#[test]
fn outline_outside_the_view_and_non_finite_draw_nothing() {
let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0));
assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty());
assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty());
}
}
+32 -1
View File
@@ -16,6 +16,7 @@ mod fr;
mod ko;
mod pt;
mod ru;
mod sr;
mod zh;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -33,10 +34,11 @@ pub enum Language {
De,
Cs,
Fr,
Sr,
}
impl Language {
pub const ALL: [Self; 10] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr];
pub const ALL: [Self; 11] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr, Self::Sr];
/// The language's own name, shown in the switcher.
pub fn name(self) -> &'static str {
@@ -51,6 +53,7 @@ impl Language {
Self::De => "Deutsch",
Self::Cs => "Čeština",
Self::Fr => "Français",
Self::Sr => "Srpski",
}
}
@@ -67,6 +70,7 @@ impl Language {
Self::De => "de",
Self::Cs => "cs",
Self::Fr => "fr",
Self::Sr => "sr",
}
}
@@ -86,6 +90,7 @@ impl Language {
"de" => Some(Self::De),
"cs" => Some(Self::Cs),
"fr" => Some(Self::Fr),
"sr" => Some(Self::Sr),
_ => None,
}
}
@@ -123,6 +128,7 @@ impl Language {
Self::De => de::translate(text),
Self::Cs => cs::translate(text),
Self::Fr => fr::translate(text),
Self::Sr => sr::translate(text),
};
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
}
@@ -290,6 +296,12 @@ const JAPANESE: &[(&str, &str)] = &[
("Select\nObjects", "オブジェクトの選択"),
("Themes", "テーマ"),
("Margins", "余白"),
("Left Margin", "左余白"),
("Right Margin", "右余白"),
("Top Margin", "上余白"),
("Bottom Margin", "下余白"),
("Header Margin", "ヘッダーの余白"),
("Footer Margin", "フッターの余白"),
("Size", "サイズ"),
("Print\nArea", "印刷範囲"),
("Set Print Area", "印刷範囲の設定"),
@@ -511,6 +523,16 @@ const JAPANESE: &[(&str, &str)] = &[
("Recent", "最近使ったアイテム"),
("No recent workbooks yet.", "最近使ったブックはまだありません。"),
("More workbooks", "その他のブック"),
("Modified", "更新日時"),
("Not available", "利用できません"),
("Just now", "たった今"),
("Yesterday", "昨日"),
("{n} min ago", "{n} 分前"),
("{n} h ago", "{n} 時間前"),
("{n} days ago", "{n} 日前"),
("{n} weeks ago", "{n} 週間前"),
("{n} months ago", "{n} か月前"),
("{n} years ago", "{n} 年前"),
("Browse…", "参照…"),
("Not saved yet", "まだ保存されていません"),
("Location", "場所"),
@@ -561,6 +583,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Options|General", "全般"),
("Options|Save", "保存"),
("Show formula bar", "数式バーを表示する"),
("Enable AutoComplete for cell values", "セルの値にオートコンプリートを使用する"),
("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"),
("Workbook calculation", "ブックの計算"),
("Enable iterative calculation", "反復計算を行う"),
@@ -587,6 +610,7 @@ mod tests {
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}");
}
assert_eq!(Language::Ja.tr("Data"), "データ");
assert_eq!(Language::Zh.tr("Data"), "数据");
@@ -616,6 +640,10 @@ mod tests {
assert_eq!(Language::Ja.tr("Orientation"), "方向");
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace");
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu");
assert_eq!(Language::Sr.tr("Data"), "Podaci");
assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija");
assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija");
assert_eq!(Language::parse("xx"), None);
}
@@ -654,6 +682,9 @@ mod tests {
assert_eq!(Language::from_tag("fr-FR"), Some(Language::Fr));
assert_eq!(Language::from_tag("fr_CA"), Some(Language::Fr));
assert_eq!(Language::parse("fr"), Some(Language::Fr));
assert_eq!(Language::from_tag("sr-Latn-RS"), Some(Language::Sr));
assert_eq!(Language::from_tag("sr-RS"), Some(Language::Sr));
assert_eq!(Language::parse("sr"), Some(Language::Sr));
}
#[test]
+144
View File
@@ -116,6 +116,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Vybrat\nobjekty",
"Themes" => "Motivy",
"Margins" => "Okraje",
"Left Margin" => "Levý okraj",
"Right Margin" => "Pravý okraj",
"Top Margin" => "Horní okraj",
"Bottom Margin" => "Dolní okraj",
"Header Margin" => "Okraj záhlaví",
"Footer Margin" => "Okraj zápatí",
"Size" => "Velikost",
"Print\nArea" => "Oblast\ntisku",
"Set Print Area" => "Nastavit oblast tisku",
@@ -256,6 +262,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Recent" => "Naposledy použité",
"No recent workbooks yet." => "Zatím žádné naposledy použité sešity.",
"More workbooks" => "Další sešity",
"Modified" => "Změněno",
"Not available" => "Nedostupné",
"Just now" => "Právě teď",
"Yesterday" => "Včera",
"{n} min ago" => "před {n} min",
"{n} h ago" => "před {n} h",
"{n} days ago" => "před {n} dny",
"{n} weeks ago" => "před {n} týdny",
"{n} months ago" => "před {n} měsíci",
"{n} years ago" => "před {n} lety",
"Browse…" => "Procházet…",
"Not saved yet" => "Zatím neuloženo",
"Location" => "Umístění",
@@ -303,6 +319,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Obecné",
"Options|Save" => "Uložit",
"Show formula bar" => "Zobrazit řádek vzorců",
"Enable AutoComplete for cell values" => "Povolit automatické dokončování hodnot buněk",
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
"Workbook calculation" => "Výpočet sešitu",
"Enable iterative calculation" => "Povolit iterativní výpočet",
@@ -391,6 +408,133 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More\nFunctions" => "Další\nfunkce",
"Sheet Options|View" => "Zobrazit",
"Print" => "Tisknout",
// Additional runtime labels and tooltips, grouped by source file.
// formula_bar.rs (alphabetical keys).
"Cancel" => "Zrušit",
"Expand formula bar" => "Rozbalit řádek vzorců",
"Insert Function" => "Vložit funkci",
// grid.rs (alphabetical keys).
"Copy" => "Kopírovat",
"Cut" => "Vyjmout",
"Define Name…" => "Definovat název…",
"Delete…" => "Odstranit…",
"Filter by Selected Cell's Value" => "Filtrovat podle hodnoty vybrané buňky",
"Hide" => "Skrýt",
"Insert…" => "Vložit…",
"Link…" => "Odkaz…",
"New Comment" => "Nový komentář",
"Pick From Drop-down List…" => "Vybrat z rozevíracího seznamu…",
"Unhide" => "Zobrazit",
// ribbon.rs (alphabetical keys).
"$ English (United States)" => "$ Angličtina (Spojené státy)",
"Add Chart Element" => "Přidat prvek grafu",
"Align Left" => "Zarovnat doleva",
"Align Right" => "Zarovnat doprava",
"Bottom Align" => "Zarovnat dolů",
"Calculate Now (F9)" => "Přepočítat (F9)",
"Calculate Sheet (⇧F9)" => "Přepočítat list (⇧F9)",
"Calculation Options" => "Možnosti výpočtu",
"Cell Styles" => "Styly buněk",
"Change Chart Type" => "Změnit typ grafu",
"Check Accessibility" => "Zkontrolovat přístupnost",
"Clear Filter" => "Vymazat filtr",
"Column or Bar" => "Sloupcový nebo pruhový graf",
"Comments pane" => "Podokno komentářů",
"Convert the last ink stroke to a shape" => "Převést poslední tah rukopisu na obrazec",
"Convert to Range" => "Převést na oblast",
"Custom Sort" => "Vlastní řazení",
"Delete Comment" => "Odstranit komentář",
"Delete Sheet" => "Odstranit list",
"Delete chart" => "Odstranit graf",
"Down" => "Dolů",
"Edit chart title" => "Upravit název grafu",
"Erase ink strokes" => "Vymazat tahy rukopisu",
"Every command run in this session (replayable)" => "Všechny příkazy spuštěné v této relaci (lze znovu přehrát)",
"Filter (⌘⇧F)" => "Filtr (⌘⇧F)",
"Find & Select" => "Najít a vybrat",
"Flash Fill (⌘E)" => "Dynamické doplňování (⌘E)",
"Forecast Sheet" => "List prognózy",
"Format Chart pane" => "Podokno formátu grafu",
"Format as Table" => "Formátovat jako tabulku",
"Hide Sheet" => "Skrýt list",
"Hierarchy & more" => "Hierarchické a další grafy",
"Highlighter" => "Zvýrazňovač",
"How agents drive GridCraft (MCP / control channel)" => "Jak agenti ovládají GridCraft (MCP / řídicí kanál)",
"Import a text or CSV file" => "Importovat textový soubor nebo CSV",
"Insert\nSlicer" => "Vložit\nprůřez",
"Insert Function (⇧F3)" => "Vložit funkci (⇧F3)",
"Insert Link (⌘K)" => "Vložit odkaz (⌘K)",
"Insert Sheet" => "Vložit list",
"Insert Slicer" => "Vložit průřez",
"Insert a picture" => "Vložit obrázek",
"Insert a slicer to filter a table" => "Vložit průřez pro filtrování tabulky",
"Insert an icon" => "Vložit ikonu",
"Line or Area" => "Spojnicový nebo plošný graf",
"Macros" => "Makra",
"Macros and scripts in this workbook (Alt+F8)" => "Makra a skripty v tomto sešitu (Alt+F8)",
"Middle Align" => "Zarovnat doprostřed",
"More commands" => "Další příkazy",
"Move or Copy Sheet…" => "Přesunout nebo zkopírovat list…",
"Name Manager" => "Správce názvů",
"Page Breaks" => "Konce stránek",
"Paper Size" => "Velikost papíru",
"Paste (⌘V)" => "Vložit (⌘V)",
"Pen\nBlack" => "Pero\nčerné",
"Pen\nBlue" => "Pero\nmodré",
"Pen\nRed" => "Pero\nčervené",
"Pie or Doughnut" => "Výsečový nebo prstencový graf",
"Print Area" => "Oblast tisku",
"Print Titles" => "Tisk názvů",
"Protect\nSheet" => "Zamknout\nlist",
"Protect Sheet" => "Zamknout list",
"Protect Workbook structure" => "Zamknout strukturu sešitu",
"Recommended Charts" => "Doporučené grafy",
"Redo (⌘Y)" => "Znovu (⌘Y)",
"Remove Duplicates" => "Odebrat duplicity",
"Rename Sheet" => "Přejmenovat list",
"Save (⌘S)" => "Uložit (⌘S)",
"Scatter or Bubble" => "Bodový nebo bublinový graf",
"Search and run any command" => "Vyhledat a spustit libovolný příkaz",
"Search commands (⌘⇧Space)" => "Vyhledat příkazy (⌘⇧Space)",
"Select Data" => "Vybrat data",
"Select and move ink and shapes" => "Vybrat a přesunout rukopis a obrazce",
"Series…" => "Řady…",
"Share: export or save a copy" => "Sdílet: exportovat nebo uložit kopii",
"Show Formulas (⌃`)" => "Zobrazit vzorce (⌃`)",
"Slicer" => "Průřez",
"Sort & Filter" => "Seřadit a filtrovat",
"Spelling (F7)" => "Pravopis (F7)",
"Statistic" => "Statistické grafy",
"Switch Row/Column" => "Prohodit řádky/sloupce",
"Table (⌘T)" => "Tabulka (⌘T)",
"Table Styles" => "Styly tabulky",
"Text Box" => "Textové pole",
"Text to Columns" => "Text do sloupců",
"Top Align" => "Zarovnat nahoru",
"Undo (⌘Z)" => "Zpět (⌘Z)",
"Undo list" => "Seznam akcí k vrácení",
"Unhide Sheet…" => "Zobrazit list…",
"Up" => "Nahoru",
"Version, contributors and the AI models that helped" => "Verze, přispěvatelé a modely AI, které pomáhaly",
"Watch\nWindow" => "Okno\nsledování",
"What-If Analysis" => "Citlivostní analýza",
"Workbook\nStatistics" => "Statistika\nsešitu",
"Workbook Statistics" => "Statistika sešitu",
"Zoom to 100%" => "Přiblížit na 100 %",
"Zoom to Selection" => "Přiblížit na výběr",
"Zoom…" => "Lupa…",
"£ English (United Kingdom)" => "£ Angličtina (Spojené království)",
"¥ Japanese" => "¥ Japonština",
"€ Euro" => "€ Euro",
// tabs.rs (alphabetical keys).
"New sheet (⇧F11)" => "Nový list (⇧F11)",
"Next sheet" => "Další list",
"Previous sheet" => "Předchozí list",
"Zoom In" => "Přiblížit",
"Zoom Out" => "Oddálit",
// widgets.rs (alphabetical keys).
"Dismiss" => "Zavřít",
_ => return None,
})
}
+17
View File
@@ -116,6 +116,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Objekte\nauswählen",
"Themes" => "Designs",
"Margins" => "Seitenränder",
"Left Margin" => "Linker Seitenrand",
"Right Margin" => "Rechter Seitenrand",
"Top Margin" => "Oberer Seitenrand",
"Bottom Margin" => "Unterer Seitenrand",
"Header Margin" => "Kopfzeilenabstand",
"Footer Margin" => "Fußzeilenabstand",
"Size" => "Größe",
"Print\nArea" => "Druck-\nbereich",
"Set Print Area" => "Druckbereich festlegen",
@@ -254,6 +260,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Recent" => "Zuletzt verwendet",
"No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.",
"More workbooks" => "Weitere Arbeitsmappen",
"Modified" => "Geändert",
"Not available" => "Nicht verfügbar",
"Just now" => "Gerade eben",
"Yesterday" => "Gestern",
"{n} min ago" => "vor {n} Min.",
"{n} h ago" => "vor {n} Std.",
"{n} days ago" => "vor {n} Tagen",
"{n} weeks ago" => "vor {n} Wochen",
"{n} months ago" => "vor {n} Monaten",
"{n} years ago" => "vor {n} Jahren",
"Browse…" => "Durchsuchen…",
"Not saved yet" => "Noch nicht gespeichert",
"Location" => "Speicherort",
@@ -303,6 +319,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Allgemein",
"Options|Save" => "Speichern",
"Show formula bar" => "Bearbeitungsleiste anzeigen",
"Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren",
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
"Workbook calculation" => "Arbeitsmappenberechnung",
"Enable iterative calculation" => "Iterative Berechnung aktivieren",
+47
View File
@@ -117,6 +117,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Seleccionar\nobjetos",
"Themes" => "Temas",
"Margins" => "Márgenes",
"Left Margin" => "Margen izquierdo",
"Right Margin" => "Margen derecho",
"Top Margin" => "Margen superior",
"Bottom Margin" => "Margen inferior",
"Header Margin" => "Margen del encabezado",
"Footer Margin" => "Margen del pie de página",
"Size" => "Tamaño",
"Print\nArea" => "Área de\nimpresión",
"Set Print Area" => "Establecer área de impresión",
@@ -255,6 +261,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Recent" => "Recientes",
"No recent workbooks yet." => "Todavía no hay libros recientes.",
"More workbooks" => "Más libros",
"Modified" => "Modificado",
"Not available" => "No disponible",
"Just now" => "Ahora mismo",
"Yesterday" => "Ayer",
"{n} min ago" => "hace {n} min",
"{n} h ago" => "hace {n} h",
"{n} days ago" => "hace {n} días",
"{n} weeks ago" => "hace {n} semanas",
"{n} months ago" => "hace {n} meses",
"{n} years ago" => "hace {n} años",
"Browse…" => "Examinar…",
"Not saved yet" => "Aún no se ha guardado",
"Location" => "Ubicación",
@@ -302,6 +318,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "General",
"Options|Save" => "Guardar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Enable AutoComplete for cell values" => "Habilitar Autocompletar para valores de celda",
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
"Workbook calculation" => "Cálculo del libro",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
@@ -393,6 +410,36 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Oscuro",
"Sheet Options|View" => "Ver",
"Print" => "Imprimir",
"PivotTable Fields" => "Campos de tabla dinámica",
"Choose fields to add to report:" => "Seleccione los campos para agregar al informe:",
"Use a field's menu to move it between areas:" => "Use el menú de un campo para moverlo entre áreas:",
"Filters" => "Filtros",
"Columns" => "Columnas",
"Rows" => "Filas",
"Values" => "Valores",
"Move to Row Labels" => "Mover a rótulos de fila",
"Move to Column Labels" => "Mover a rótulos de columna",
"Move to Report Filter" => "Mover al filtro de informe",
"Move to Values" => "Mover a valores",
"Summarize by Sum" => "Resumir por suma",
"Summarize by Count" => "Resumir por cuenta",
"Summarize by Average" => "Resumir por promedio",
"Summarize by Max" => "Resumir por máximo",
"Summarize by Min" => "Resumir por mínimo",
"Show as No Calculation" => "Mostrar como sin cálculo",
"Show as % of Grand Total" => "Mostrar como % del total general",
"Show as Running Total" => "Mostrar como total acumulado",
"Show as Rank" => "Mostrar como clasificación",
"Group by Years" => "Agrupar por años",
"Group by Quarters" => "Agrupar por trimestres",
"Group by Months" => "Agrupar por meses",
"Remove Field" => "Quitar campo",
"Refresh" => "Actualizar",
"Calculated Field…" => "Campo calculado…",
"Report Layout" => "Diseño de informe",
"Compact" => "Compacto",
"Outline" => "Esquema",
"Tabular" => "Tabular",
_ => return None,
})
}
+204 -177
View File
@@ -11,32 +11,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Transpose" => "Transposer",
"Paste Link" => "Coller la liaison",
"Paste Special…" => "Collage spécial…",
"Insert Copied Cells" => "Insérer les cellules coupées",
"Insert Copied Cells…" => "Coller en insérant",
"Insert Cut Cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
"Insert Paste" => "Taille de l’interface",
"Insert Copied Cells" => "Insérer les cellules copiées",
"Insert Copied Cells…" => "Insérer les cellules copiées…",
"Insert Cut Cells" => "Insérer les cellules coupées",
"Insert Paste" => "Insérer en collant",
"Cut (⌘X)" => "Couper (⌘X)",
"Copy (⌘C)" => "Copier (⌘C)",
"Format Painter (double-click to keep it on)" => "Reproduire la mise en forme (double-clic pour la conserver)",
"Bold (⌘B)" => "Gras (⌘B)",
"Italic (⌘I)" => "Italique (⌘I)",
"Underline (⌘U)" => "Souligné (⌘U)",
"Double Underline" => "Double souligné",
"Double Underline" => "Double soulignement",
"Strikethrough" => "Barré",
"Underline" => "Souligné",
"Increase Font Size" => "Augmenter la taille de la police",
"Decrease Font Size" => "Réduire la taille de la police",
"Wrap Text" => "Renvoyer à la ligne",
"Increase Font Size" => "Augmenter la taille de police",
"Decrease Font Size" => "Réduire la taille de police",
"Wrap Text" => "Renvoyer à la ligne automatiquement",
"Merge & Center" => "Fusionner et centrer",
"Merge Cells" => "Fusionner les cellules",
"Merge Across" => "Fusionner horizontalement",
"Unmerge Cells" => "Dissocier les cellules",
"Unmerge Cells" => "Annuler la fusion des cellules",
"Fill Color" => "Couleur de remplissage",
"Font Color" => "Couleur de police",
"Borders" => "Bordures",
"Top\nAlign" => "Aligner en haut",
"Middle\nAlign" => "Aligner au centre",
"Bottom\nAlign" => "Aligner en bas",
"Top\nAlign" => "Aligner en\nhaut",
"Middle\nAlign" => "Aligner au\nmilieu",
"Bottom\nAlign" => "Aligner en\nbas",
"Left" => "Gauche",
"Right" => "Droite",
"Center" => "Centré",
@@ -52,17 +52,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Rotate Text Down" => "Pivoter le texte vers le bas",
"Accounting Number Format" => "Format de nombre Comptabilité",
"Percent Style" => "Style de pourcentage",
"Comma Style" => "Style de virgule",
"Comma Style" => "Style de milliers",
"Increase Decimal" => "Ajouter une décimale",
"Decrease Decimal" => "Retirer une décimale",
"More Accounting Formats…" => "Autres formats comptables…",
"Conditional\nFormatting" => "Mise en forme conditionnelle",
"Conditional\nFormatting" => "Mise en forme\nconditionnelle",
"Conditional Formatting" => "Mise en forme conditionnelle",
"Format\nas Table" => "Mettre sous forme de tableau",
"Cell\nStyles" => "Styles de cellules",
"Format\nas Table" => "Mettre sous forme\nde tableau",
"Cell\nStyles" => "Styles de\ncellules",
"Format Cells…" => "Format de cellules…",
"Format Cell Alignment…" => "Format d’alignement de cellule…",
"Format\nPane" => "Volet Format",
"Format Cell Alignment…" => "Format d'alignement de cellule…",
"Format\nPane" => "Volet\nFormat",
"Format" => "Format",
"Insert" => "Insertion",
"Insert Cells…" => "Insérer des cellules…",
@@ -90,58 +90,64 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Hyperlinks" => "Effacer les liens hypertexte",
"AutoSum" => "Somme automatique",
"AutoSum (⌘⇧T)" => "Somme automatique (⌘⇧T)",
"Flash\nFill" => "Remplissage instantané",
"Flash\nFill" => "Remplissage\ninstantané",
"Flash Fill" => "Remplissage instantané",
"Fill" => "Remplir",
"Clear Print Area" => "Effacer la zone d’impression",
"Find &\nSelect" => "Rechercher et sélectionner",
"Clear Print Area" => "Effacer la zone d'impression",
"Find &\nSelect" => "Rechercher et\nsélectionner",
"Find…" => "Rechercher…",
"Replace…" => "Remplacer…",
"Go To…" => "Atteindre…",
"Go To Special…" => "Atteindre spécial…",
"PivotTable" => "Tableau croisé dynamique",
"Recommended\nCharts" => "Graphiques recommandés",
"Recommended\nCharts" => "Graphiques\nrecommandés",
"Table" => "Tableau",
"Pictures" => "Images",
"Shapes" => "Formes",
"Icons" => "Icônes",
"Text\nBox" => "Zone de texte",
"Text\nBox" => "Zone de\ntexte",
"Comment" => "Commentaire",
"New\nComment" => "Nouveau commentaire",
"New\nComment" => "Nouveau\ncommentaire",
"New Note" => "Nouvelle note",
"Show/Hide Note" => "Afficher/masquer la note",
"Header &\nFooter" => "En-tête et pied de page",
"Text to\nColumns" => "Convertir",
"Header &\nFooter" => "En-tête et\npied de page",
"Text to\nColumns" => "Convertir le texte\nen colonnes",
"Link" => "Lien",
"Symbol" => "Symbole",
"Sparklines" => "Sparklines",
"Checkbox" => "Cliquez pour ajouter des données",
"Insert checkboxes in the selected cells" => "Mots-clés",
"Sparklines" => "Courbes de tendance",
"Checkbox" => "Case à cocher",
"Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
"Header & Footer" => "En-tête et pied de page",
"Draw with ink" => "Dessiner avec l’encre",
"Draw with ink" => "Dessiner à l'encre",
"Eraser" => "Gomme",
"Ink to\nShape" => "Encre vers forme",
"Select\nObjects" => "Sélection d’objets",
"Ink to\nShape" => "Encre en\nforme",
"Select\nObjects" => "Sélectionner les\nobjets",
"Themes" => "Thèmes",
"Margins" => "Marges",
"Left Margin" => "Marge gauche",
"Right Margin" => "Marge droite",
"Top Margin" => "Marge haute",
"Bottom Margin" => "Marge basse",
"Header Margin" => "Marge de l'en-tête",
"Footer Margin" => "Marge du pied de page",
"Size" => "Taille",
"Print\nArea" => "Zone d’impression",
"Set Print Area" => "Définir la zone d’impression",
"Print\nArea" => "Zone\nd'impression",
"Set Print Area" => "Définir la zone d'impression",
"Breaks" => "Sauts",
"Insert Page Break" => "Insérer un saut de page",
"Remove Page Break" => "Supprimer le saut de page",
"Reset All Page Breaks" => "Réinitialiser tous les sauts de page",
"Print\nTitles" => "Titres à imprimer",
"Sheet\nRight-to-Left" => "Afficher la barre de formule",
"Sheet Right-to-Left" => "Feuilles",
"Narrow" => "Étroites",
"Wide" => "Larges",
"Normal" => "Normales",
"Print\nTitles" => "Titres à\nimprimer",
"Sheet\nRight-to-Left" => "Feuille de\ndroite à gauche",
"Sheet Right-to-Left" => "Feuille de droite à gauche",
"Narrow" => "Étroit",
"Wide" => "Large",
"Normal" => "Normal",
"Portrait" => "Portrait",
"Landscape" => "Paysage",
"Custom Margins…" => "Marges personnalisées…",
"Insert\nFunction" => "Insérer une fonction",
"Name\nManager" => "Gestionnaire de noms",
"Insert\nFunction" => "Insérer une\nfonction",
"Name\nManager" => "Gestionnaire\nde noms",
"Define Name" => "Définir un nom",
"Use in Formula" => "Utiliser dans la formule",
"Create from Selection" => "Créer à partir de la sélection",
@@ -152,90 +158,92 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Error Checking" => "Vérification des erreurs",
"Evaluate Formula" => "Évaluation de formule",
"Watch Window" => "Fenêtre Espion",
"Calculation\nOptions" => "Options de calcul",
"Calculation\nOptions" => "Options de\ncalcul",
"Calculate Now" => "Calculer maintenant",
"Calculate Sheet" => "Calculer la feuille",
"Automatic" => "Automatique",
"Automatic Except for Data Tables" => "Automatique sauf pour les tables de données",
"Manual" => "Manuel",
"Sort &\nFilter" => "Trier et filtrer",
"Sort &\nFilter" => "Trier et\nfiltrer",
"Sort" => "Trier",
"Sort A to Z" => "Trier de A à Z",
"Sort Z to A" => "Trier de Z à A",
"Custom Sort…" => "Tri personnalisé…",
"Filter" => "Filtrer",
"Reapply" => "Réappliquer",
"Get Data\n(Text/CSV)" => "Obtenir des données (Texte/CSV)",
"Remove\nDuplicates" => "Supprimer les doublons",
"Data\nValidation" => "Validation des données",
"Get Data\n(Text/CSV)" => "Obtenir des données\n(Texte/CSV)",
"Remove\nDuplicates" => "Supprimer les\ndoublons",
"Data\nValidation" => "Validation des\ndonnées",
"Data Validation" => "Validation des données",
"Data Validation…" => "Validation des données…",
"Circle Invalid Data" => "Cerner les données non valides",
"Clear Validation" => "Effacer la validation",
"Group" => "Grouper",
"Ungroup" => "Dissocier",
"Show Detail" => "Titre",
"Hide Detail" => "Insérer les cellules copiées",
"Show Detail" => "Afficher le détail",
"Hide Detail" => "Masquer le détail",
"Subtotal" => "Sous-total",
"Data\nAnalysis" => "Description",
"Data Analysis" => "Analyse\nde données",
"Solver" => "Page web (.html)",
"What-If\nAnalysis" => "Analyse scénarios",
"Data\nAnalysis" => "Analyse de\ndonnées",
"Data Analysis" => "Analyse de données",
"Solver" => "Solveur",
"What-If\nAnalysis" => "Analyse\nde scénarios",
"Data Table…" => "Table de données…",
"Scenario Manager…" => "Gestionnaire de scénarios…",
"Goal Seek…" => "Valeur cible…",
"Forecast\nSheet" => "Aller au pied de page",
"Forecast\nSheet" => "Feuille de\nprévision",
"Spelling" => "Orthographe",
"Check\nAccessibility" => "Vérifier l’accessibilité",
"Threaded Comments" => "Commentaires threadés",
"Show\nComments" => "Afficher les commentaires",
"Previous" => "Imprimer les feuilles actives",
"Next" => "Aucun classeur récent pour le moment.",
"Previous Comment" => "Imprimer tout le classeur",
"Next Comment" => "Aucun classeur n’est ouvert.",
"Show All Notes" => "Objet",
"Convert to Comments" => "Créer un PDF",
"Protect\nWorkbook" => "Protéger le classeur",
"Allow Edit\nRanges" => "Animations",
"Allow Edit Ranges" => "Autoriser la\nmodification des plages",
"Unprotect\nSheet" => "Ôter la protection de la feuille",
"Check\nAccessibility" => "Vérifier\nl'accessibilité",
"Threaded Comments" => "Commentaires en fil de discussion",
"Show\nComments" => "Afficher les\ncommentaires",
"Previous" => "Précédent",
"Next" => "Suivant",
"Previous Comment" => "Commentaire précédent",
"Next Comment" => "Commentaire suivant",
"Show All Notes" => "Afficher toutes les notes",
"Convert to Comments" => "Convertir en commentaires",
"Protect\nWorkbook" => "Protéger le\nclasseur",
"Allow Edit\nRanges" => "Autoriser la modification\ndes plages",
"Allow Edit Ranges" => "Autoriser la modification des plages",
"Unprotect\nSheet" => "Ôter la protection\nde la feuille",
"Protect Sheet…" => "Protéger la feuille…",
"Comments" => "Commentaires",
"Notes" => "Notes",
"Page Break\nPreview" => "Aperçu des sauts de page",
"Page\nLayout" => "Mise en page",
"Page Break\nPreview" => "Aperçu des sauts\nde page",
"Page\nLayout" => "Mise en\npage",
"Formula Bar" => "Barre de formule",
"Gridlines" => "Quadrillage",
"Headings" => "En-têtes",
"Zoom" => "Zoom",
"100%" => "100%",
"Zoom to\nSelection" => "Zoom sur la sélection",
"Freeze\nPanes" => "Figer les volets",
"100%" => "100 %",
"Zoom to\nSelection" => "Zoom sur la\nsélection",
"Freeze\nPanes" => "Figer les\nvolets",
"Freeze Panes" => "Figer les volets",
"Freeze Top Row" => "Figer la ligne supérieure",
"Freeze First Column" => "Figer la première colonne",
"Unfreeze Panes" => "Libérer les volets",
"Dark Mode" => "Mode sombre",
"Display theme" => "Thème d’affichage",
"Animations" => "Auteur",
"Glide the selection outline to the cell you move to instead of jumping there" => "Aller à l’en-tête",
"Display theme" => "Thème d'affichage",
"Animations" => "Animations",
"Glide the selection outline to the cell you move to instead of jumping there" => {
"Faire glisser le contour de la sélection jusqu'à la cellule de destination au lieu d'y sauter directement"
}
"System" => "Système",
"Light" => "Clair",
"Dark" => "Sombre",
"Interface language" => "Langue de l’interface",
"Interface size" => "Emplacement",
"Auto" => "Retour",
"Drag to freeze columns" => "Faites glisser pour figer les lignes",
"Drag to freeze rows" => "Activer le calcul itératif",
"Command\nPalette" => "Palette de commandes",
"Agent\nControl" => "Contrôle de l’agent",
"Action\nJournal" => "Journal des actions",
"About\nGridCraft" => "À propos de GridCraft",
"Interface language" => "Langue de l'interface",
"Interface size" => "Taille de l'interface",
"Auto" => "Automatique",
"Drag to freeze columns" => "Faites glisser pour figer les colonnes",
"Drag to freeze rows" => "Faites glisser pour figer les lignes",
"Command\nPalette" => "Palette de\ncommandes",
"Agent\nControl" => "Contrôle de\nl'agent",
"Action\nJournal" => "Journal des\nactions",
"About\nGridCraft" => "À propos de\nGridCraft",
"Share" => "Partager",
"Add Chart\nElement" => "Ajouter un élément de graphique",
"Change\nChart Type" => "Modifier le type de graphique",
"Switch\nRow/Column" => "Permuter ligne/colonne",
"Select\nData" => "Nom de la\nfeuille",
"Add Chart\nElement" => "Ajouter un élément\nde graphique",
"Change\nChart Type" => "Modifier le type\nde graphique",
"Switch\nRow/Column" => "Permuter\nligne/colonne",
"Select\nData" => "Sélectionner les\ndonnées",
"Data Labels: None" => "Étiquettes de données : Aucune",
"Data Labels: Show" => "Étiquettes de données : Afficher",
"Legend: None" => "Légende : Aucune",
@@ -244,10 +252,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Legend: Right" => "Légende : Droite",
"Gridlines: None" => "Quadrillage : Aucun",
"Gridlines: Show" => "Quadrillage : Afficher",
"Chart\nTitle" => "Titre du graphique",
"Delete\nChart" => "Supprimer le graphique",
"Convert\nto Range" => "Convertir en plage",
"Table\nStyles" => "Styles de tableau",
"Chart\nTitle" => "Titre du\ngraphique",
"Delete\nChart" => "Supprimer le\ngraphique",
"Convert\nto Range" => "Convertir\nen plage",
"Table\nStyles" => "Styles de\ntableau",
"Top 10 Items" => "10 premiers éléments",
"Top 10%" => "10 % les plus élevés",
"Bottom 10 Items" => "10 derniers éléments",
@@ -259,7 +267,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Constants" => "Constantes",
"Data Bars" => "Barres de données",
"Color Scales" => "Échelles de couleurs",
"Icon Sets" => "Jeux d’icônes",
"Icon Sets" => "Jeux d'icônes",
"Home" => "Accueil",
"Draw" => "Dessin",
"Page Layout" => "Mise en page",
@@ -293,96 +301,115 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"More Number Formats…" => "Autres formats numériques…",
"Financial" => "Financières",
"Logical" => "Logiques",
"Date &\nTime" => "Date et heure",
"Lookup &\nReference" => "Recherche et référence",
"Math &\nTrig" => "Maths et trigonométrie",
"More\nFunctions" => "Autres fonctions",
"Sheet Options|View" => "Affichage",
"Date &\nTime" => "Date et\nheure",
"Lookup &\nReference" => "Recherche et\nréférence",
"Math &\nTrig" => "Maths et\ntrigonométrie",
"More\nFunctions" => "Autres\nfonctions",
"Sheet Options|View" => "Options de la feuille|Affichage",
"Print" => "Impression",
"Click to add data" => "Fermer",
"Add header" => "Autoriser la modification des plages",
"Add footer" => "Ajouter un en-tête",
"Click to add data" => "Cliquez pour ajouter des données",
"Add header" => "Ajouter un en-tête",
"Add footer" => "Ajouter un pied de page",
"(none)" => "(aucun)",
"Header" => "Informations",
"Footer" => "Feuille de\nprévision",
"Page\nNumber" => "Page précédente",
"Number\nof Pages" => "Général",
"Current\nDate" => "Heure\nactuelle",
"Current\nTime" => "Analyse de données",
"File\nPath" => "Ajuster la feuille sur une seule page",
"File\nName" => "Chemin\ndu fichier",
"Sheet\nName" => "Afficher le détail",
"Go to\nHeader" => "Masquer le détail",
"Go to\nFooter" => "En-tête",
"Page Number" => "Numéro\nde page",
"Number of Pages" => "Options",
"Current Date" => "Heure actuelle",
"Current Time" => "Date\nactuelle",
"File Path" => "Nom du\nfichier",
"File Name" => "Chemin du fichier",
"Sheet Name" => "Feuille de\ndroite à gauche",
"Go to Header" => "Aller à\nl’en-tête",
"Go to Footer" => "Aller au\npied de page",
"Ruler" => "Enregistrer sous",
"File" => "Nom du fichier",
"Back" => "Classeur vide",
"New" => "Page suivante",
"Open" => "Enregistrement",
"Info" => "Insérer les cellules copiées…",
"Save" => "Type de fichier",
"Save As" => "Enregistrer la feuille sous forme de document PDF en conservant sa mise en page.",
"Export" => "Fichier",
"Close" => "Société",
"Options" => "PDF (*.pdf)",
"Blank workbook" => "Parcourir…",
"Recent" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"No recent workbooks yet." => "Rien à imprimer.",
"More workbooks" => "Commentaire suivant",
"Browse…" => "CSV (séparé par des virgules)",
"Not saved yet" => "Nombre\nde pages",
"Location" => "Nouveau",
"Sheets" => "Solveur",
"Protect Workbook" => "Enregistrer",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "Afficher toutes les notes",
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "Suivant",
"Properties" => "Règle",
"Header" => "En-tête",
"Footer" => "Pied de page",
"Page\nNumber" => "Numéro de\npage",
"Number\nof Pages" => "Nombre\nde pages",
"Current\nDate" => "Date\nactuelle",
"Current\nTime" => "Heure\nactuelle",
"File\nPath" => "Chemin\ndu fichier",
"File\nName" => "Nom du\nfichier",
"Sheet\nName" => "Nom de la\nfeuille",
"Go to\nHeader" => "Aller à\nl'en-tête",
"Go to\nFooter" => "Aller au\npied de page",
"Page Number" => "Numéro de page",
"Number of Pages" => "Nombre de pages",
"Current Date" => "Date actuelle",
"Current Time" => "Heure actuelle",
"File Path" => "Chemin du fichier",
"File Name" => "Nom du fichier",
"Sheet Name" => "Nom de la feuille",
"Go to Header" => "Aller à l'en-tête",
"Go to Footer" => "Aller au pied de page",
"Ruler" => "Règle",
"File" => "Fichier",
"Back" => "Retour",
"New" => "Nouveau",
"Open" => "Ouvrir",
"Info" => "Informations",
"Save" => "Enregistrer",
"Save As" => "Enregistrer sous",
"Export" => "Exporter",
"Close" => "Fermer",
"Options" => "Options",
"Blank workbook" => "Classeur vide",
"Recent" => "Récent",
"No recent workbooks yet." => "Aucun classeur récent pour le moment.",
"More workbooks" => "Plus de classeurs",
"Modified" => "Modifié",
"Not available" => "Indisponible",
"Just now" => "À l'instant",
"Yesterday" => "Hier",
"{n} min ago" => "il y a {n} min",
"{n} h ago" => "il y a {n} h",
"{n} days ago" => "il y a {n} jours",
"{n} weeks ago" => "il y a {n} semaines",
"{n} months ago" => "il y a {n} mois",
"{n} years ago" => "il y a {n} ans",
"Browse…" => "Parcourir…",
"Not saved yet" => "Pas encore enregistré",
"Location" => "Emplacement",
"Sheets" => "Feuilles",
"Protect Workbook" => "Protéger le classeur",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"La structure de la feuille est verrouillée : les feuilles ne peuvent pas être ajoutées, déplacées, renommées ou supprimées."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Verrouiller la structure des feuilles pour qu'elles ne puissent pas être ajoutées, déplacées, renommées ou supprimées."
}
"Properties" => "Propriétés",
"Title" => "Titre",
"Subject" => "Calcul du classeur",
"Author" => "Automatique",
"Company" => "Convertir en commentaires",
"Keywords" => "Plus de classeurs",
"Description" => "Télécharger",
"No workbook is open." => "Nombre de pages",
"Save as type" => "Enregistrer le classeur sous forme de page web.",
"Excel Workbook (*.xlsx)" => "Exporter",
"CSV UTF-8 (Comma delimited) (*.csv)" => "Les paramètres de calcul sont enregistrés avec le classeur.",
"Subject" => "Sujet",
"Author" => "Auteur",
"Company" => "Société",
"Keywords" => "Mots-clés",
"Description" => "Description",
"No workbook is open." => "Aucun classeur n'est ouvert.",
"Save as type" => "Type de fichier",
"Excel Workbook (*.xlsx)" => "Classeur Excel (*.xlsx)",
"CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (séparateur point-virgule) (*.csv)",
"Web Page (*.html)" => "Page web (*.html)",
"PDF (*.pdf)" => "Page {page} sur {pages}",
"Download" => "Faites glisser pour figer les colonnes",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => "Ajouter un pied de page",
"Save the workbook under another name or in another folder." => "Feuille de droite à gauche",
"Print Active Sheets" => "Protéger le classeur",
"Print Entire Workbook" => "Récents",
"Fit Sheet on One Page" => "Pied de page",
"Page Setup…" => "Précédent",
"Page {page} of {pages}" => "Commentaire précédent",
"Nothing to print." => "Ouvrir",
"Previous page" => "Propriétés",
"Next page" => "Pas encore enregistré",
"Create PDF" => "Date actuelle",
"Save the sheet as a PDF document that keeps its page layout." => "Sélectionner\nles données",
"Change File Type" => "Case à cocher",
"CSV (Comma delimited)" => "CSV UTF-8 (séparé par des virgules) (*.csv)",
"Save the active sheet as comma-separated text." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.",
"PDF (*.pdf)" => "PDF (*.pdf)",
"Download" => "Télécharger",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Un fichier CSV, une page web ou un PDF est une copie : le classeur conserve son propre fichier."
}
"Save the workbook under another name or in another folder." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.",
"Print Active Sheets" => "Imprimer les feuilles actives",
"Print Entire Workbook" => "Imprimer tout le classeur",
"Fit Sheet on One Page" => "Ajuster la feuille sur une page",
"Page Setup…" => "Mise en page…",
"Page {page} of {pages}" => "Page {page} sur {pages}",
"Nothing to print." => "Rien à imprimer.",
"Previous page" => "Page précédente",
"Next page" => "Page suivante",
"Create PDF" => "Créer un PDF",
"Save the sheet as a PDF document that keeps its page layout." => {
"Enregistrer la feuille en tant que document PDF qui conserve sa mise en page."
}
"Change File Type" => "Changer le type de fichier",
"CSV (Comma delimited)" => "CSV (séparateur point-virgule)",
"Save the active sheet as comma-separated text." => "Enregistrer la feuille active sous forme de texte séparé par des points-virgules.",
"Web Page (.html)" => "Page web (.html)",
"Save the workbook as a web page." => "Nom de la feuille",
"Options|General" => "Numéro de page",
"Options|Save" => "Mise en page…",
"Show formula bar" => "Page web (*.html)",
"Save .xlsx workbooks automatically after every change" => "Enregistrer la feuille active sous forme de texte séparé par des virgules.",
"Save the workbook as a web page." => "Enregistrer le classeur en tant que page web.",
"Options|General" => "Options|Général",
"Options|Save" => "Options|Enregistrer",
"Show formula bar" => "Afficher la barre de formule",
"Enable AutoComplete for cell values" => "Activer la saisie semi-automatique des valeurs de cellule",
"Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"Workbook calculation" => "Calcul du classeur",
"Enable iterative calculation" => "Classeur Excel (*.xlsx)",
"Calculation settings are saved with the workbook." => "Modifier le type de fichier",
"Enable iterative calculation" => "Activer le calcul itératif",
"Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.",
_ => return None,
})
}
+17
View File
@@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "개체\n선택",
"Themes" => "테마",
"Margins" => "여백",
"Left Margin" => "왼쪽 여백",
"Right Margin" => "오른쪽 여백",
"Top Margin" => "위쪽 여백",
"Bottom Margin" => "아래쪽 여백",
"Header Margin" => "머리글 여백",
"Footer Margin" => "바닥글 여백",
"Size" => "용지 크기",
"Print\nArea" => "인쇄\n영역",
"Set Print Area" => "인쇄 영역 설정",
@@ -342,6 +348,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Recent" => "최근 항목",
"No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.",
"More workbooks" => "더 많은 통합 문서",
"Modified" => "수정한 날짜",
"Not available" => "사용할 수 없음",
"Just now" => "방금",
"Yesterday" => "어제",
"{n} min ago" => "{n}분 전",
"{n} h ago" => "{n}시간 전",
"{n} days ago" => "{n}일 전",
"{n} weeks ago" => "{n}주 전",
"{n} months ago" => "{n}개월 전",
"{n} years ago" => "{n}년 전",
"Browse…" => "찾아보기…",
"Not saved yet" => "아직 저장되지 않음",
"Location" => "위치",
@@ -389,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "일반",
"Options|Save" => "저장",
"Show formula bar" => "수식 입력줄 표시",
"Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용",
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
"Workbook calculation" => "통합 문서 계산",
"Enable iterative calculation" => "반복 계산 사용",
+562
View File
@@ -122,6 +122,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Selecionar\nObjetos",
"Themes" => "Temas",
"Margins" => "Margens",
"Left Margin" => "Margem esquerda",
"Right Margin" => "Margem direita",
"Top Margin" => "Margem superior",
"Bottom Margin" => "Margem inferior",
"Header Margin" => "Margem do cabeçalho",
"Footer Margin" => "Margem do rodapé",
"Size" => "Tamanho",
"Print\nArea" => "Área de\nImpressão",
"Set Print Area" => "Definir Área de Impressão",
@@ -343,6 +349,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Recent" => "Recentes",
"No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.",
"More workbooks" => "Mais pastas de trabalho",
"Modified" => "Modificado",
"Not available" => "Indisponível",
"Just now" => "Agora mesmo",
"{n} min ago" => "há {n} min",
"{n} h ago" => "há {n} h",
"{n} days ago" => "há {n} dias",
"{n} weeks ago" => "há {n} semanas",
"{n} months ago" => "há {n} meses",
"{n} years ago" => "há {n} anos",
"Browse…" => "Procurar…",
"Not saved yet" => "Ainda não salvo",
"Location" => "Local",
@@ -390,10 +405,557 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Geral",
"Options|Save" => "Salvar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Enable AutoComplete for cell values" => "Habilitar AutoConclusão para valores de célula",
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
"Workbook calculation" => "Cálculo da pasta de trabalho",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
"Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.",
"OK" => "OK",
"Cancel" => "Cancelar",
"Don't Save" => "Não Salvar",
"Open…" => "Abrir…",
"Show password" => "Mostrar senha",
"Start a new workbook" => "Criar uma pasta de trabalho",
"Household Budget" => "Orçamento Doméstico",
"Quarterly Sales Dashboard" => "Painel de Vendas Trimestrais",
"Gradebook" => "Boletim de Notas",
"this workbook" => "esta pasta de trabalho",
"This workbook" => "Esta pasta de trabalho",
"Do you want to save the changes you made to {title}?" => "Deseja salvar as alterações feitas em {title}?",
"Save a copy as .{format}" => "Salvar uma cópia como .{format}",
"Select a chart first." => "Selecione um gráfico primeiro.",
"Text Box" => "Caixa de Texto",
"Select Data" => "Selecionar Dados",
"Row Height" => "Altura da Linha",
"Column Width" => "Largura da Coluna",
"Standard Width" => "Largura Padrão",
"Rename Sheet" => "Renomear Planilha",
"Move or Copy" => "Mover ou Copiar",
"Protect Sheet" => "Proteger Planilha",
"Protect Structure and Windows" => "Proteger Estrutura e Janelas",
"Unprotect Workbook" => "Desproteger Pasta de Trabalho",
"Unprotect Sheet" => "Desproteger Planilha",
"Password" => "Senha",
"Unhide" => "Reexibir",
"Paste Special" => "Colar Especial",
"Series" => "Série",
"New Comment" => "Novo Comentário",
"Insert Hyperlink" => "Inserir Hiperlink",
"Create Sparklines" => "Criar Minigráficos",
"Page Setup" => "Configurar Página",
"Convert Text to Columns" => "Converter Texto em Colunas",
"Remove Duplicates" => "Remover Duplicatas",
"Goal Seek" => "Atingir Meta",
"Create Forecast Worksheet" => "Criar Planilha de Previsão",
"Solver Parameters" => "Parâmetros do Solver",
"Solver Results" => "Resultados do Solver",
"Edit Name" => "Editar Nome",
"New Name" => "Novo Nome",
"Format Cells" => "Formatar Células",
"Formula Builder" => "Construtor de Fórmulas",
"Find and Replace" => "Localizar e Substituir",
"Go To" => "Ir para",
"Insert Calculated Field" => "Inserir Campo Calculado",
"Go To Special" => "Ir para Especial",
"Name Manager" => "Gerenciador de Nomes",
"Conditional Formatting Rules Manager" => "Gerenciador de Regras de Formatação Condicional",
"Search Commands" => "Pesquisar Comandos",
"Agent Control" => "Controle por Agentes",
"About GridCraft" => "Sobre o GridCraft",
"Insert Chart" => "Inserir Gráfico",
"Select Data Source" => "Selecionar Fonte de Dados",
"Action Journal" => "Registro de Ações",
"Macros" => "Macros",
"Workbook Statistics" => "Estatísticas da Pasta de Trabalho",
"Accessibility" => "Acessibilidade",
"Chart Title" => "Título do Gráfico",
"Place Picture in Cell" => "Colocar Imagem na Célula",
"Place Picture over Cells" => "Colocar Imagem sobre as Células",
"Pick From List" => "Escolher na Lista",
"Save a Copy" => "Salvar uma Cópia",
"Save Changes?" => "Salvar Alterações?",
"Shift cells right" => "Deslocar células para a direita",
"Shift cells down" => "Deslocar células para baixo",
"Shift cells left" => "Deslocar células para a esquerda",
"Shift cells up" => "Deslocar células para cima",
"Entire row" => "Linha inteira",
"Entire column" => "Coluna inteira",
"Row height (pixels):" => "Altura da linha (pixels):",
"Column width (characters):" => "Largura da coluna (caracteres):",
"Standard column width (pixels):" => "Largura padrão da coluna (pixels):",
"Name:" => "Nome:",
"Before sheet (position, 0 = first):" => "Antes da planilha (posição, 0 = primeira):",
"Create a copy" => "Criar uma cópia",
"Sheets: {sheets}" => "Planilhas: {sheets}",
"Password (optional):" => "Senha (opcional):",
"Password:" => "Senha:",
"Allow all users of this sheet to:" => "Permitir que todos os usuários desta planilha possam:",
"Format cells" => "Formatar células",
"Format columns" => "Formatar colunas",
"Format rows" => "Formatar linhas",
"Insert rows" => "Inserir linhas",
"Delete rows" => "Excluir linhas",
"Use AutoFilter" => "Usar AutoFiltro",
" (password, unlocked)" => " (senha, desbloqueado)",
" (password)" => " (senha)",
"No ranges yet." => "Nenhum intervalo ainda.",
"Ranges that stay editable when the sheet is protected:" => "Intervalos que continuam editáveis quando a planilha está protegida:",
"Action" => "Ação",
"Modify" => "Modificar",
"Unlock with password" => "Desbloquear com senha",
"Title:" => "Título:",
"Refers to cells:" => "Refere-se às células:",
"Range password:" => "Senha do intervalo:",
"New title (Modify):" => "Novo título (Modificar):",
"Remove the password (Modify)" => "Remover a senha (Modificar)",
"'{file}' is protected." => "'{file}' está protegido.",
"Unhide sheet:" => "Reexibir planilha:",
"Hidden: {sheets}" => "Ocultas: {sheets}",
"All" => "Tudo",
"Formulas (F)" => "Fórmulas (F)",
"Values (V)" => "Valores (V)",
"Formats (T)" => "Formatos (T)",
"Comments and notes (C)" => "Comentários e notas (C)",
"Validation" => "Validação",
"All except borders" => "Tudo, exceto bordas",
"Column widths" => "Larguras da coluna",
"Formulas and number formats" => "Fórmulas e formatos de número",
"Values and number formats" => "Valores e formatos de número",
"Operation" => "Operação",
"Add" => "Adicionar",
"Subtract" => "Subtrair",
"Multiply" => "Multiplicar",
"Divide" => "Dividir",
"Skip blanks" => "Ignorar em branco",
"Transpose (E)" => "Transpor (E)",
"Series in" => "Série em",
"Columns" => "Colunas",
"Rows" => "Linhas",
"Type" => "Tipo",
"Linear" => "Linear",
"Growth" => "Crescimento",
"Date unit" => "Unidade de data",
"Day" => "Dia",
"Weekday" => "Dia da semana",
"Month" => "Mês",
"Year" => "Ano",
"Step value:" => "Incremento:",
"Stop value:" => "Limite:",
"Start a conversation:" => "Iniciar uma conversa:",
"Note:" => "Nota:",
"Display:" => "Texto para exibição:",
"Link to (web address, e-mail or Sheet!A1):" => "Vincular a (endereço da Web, e-mail ou Planilha!A1):",
"ScreenTip:" => "Dica de tela:",
"Data Range:" => "Intervalo de dados:",
"Location Range:" => "Intervalo de local:",
"Markers" => "Marcadores",
"Footer:" => "Rodapé:",
"Left margin (in):" => "Margem esquerda (pol.):",
"Right margin (in):" => "Margem direita (pol.):",
"Top margin (in):" => "Margem superior (pol.):",
"Bottom margin (in):" => "Margem inferior (pol.):",
"Header (in):" => "Cabeçalho (pol.):",
"Footer (in):" => "Rodapé (pol.):",
"Tab" => "Tabulação",
"Semicolon" => "Ponto e vírgula",
"Comma" => "Vírgula",
"Space" => "Espaço",
"Other:" => "Outros:",
"Treat consecutive delimiters as one" => "Considerar delimitadores consecutivos como um só",
"Destination:" => "Destino:",
"My data has headers" => "Meus dados contêm cabeçalhos",
"Columns (blank = all, e.g. A,C):" => "Colunas (em branco = todas, p. ex. A,C):",
"Timeline range (blank: first column of the selection):" => "Intervalo da linha do tempo (em branco: primeira coluna da seleção):",
"Values range (blank: second column of the selection):" => "Intervalo de valores (em branco: segunda coluna da seleção):",
"Confidence interval" => "Intervalo de confiança",
"Confidence level (%):" => "Nível de confiança (%):",
"Seasonality (auto, 0 for none, or a season length):" => "Sazonalidade (automática, 0 para nenhuma ou a duração de um período):",
"Fill missing points using" => "Preencher pontos ausentes usando",
"Interpolation" => "Interpolação",
"Zeros" => "Zeros",
"Aggregate duplicates using" => "Agregar duplicatas usando",
"Average" => "Média",
"Count" => "Contagem",
"CountA" => "Cont.Valores",
"Max" => "Máx",
"Median" => "Mediana",
"Min" => "Mín",
"Sum" => "Soma",
"Product" => "Produto",
"Include forecast statistics" => "Incluir estatísticas de previsão",
"Chart" => "Gráfico",
"Line chart" => "Gráfico de linhas",
"Column chart" => "Gráfico de colunas",
"At each change in (column letter):" => "A cada alteração em (letra da coluna):",
"Use function" => "Usar função",
"Add subtotal to (columns, e.g. C,D):" => "Adicionar subtotal a (colunas, p. ex. C,D):",
"Magnification" => "Ampliação",
"Custom (%):" => "Personalizado (%):",
"Scope (Workbook or sheet name):" => "Escopo (Pasta de Trabalho ou nome da planilha):",
"Comment:" => "Comentário:",
"Refers to:" => "Refere-se a:",
"Allow" => "Permitir",
"Any value" => "Qualquer valor",
"Whole number" => "Número inteiro",
"Decimal" => "Decimal",
"List" => "Lista",
"Text length" => "Comprimento do texto",
"between" => "está entre",
"not between" => "não está entre",
"equal to" => "é igual a",
"not equal to" => "é diferente de",
"greater than" => "é maior do que",
"less than" => "é menor do que",
"greater than or equal to" => "é maior ou igual a",
"less than or equal to" => "é menor ou igual a",
"Minimum / Source / Formula:" => "Mínimo / Fonte / Fórmula:",
"Maximum:" => "Máximo:",
"Ignore blank" => "Ignorar em branco",
"In-cell dropdown" => "Menu suspenso na célula",
"Input message:" => "Mensagem de entrada:",
"Error style" => "Estilo de erro",
"Stop" => "Parar",
"Warning" => "Aviso",
"Information" => "Informações",
"Error message:" => "Mensagem de erro:",
"Format cells that are (value):" => "Formatar células que são (valor):",
"and:" => "e:",
"Format cells that contain the text:" => "Formatar células que contêm o texto:",
"Format cells that contain a date occurring:" => "Formatar células que contêm uma data:",
"Yesterday" => "Ontem",
"Today" => "Hoje",
"Tomorrow" => "Amanhã",
"In the last 7 days" => "Nos últimos 7 dias",
"Last week" => "Semana passada",
"This week" => "Esta semana",
"Next week" => "Próxima semana",
"Last month" => "Mês passado",
"This month" => "Este mês",
"Next month" => "Próximo mês",
"Format values where this formula is true:" => "Formatar valores em que esta fórmula é verdadeira:",
"with" => "com",
"Light Red Fill with Dark Red Text" => "Preenchimento Vermelho Claro e Texto Vermelho Escuro",
"Yellow Fill with Dark Yellow Text" => "Preenchimento Amarelo e Texto Amarelo Escuro",
"Green Fill with Dark Green Text" => "Preenchimento Verde e Texto Verde Escuro",
"Light Red Fill" => "Preenchimento Vermelho Claro",
"Red Text" => "Texto Vermelho",
"Red Border" => "Borda Vermelha",
"Formula:" => "Fórmula:",
"Select" => "Selecionar",
"Blanks" => "Células em branco",
"Errors" => "Erros",
"Numbers" => "Números",
"Current region" => "Região atual",
"Last cell" => "Última célula",
"Visible cells only" => "Somente células visíveis",
"Conditional formats" => "Formatos condicionais",
"Data validation" => "Validação de dados",
"Horizontal axis title:" => "Título do eixo horizontal:",
"Vertical axis title:" => "Título do eixo vertical:",
"No picture selected" => "Nenhuma imagem selecionada",
"Picture file path (PNG or JPEG):" => "Caminho do arquivo de imagem (PNG ou JPEG):",
"Description (optional):" => "Descrição (opcional):",
"Fits inside the selected cell and moves with its content." => "Ajusta-se dentro da célula selecionada e move-se com o conteúdo dela.",
"Choose a PNG or JPEG no larger than 16 MB." => "Escolha um PNG ou JPEG de no máximo 16 MB.",
"About" => "Sobre",
"Contributors" => "Colaboradores",
"Models" => "Modelos",
"Version {version}" => "Versão {version}",
"Join the ArtCraft Discord" => "Participe do Discord da ArtCraft",
"This workbook has no scripts or macros." => "Esta pasta de trabalho não tem scripts nem macros.",
"(unnamed)" => "(sem nome)",
"— VBA project (preserved, not run)" => "— Projeto VBA (preservado, não executado)",
"— 1 step" => "— 1 etapa",
"— {steps} steps" => "— {steps} etapas",
"Alignment" => "Alinhamento",
"Font" => "Fonte",
"Border" => "Borda",
"Format Cells|Fill" => "Preenchimento",
"Protection" => "Proteção",
"Category:" => "Categoria:",
"Sample" => "Exemplo",
"Type (custom format code):" => "Tipo (código de formato personalizado):",
"Vertical" => "Vertical",
"Justify" => "Justificar",
"Center Across Selection" => "Centralizar Seleção",
"Distributed" => "Distribuído",
"Top" => "Superior",
"Bottom" => "Inferior",
"Indent" => "Recuo",
"Orientation (degrees)" => "Orientação (graus)",
"Wrap text" => "Quebrar texto automaticamente",
"Shrink to fit" => "Reduzir para caber",
"Bold" => "Negrito",
"Italic" => "Itálico",
"None" => "Nenhum",
"Single" => "Simples",
"Double" => "Duplo",
"Single Accounting" => "Simples (Contábil)",
"Double Accounting" => "Duplo (Contábil)",
"Superscript" => "Sobrescrito",
"Subscript" => "Subscrito",
"Color:" => "Cor:",
"Outline" => "Contorno",
"Diagonal ↘" => "Diagonal ↘",
"Diagonal ↗" => "Diagonal ↗",
"none" => "nenhuma",
"thin" => "fina",
"medium" => "média",
"thick" => "espessa",
"dashed" => "tracejada",
"dotted" => "pontilhada",
"double" => "dupla",
"Background Color:" => "Cor do plano de fundo:",
"Locked" => "Bloqueadas",
"Hidden" => "Ocultas",
"Search:" => "Pesquisar:",
"e.g. lookup, average, date" => "p. ex. pesquisa, média, data",
"Date & Time" => "Data e Hora",
"Lookup & Reference" => "Pesquisa e Referência",
"Math & Trig" => "Matemática e Trigonometria",
"Statistical" => "Estatística",
"Engineering" => "Engenharia",
"Database" => "Banco de Dados",
"Compatibility" => "Compatibilidade",
"Web" => "Web",
"Cube" => "Cubo",
"Add-in and Automation" => "Suplemento e Automação",
"Find" => "Localizar",
"Replace" => "Substituir",
"Find what:" => "Localizar:",
"Replace with:" => "Substituir por:",
"Match case" => "Diferenciar maiúsculas e minúsculas",
"Match entire cell contents" => "Coincidir conteúdo da célula inteira",
"Within workbook" => "Na pasta de trabalho",
"Replace All" => "Substituir Tudo",
"Find All" => "Localizar Tudo",
"Find Next" => "Localizar Próxima",
"All done. We made {count} replacements." => "Tudo pronto. Fizemos {count} substituições.",
"{count} cell(s) found" => "{count} célula(s) encontrada(s)",
"Reference:" => "Referência:",
"Special…" => "Especial…",
"My list has headers" => "Meus dados contêm cabeçalhos",
"Sort by" => "Classificar por",
"Then by" => "E depois por",
"Cell Values" => "Valores de Célula",
"Cell Color" => "Cor da Célula",
"A to Z" => "De A a Z",
"Z to A" => "De Z a A",
"+ Add Level" => "+ Adicionar Nível",
"New…" => "Novo…",
"Edit…" => "Editar…",
"Name" => "Nome",
"Value" => "Valor",
"Refers To" => "Refere-se a",
"Scope" => "Escopo",
"Workbook" => "Pasta de Trabalho",
"No names defined" => "Nenhum nome definido",
"Recommended Charts" => "Gráficos Recomendados",
"All Charts" => "Todos os Gráficos",
"Clustered Column" => "Colunas Agrupadas",
"Stacked Column" => "Colunas Empilhadas",
"100% Stacked Column" => "Colunas 100% Empilhadas",
"Clustered Bar" => "Barras Agrupadas",
"Stacked Bar" => "Barras Empilhadas",
"100% Stacked Bar" => "Barras 100% Empilhadas",
"Line" => "Linhas",
"Line with Markers" => "Linhas com Marcadores",
"Stacked Line" => "Linhas Empilhadas",
"Pie" => "Pizza",
"Doughnut" => "Rosca",
"Area" => "Área",
"Stacked Area" => "Área Empilhada",
"Scatter" => "Dispersão",
"Scatter with Lines" => "Dispersão com Linhas",
"Radar" => "Radar",
"Histogram" => "Histograma",
"Waterfall" => "Cascata",
"Funnel" => "Funil",
"Combo" => "Combinação",
"Chart data range:" => "Intervalo de dados do gráfico:",
"Legend Entries (Series)" => "Entradas de Legenda (Série)",
"Series{n}" => "Série{n}",
"Keep" => "Manter",
"Remove" => "Remover",
"Horizontal (Category) Axis Labels:" => "Rótulos do Eixo Horizontal (Categorias):",
"There are no conditional formatting rules on this sheet." => "Não há regras de formatação condicional nesta planilha.",
"applies to" => "aplica-se a",
"Type a command, e.g. freeze, chart, bold…" => "Digite um comando, p. ex. congelar, gráfico, negrito…",
"Set cell:" => "Definir célula:",
"To value:" => "Para valor:",
"By changing cell:" => "Alternando célula:",
"Goal Seeking found a solution: {value}" => "Atingir Meta encontrou uma solução: {value}",
"Analysis Tools" => "Ferramentas de Análise",
"Anova: Single Factor" => "Anova: fator único",
"Correlation" => "Correlação",
"Covariance" => "Covariância",
"Descriptive Statistics" => "Estatística descritiva",
"Exponential Smoothing" => "Ajuste exponencial",
"F-Test Two-Sample for Variances" => "Teste F: duas amostras para variâncias",
"Moving Average" => "Média móvel",
"Rank and Percentile" => "Ordem e percentil",
"Regression" => "Regressão",
"t-Test: Paired Two Sample for Means" => "Teste t: duas amostras em par para médias",
"t-Test: Two-Sample Assuming Equal Variances" => "Teste t: duas amostras presumindo variâncias equivalentes",
"t-Test: Two-Sample Assuming Unequal Variances" => "Teste t: duas amostras presumindo variâncias diferentes",
"Output options" => "Opções de saída",
"Output Range:" => "Intervalo de saída:",
"New Worksheet Ply:" => "Nova planilha:",
"Grouped By:" => "Agrupado por:",
"Labels in first row" => "Rótulos na primeira linha",
"Input Range:" => "Intervalo de entrada:",
"Variable 1 Range:" => "Intervalo da variável 1:",
"Variable 2 Range:" => "Intervalo da variável 2:",
"Summary statistics" => "Resumo estatístico",
"Confidence Level for Mean (%), blank for none:" => "Nível de confiabilidade para a média (%), em branco para nenhum:",
"Kth Largest (blank for none):" => "K-ésimo maior (em branco para nenhum):",
"Kth Smallest (blank for none):" => "K-ésimo menor (em branco para nenhum):",
"Bin Range (blank for automatic bins):" => "Intervalo do bloco (em branco para blocos automáticos):",
"Pareto (sorted histogram)" => "Pareto (histograma classificado)",
"Cumulative Percentage" => "Porcentagem cumulativa",
"Alpha:" => "Alfa:",
"Input Y Range:" => "Intervalo Y de entrada:",
"Input X Range:" => "Intervalo X de entrada:",
"Constant is Zero" => "Constante é zero",
"Confidence Level (%):" => "Nível de confiança (%):",
"Residuals" => "Resíduos",
"Standardized Residuals" => "Resíduos padronizados",
"Normal Probability" => "Plotagem de probabilidade normal",
"Interval:" => "Intervalo:",
"Standard Errors" => "Erros padrão",
"Damping factor:" => "Fator de amortecimento:",
"Hypothesized Mean Difference:" => "Hipótese da diferença de média:",
"Set Objective:" => "Definir Objetivo:",
"To:" => "Para:",
"Value Of:" => "Valor de:",
"By Changing Variable Cells:" => "Alterando Células Variáveis:",
"Subject to the Constraints:" => "Sujeito às Restrições:",
"Make Unconstrained Variables Non-Negative" => "Tornar Variáveis Irrestritas Não Negativas",
"Select a Solving Method:" => "Selecionar um Método de Solução:",
"GRG Nonlinear" => "GRG Não Linear",
"Solve" => "Resolver",
"Keep Solver Solution" => "Manter Solução do Solver",
"Restore Original Values" => "Restaurar Valores Originais",
"The spelling check is complete for the entire sheet." => "A verificação ortográfica da planilha inteira foi concluída.",
"Not in Dictionary ({cell}):" => "Não consta no dicionário ({cell}):",
"Suggestions:" => "Sugestões:",
"(No suggestions)" => "(Nenhuma sugestão)",
"Ignore Once" => "Ignorar uma vez",
"Add to Dictionary" => "Adicionar ao dicionário",
"Change" => "Alterar",
"Change All" => "Alterar todas",
"$ English (United States)" => "$ Inglês (Estados Unidos)",
"Add Chart Element" => "Adicionar Elemento Gráfico",
"Align Left" => "Alinhar à Esquerda",
"Align Right" => "Alinhar à Direita",
"Bottom Align" => "Alinhar Embaixo",
"Calculate Now (F9)" => "Calcular Agora (F9)",
"Calculate Sheet (⇧F9)" => "Calcular Planilha (⇧F9)",
"Calculation Options" => "Opções de Cálculo",
"Cell Styles" => "Estilos de Célula",
"Change Chart Type" => "Alterar Tipo de Gráfico",
"Check Accessibility" => "Verificar Acessibilidade",
"Clear Filter" => "Limpar Filtro",
"Column or Bar" => "Coluna ou Barra",
"Comments pane" => "Painel de comentários",
"Convert the last ink stroke to a shape" => "Converter o último traço de tinta em forma",
"Convert to Range" => "Converter em Intervalo",
"Copy" => "Copiar",
"Custom Sort" => "Classificação Personalizada",
"Cut" => "Recortar",
"Define Name…" => "Definir Nome…",
"Delete Comment" => "Excluir Comentário",
"Delete Sheet" => "Excluir Planilha",
"Delete chart" => "Excluir gráfico",
"Delete…" => "Excluir…",
"Dismiss" => "Fechar",
"Down" => "Para Baixo",
"Edit chart title" => "Editar título do gráfico",
"Erase ink strokes" => "Apagar traços de tinta",
"Every command run in this session (replayable)" => "Todos os comandos executados nesta sessão (podem ser repetidos)",
"Expand formula bar" => "Expandir barra de fórmulas",
"Filter (⌘⇧F)" => "Filtro (⌘⇧F)",
"Filter by Selected Cell's Value" => "Filtrar pelo Valor da Célula Selecionada",
"Find & Select" => "Localizar e Selecionar",
"Flash Fill (⌘E)" => "Preenchimento Relâmpago (⌘E)",
"Forecast Sheet" => "Planilha de Previsão",
"Forecast end (date or number; blank: a third of the history):" => "Fim da previsão (data ou número; em branco: um terço do histórico):",
"Format Chart pane" => "Painel Formatar Gráfico",
"Format as Table" => "Formatar como Tabela",
"Hide" => "Ocultar",
"Hide Sheet" => "Ocultar Planilha",
"Hierarchy & more" => "Hierarquia e mais",
"Highlighter" => "Marca-texto",
"How agents drive GridCraft (MCP / control channel)" => "Como os agentes controlam o GridCraft (MCP / canal de controle)",
"Import a text or CSV file" => "Importar um arquivo de texto ou CSV",
"Insert Function" => "Inserir Função",
"Insert Function (⇧F3)" => "Inserir Função (⇧F3)",
"Insert Link (⌘K)" => "Inserir Link (⌘K)",
"Insert Sheet" => "Inserir Planilha",
"Insert Slicer" => "Inserir Segmentação de Dados",
"Insert a picture" => "Inserir uma imagem",
"Insert a slicer to filter a table" => "Inserir uma segmentação de dados para filtrar uma tabela",
"Insert an icon" => "Inserir um ícone",
"Insert…" => "Inserir…",
"Line or Area" => "Linha ou Área",
"Link…" => "Link…",
"Macros and scripts in this workbook (Alt+F8)" => "Macros e scripts nesta pasta de trabalho (Alt+F8)",
"Middle Align" => "Alinhar no Meio",
"More commands" => "Mais comandos",
"Move or Copy Sheet…" => "Mover ou Copiar Planilha…",
"New sheet (⇧F11)" => "Nova planilha (⇧F11)",
"Next sheet" => "Próxima planilha",
"Page Breaks" => "Quebras",
"Paper Size" => "Tamanho",
"Paste (⌘V)" => "Colar (⌘V)",
"Pick From Drop-down List…" => "Escolher na Lista Suspensa…",
"Pie or Doughnut" => "Pizza ou Rosca",
"Previous sheet" => "Planilha anterior",
"Print Area" => "Área de Impressão",
"Print Titles" => "Imprimir Títulos",
"Protect Workbook structure" => "Proteger estrutura da pasta de trabalho",
"Redo (⌘Y)" => "Refazer (⌘Y)",
"Save (⌘S)" => "Salvar (⌘S)",
"Scatter or Bubble" => "Dispersão ou Bolhas",
"Search and run any command" => "Pesquisar e executar qualquer comando",
"Search commands (⌘⇧Space)" => "Pesquisar comandos (⌘⇧Space)",
"Select and move ink and shapes" => "Selecionar e mover tinta e formas",
"Series…" => "Série…",
"Share: export or save a copy" => "Compartilhar: exportar ou salvar uma cópia",
"Show Formulas (⌃`)" => "Mostrar Fórmulas (⌃`)",
"Slicer" => "Segmentação de Dados",
"Sort & Filter" => "Classificar e Filtrar",
"Spelling (F7)" => "Ortografia (F7)",
"Statistic" => "Estatístico",
"Switch Row/Column" => "Alternar Linha/Coluna",
"Table (⌘T)" => "Tabela (⌘T)",
"Table Styles" => "Estilos de Tabela",
"Text to Columns" => "Texto para Colunas",
"The output range already holds data. Replace it with the report?" => "O intervalo de saída já contém dados. Substituí-los pelo relatório?",
"Top Align" => "Alinhar em Cima",
"Undo (⌘Z)" => "Desfazer (⌘Z)",
"Undo list" => "Lista de desfazer",
"Unhide Sheet…" => "Reexibir Planilha…",
"Up" => "Para Cima",
"Version, contributors and the AI models that helped" => "Versão, colaboradores e os modelos de IA que ajudaram",
"What-If Analysis" => "Teste de Hipóteses",
"Zoom In" => "Ampliar",
"Zoom Out" => "Reduzir",
"Zoom to 100%" => "Zoom em 100%",
"Zoom to Selection" => "Zoom na Seleção",
"Zoom…" => "Zoom…",
"£ English (United Kingdom)" => "£ Inglês (Reino Unido)",
"¥ Japanese" => "¥ Japonês",
"€ Euro" => "€ Euro",
"Tab|Insert" => "Inserir",
"AutoSave" => "Salvamento Automático",
"Insert\nSlicer" => "Inserir\nSegmentação",
"Pen\nBlack" => "Caneta\nPreta",
"Pen\nBlue" => "Caneta\nAzul",
"Pen\nRed" => "Caneta\nVermelha",
"Protect\nSheet" => "Proteger\nPlanilha",
"Watch\nWindow" => "Janela de\nInspeção",
"Workbook\nStatistics" => "Estatísticas da\nPasta de Trabalho",
_ => return None,
})
}
+17
View File
@@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Select\nObjects" => "Выбрать\nобъекты",
"Themes" => "Темы",
"Margins" => "Поля",
"Left Margin" => "Левое поле",
"Right Margin" => "Правое поле",
"Top Margin" => "Верхнее поле",
"Bottom Margin" => "Нижнее поле",
"Header Margin" => "Поле верхнего колонтитула",
"Footer Margin" => "Поле нижнего колонтитула",
"Size" => "Размер",
"Print\nArea" => "Область\nпечати",
"Set Print Area" => "Задать область печати",
@@ -342,6 +348,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Recent" => "Последние",
"No recent workbooks yet." => "Недавних книг пока нет.",
"More workbooks" => "Другие книги",
"Modified" => "Изменён",
"Not available" => "Недоступен",
"Just now" => "Только что",
"Yesterday" => "Вчера",
"{n} min ago" => "{n} мин назад",
"{n} h ago" => "{n} ч назад",
"{n} days ago" => "{n} дн. назад",
"{n} weeks ago" => "{n} нед. назад",
"{n} months ago" => "{n} мес. назад",
"{n} years ago" => "{n} г. назад",
"Browse…" => "Обзор…",
"Not saved yet" => "Ещё не сохранено",
"Location" => "Расположение",
@@ -389,6 +405,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Общие",
"Options|Save" => "Сохранение",
"Show formula bar" => "Показывать строку формул",
"Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек",
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
"Workbook calculation" => "Вычисления в книге",
"Enable iterative calculation" => "Включить итеративные вычисления",

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